Repository: cta-observatory/ctapipe
Pull request #2670: Muon parameters update
Author: login: jstvdk
Labels: name: module:calib
description: issues related to ctapipe.calib
Created: 2024-12-10T10:01:34Z | Merged: 2025-03-27T17:03:50Z

I added new functionality to the muon analysis as described in the Issue #2665 , specifically

- new fields to the `MuonParametersContainer`
- new methods to the `ctapipe/image/muon/features.py`
- new tests for above mentioned methods in `ctapipe/image/muon/tests/test_muon_features.py`
- additional code block in `ctapipe/image/muon/processor.py` to process and save new muon parameters

Added changes mainly follow existing code in `lstchain/image/muon/muon_analysis.py`, with one exception:

- I redefined the variable `num_pixels_in_ring` - now it takes into account only pixels that survived the cleaning, contrary to what currently implemented in `lstchain`, where this variable contains all pixels inside the geometrical area of the ring.

Closes #2665 

Linked issue #2665: Implement Additional Muon Parameters in ctapipe for LST Analysis
Currently, we are lacking some useful muon parameters that are used in processing LST-1 data and can be beneficial for subsequent analysis. I think it would be good to have them in `ctapipe` because we will need to use them in `calibpipe` for the derivation of calibration coefficients. For example, such muon metrics as:

- `ring_size` and `size_outside` - the sum of the photoelectrons in the ring area, defined as a region `dist_mask` = (R-0.25R; R+0.25R), and outside of it. 
- `mean_pixel_charge_around_ring` - average pixel charge in pixels surrounding the outer part of the ring
- `radial_stdev` - standard deviation of (cleaned) light distribution along ring radius
- `radial_skewness` - skewness of (cleaned) light distribution along ring radius
- `radial_excess_kurtosis` - excess kurtosis of (cleaned) light distribution along ring radius
- `num_pixels_in_ring` - number of pixels in the integration area `dist_mask`


Taking the above into account, I would like to:

- Add methods for calculating these parameters to the module `ctapipe.image.muon.features` (following already implemented functionality in the `lstchain`).
- Add appropriate fields to the `MuonParameterContainers` to store the new parameters.
- Implement calculation of these parameters inside `MuonProcessor._calculate_muon_parameters()`

Please comment in case something is missing or we should follow other direction. Otherwise I can start to create corresponding PRs and implement these features.

Linked issue comments: user: login: maxnoe
created_at: 2024-12-03T16:21:41Z
body: > ring_size and size_outside - the sum of the photoelectrons in the ring area, defined as a region dist_mask = (R-0.25R; R+0.25R), and outside of it.

We have something that is closely related: `intensity_ratio_inside_ring`:
https://github.com/cta-observatory/ctapipe/blob/11561812f28c8a7f9f77e4b08066cba4ccd3a32c/src/ctapipe/image/muon/features.py#L37-L44


But other than that, sounds good!
author_association: MEMBER
user: login: jstvdk
created_at: 2024-12-03T16:44:32Z
body: > > ring_size and size_outside - the sum of the photoelectrons in the ring area, defined as a region dist_mask = (R-0.25R; R+0.25R), and outside of it.
> 
> We have something that is closely related: `intensity_ratio_inside_ring`:

Thanks for the comment, indeed this `intensity_ratio_inside_ring` looks good to isolate pure muon ring events apart from contaminated rings, which can currently partially be done by parameter `size_outside` in `lstchain`.
But apart from this, we still will need `ring_size` parameter for some of the `optical_efficiency` studies.

> But other than that, sounds good!

Cool, then I am starting to work on it.
author_association: CONTRIBUTOR

Diff:
diff --git a/docs/changes/2670.feature.rst b/docs/changes/2670.feature.rst
new file mode 100644
index 00000000000..fb6839a364f
--- /dev/null
+++ b/docs/changes/2670.feature.rst
@@ -0,0 +1,13 @@
+Add new fields to the MuonParametersContainer.
+
+Implement the computation of the new features listed below
+and refactor the code using the existing containers.
+
+Added fields:
+- ring_intensity
+- intensity_outside_ring
+- n_pixels_in_ring
+- mean_intensity_outside_ring
+- radial_std_dev
+- skeweness
+- excess_kurtosis
diff --git a/src/ctapipe/containers.py b/src/ctapipe/containers.py
index 311142311a3..0796ac11533 100644
--- a/src/ctapipe/containers.py
+++ b/src/ctapipe/containers.py
@@ -33,6 +33,11 @@
     "MonitoringCameraContainer",
     "MonitoringContainer",
     "MorphologyContainer",
+    "MuonRingContainer",
+    "MuonEfficiencyContainer",
+    "MuonParametersContainer",
+    "MuonTelescopeContainer",
+    "MuonContainer",
     "BaseHillasParametersContainer",
     "CameraHillasParametersContainer",
     "CameraTimingParametersContainer",
@@ -1159,7 +1164,7 @@ class MuonRingContainer(Container):
         nan * u.deg, "Angle of ring center within camera plane", unit=u.deg
     )
     center_distance = Field(
-        nan * u.deg, "Distance of ring center from camera center", unit=u.deg
+        nan * u.deg, "Distance from the ring center to camera center", unit=u.deg
     )
 
 
@@ -1189,6 +1194,26 @@ class MuonParametersContainer(Container):
         nan * u.deg**2,
         "MSE of the deviation of all pixels after cleaning from the ring fit",
     )
+    ring_intensity = Field(
+        nan, "Sum of the pixel charges inside the integration area around a ring"
+    )
+    intensity_outside_ring = Field(nan, "Sum of the pixel charges outside the ring")
+    n_pixels_in_ring = Field(
+        -1,
+        "Number of pixels inside the ring integration area that passed the cleaning",
+    )
+    mean_intensity_outside_ring = Field(
+        nan,
+        "Mean intensity of pixels inside the region limited by ring integration width and outer ring width.",
+    )
+    radial_std_dev = Field(
+        nan * u.deg,
+        "Standard deviation of the radial light distribution.",
+    )
+    skewness = Field(nan, "Skewness of the light distribution along the ring radius.")
+    excess_kurtosis = Field(
+        nan, "Excess kurtosis of the light distribution along the ring radius."
+    )
 
 
 class MuonTelescopeContainer(Container):
diff --git a/src/ctapipe/image/muon/features.py b/src/ctapipe/image/muon/features.py
index 352073ed959..8d547bf52c5 100644
--- a/src/ctapipe/image/muon/features.py
+++ b/src/ctapipe/image/muon/features.py
@@ -1,69 +1,103 @@
+from typing import Sequence
+
 import astropy.units as u
 import numpy as np
+from astropy.units import Quantity
 
-from ...utils.quantities import all_to_value
+from ...containers import MuonRingContainer
 
 __all__ = [
     "mean_squared_error",
     "intensity_ratio_inside_ring",
     "ring_completeness",
     "ring_containment",
+    "ring_intensity_parameters",
+    "radial_light_distribution",
 ]
 
 
-def mean_squared_error(pixel_x, pixel_y, weights, radius, center_x, center_y):
+def mean_squared_error(
+    pixel_fov_lon: Quantity,
+    pixel_fov_lat: Quantity,
+    weights: Quantity | np.ndarray | Sequence[float],
+    ring: MuonRingContainer,
+) -> float:
     """
-    Calculate the weighted mean squared error for a circle
+    Calculate the weighted mean squared error for a circle.
 
     Parameters
     ----------
-    pixel_x: array-like
-        x coordinates of the camera pixels
-    pixel_y: array-like
-        y coordinates of the camera pixels
-    weights: array-like
-        weights for the camera pixels, will usually be the pe charges
-    radius: float
-        radius of the ring
-    center_x: float
-        x coordinate of the ring center
-    center_y: float
-        y coordinate of the ring center
+    pixel_fov_lon : Quantity
+        Longitudes (x-coordinates) of the camera pixels in the TelescopeFrame.
+    pixel_fov_lat : Quantity
+        Latitudes (y-coordinates) of the camera pixels in the TelescopeFrame.
+    weights : Quantity | np.ndarray | Sequence[float]
+        Weights for the camera pixels, usually the photoelectron charges.
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+
+    Returns
+    -------
+    float
+        The weighted mean squared error of the pixels around the fitted ring.
+
+    Notes
+    -----
+    This function calculates the weighted mean squared error of the pixels around
+    the fitted ring by determining the radial distance of each pixel from the ring
+    center and comparing it to the ring radius. The mean squared error is weighted
+    by the pixel intensities.
     """
-    r = np.sqrt((center_x - pixel_x) ** 2 + (center_y - pixel_y) ** 2)
-    return np.average((r - radius) ** 2, weights=weights)
+    r = np.hypot(
+        ring.center_fov_lon - pixel_fov_lon, ring.center_fov_lat - pixel_fov_lat
+    )
+    return np.average((r - ring.radius) ** 2, weights=weights)
 
 
 def intensity_ratio_inside_ring(
-    pixel_x, pixel_y, weights, radius, center_x, center_y, width
-):
+    pixel_fov_lon: Quantity,
+    pixel_fov_lat: Quantity,
+    weights: Quantity | np.ndarray | Sequence[float],
+    ring: MuonRingContainer,
+    width: Quantity,
+) -> float:
     """
     Calculate the ratio of the photons inside a given ring with
-    coordinates (center_x, center_y), radius and width.
+    coordinates (center_fov_lon, center_fov_lat), radius and width.
 
     The ring is assumed to be in [radius - 0.5 * width, radius + 0.5 * width]
 
     Parameters
     ----------
-    pixel_x: array-like
-        x coordinates of the camera pixels
-    pixel_y: array-like
-        y coordinates of the camera pixels
-    weights: array-like
-        weights for the camera pixels, will usually be the pe charges
-    radius: float
-        radius of the ring
-    center_x: float
-        x coordinate of the ring center
-    center_y: float
-        y coordinate of the ring center
-    width: float
-        width of the ring
+    pixel_fov_lon : Quantity
+        Longitudes (x-coordinates) of the camera pixels in the TelescopeFrame.
+    pixel_fov_lat : Quantity
+        Latitudes (y-coordinates) of the camera pixels in the TelescopeFrame.
+    weights : Quantity | np.ndarray | Sequence[float]
+        Weights for the camera pixels, usually the photoelectron charges.
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    width : Quantity
+        Width of the ring.
+
+    Returns
+    -------
+    float
+        The ratio of the photons inside the ring to the total photons.
+
+    Notes
+    -----
+    This function calculates the ratio of the photons inside a given ring by
+    determining the pixels that fall within the specified ring width and summing
+    their weights. The ratio is the sum of the weights inside the ring divided by
+    the total sum of the weights.
     """
 
-    pixel_r = np.sqrt((center_x - pixel_x) ** 2 + (center_y - pixel_y) ** 2)
+    pixel_r = np.hypot(
+        ring.center_fov_lon - pixel_fov_lon, ring.center_fov_lat - pixel_fov_lat
+    )
     mask = np.logical_and(
-        pixel_r >= radius - 0.5 * width, pixel_r <= radius + 0.5 * width
+        pixel_r >= ring.radius - 0.5 * width, pixel_r <= ring.radius + 0.5 * width
     )
 
     inside = weights[mask].sum()
@@ -73,88 +107,243 @@ def intensity_ratio_inside_ring(
 
 
 def ring_completeness(
-    pixel_x, pixel_y, weights, radius, center_x, center_y, threshold=30, bins=30
-):
+    pixel_fov_lon: Quantity,
+    pixel_fov_lat: Quantity,
+    weights: Quantity | np.ndarray | Sequence[float],
+    ring: MuonRingContainer,
+    threshold: float = 30,
+    bins: int = 30,
+) -> float:
     """
-    Estimate how complete a ring is.
-    Bin the light distribution along the the ring and apply a threshold to the
-    bin content.
+    Estimate how complete a muon ring is by binning the light distribution along the ring
+    and applying a threshold to the bin content.
 
     Parameters
     ----------
-    pixel_x: array-like
-        x coordinates of the camera pixels
-    pixel_y: array-like
-        y coordinates of the camera pixels
-    weights: array-like
-        weights for the camera pixels, will usually be the pe charges
-    radius: float
-        radius of the ring
-    center_x: float
-        x coordinate of the ring center
-    center_y: float
-        y coordinate of the ring center
-    threshold: float
-        number of photons a bin must contain to be counted
-    bins: int
-        number of bins to use for the histogram
+    pixel_fov_lon : Quantity
+        Longitudes (x-coordinates) of the camera pixels in the TelescopeFrame.
+    pixel_fov_lat : Quantity
+        Latitudes (y-coordinates) of the camera pixels in the TelescopeFrame.
+    weights : array-like
+        Weights for the camera pixels, usually the photoelectron charges.
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    threshold : float, optional
+        Number of photoelectrons a bin must contain to be counted. Default is 30.
+    bins : int, optional
+        Number of bins to use for the histogram. Default is 30.
 
     Returns
     -------
-    ring_completeness: float
-        the ratio of bins above threshold
+    float
+        The ratio of bins above the threshold, representing the completeness of the ring.
+
+    Notes
+    -----
+    This function calculates the completeness of the muon ring by dividing the ring into
+    segments and counting the number of segments that have a light intensity above a given
+    threshold. The completeness is the ratio of the number of segments above the threshold
+    to the total number of segments.
     """
 
-    angle = np.arctan2(pixel_y - center_y, pixel_x - center_x)
-    if hasattr(angle, "unit"):
-        angle = angle.to_value(u.rad)
+    if hasattr(weights, "unit"):
+        weights = weights.to_value(u.dimensionless_unscaled)
+    angle = np.arctan2(
+        (pixel_fov_lat - ring.center_fov_lat).to_value(u.rad),
+        (pixel_fov_lon - ring.center_fov_lon).to_value(u.rad),
+    )
 
-    hist, _ = np.histogram(angle, bins=bins, range=[-np.pi, np.pi], weights=weights)
+    hist, _ = np.histogram(
+        angle,
+        bins=bins,
+        range=[-np.pi, np.pi],
+        weights=weights,
+    )
 
     bins_above_threshold = hist > threshold
 
     return np.sum(bins_above_threshold) / bins
 
 
-def ring_containment(radius, center_x, center_y, camera_radius):
+def ring_containment(ring: MuonRingContainer, camera_radius: Quantity) -> float:
     """
-    Estimate angular containment of a ring inside the camera
-    (camera center is (0,0))
+    Estimate the angular containment of a muon ring inside the camera's field of view.
 
-    Improve: include the case of an arbitrary
-    center for the camera
-
-    See https://stackoverflow.com/questions/3349125/circle-circle-intersection-points
+    This function calculates the fraction of the muon ring that is contained within
+    the camera's field of view. It uses geometric properties to determine the intersection
+    of the ring with the circular boundary of the camera.
 
     Parameters
     ----------
-    radius: float or quantity
-        radius of the muon ring
-    center_x: float or quantity
-        x coordinate of the center of the muon ring
-    center_y: float or quantity
-        y coordinate of the center of the muon ring
-    camera_radius: float or quantity
-        radius of the camera
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    camera_radius : `astropy.units.Quantity`
+        Radius of the camera's field of view (in degrees).
 
     Returns
     -------
-    ringcontainment: float
-        the ratio of ring inside the camera
-    """
-    if hasattr(radius, "unit"):
-        radius, center_x, center_y, camera_radius = all_to_value(
-            radius, center_x, center_y, camera_radius, unit=radius.unit
-        )
-    d = np.sqrt(center_x**2 + center_y**2)
+    float
+        The fraction of the ring that is inside the camera's field of view, ranging from 0.0 to 1.0.
 
+    Notes
+    -----
+    The calculation is based on the geometric intersection of two circles:
+    the muon ring and the camera's field of view. The method handles cases where:
+    - The ring is fully contained within the camera.
+    - The ring is partially contained within the camera.
+    - The ring is completely outside the camera.
+
+    References
+    ----------
+    See https://stackoverflow.com/questions/3349125/circle-circle-intersection-points
+    for the geometric approach to circle-circle intersection.
+    """
     # one circle fully contained in the other
-    if d <= np.abs(camera_radius - radius):
+    if ring.center_distance <= np.abs(camera_radius - ring.radius):
         return 1.0
 
     # no intersection
-    if d > (radius + camera_radius):
+    if ring.center_distance > (ring.radius + camera_radius):
         return 0.0
 
-    a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
-    return np.arccos(a / radius) / np.pi
+    a = (ring.radius**2 - camera_radius**2 + ring.center_distance**2) / (
+        2 * ring.center_distance
+    )
+    return np.arccos((a / ring.radius).to_value(u.dimensionless_unscaled)) / np.pi
+
+
+def ring_intensity_parameters(
+    ring: MuonRingContainer,
+    pixel_fov_lon: Quantity,
+    pixel_fov_lat: Quantity,
+    ring_integration_width: float,
+    outer_ring_width: float,
+    image: np.ndarray,
+    image_mask: np.ndarray,
+) -> tuple[float, float, int, float]:
+    """
+    Calculate the parameters related to the size of the ring image.
+
+    Parameters
+    ----------
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    pixel_fov_lon : Quantity
+        Longitudes (x-coordinates) of the camera pixels in the TelescopeFrame.
+    pixel_fov_lat : Quantity
+        Latitudes (y-coordinates) of the camera pixels in the TelescopeFrame.
+    ring_integration_width : float
+        Width of the ring in fractions of ring radius.
+    outer_ring_width : float
+        Width of the outer ring in fractions of ring radius.
+    image : np.ndarray
+        Amplitude of image pixels.
+    image_mask : np.ndarray
+        Mask of the camera pixels after cleaning.
+
+    Returns
+    -------
+    ring_intensity : float
+        Sum of the p.e. inside the integration area of the ring.
+    intensity_outside_ring : float
+        Sum of the p.e. outside the ring integration area that passed the cleaning.
+    n_pixels_in_ring : int
+        Number of pixels inside the ring integration area that passed the cleaning.
+    mean_intensity_outside_ring : float
+        Mean intensity of the pixels outside the integration area of the ring,
+        and restricted by the outer ring width, i.e. in the strip between
+        ring integration width and outer ring width.
+    """
+
+    dist = np.hypot(
+        pixel_fov_lon - ring.center_fov_lon, pixel_fov_lat - ring.center_fov_lat
+    )
+    dist_mask = np.abs(dist - ring.radius) < (ring.radius * ring_integration_width)
+    pix_ring = image * dist_mask
+    pix_outside_ring = image * ~dist_mask
+
+    dist_mask_2 = np.logical_and(
+        ~dist_mask,
+        np.abs(dist - ring.radius)
+        < ring.radius * (ring_integration_width + outer_ring_width),
+    )
+    pix_ring_2 = image[dist_mask_2]
+
+    ring_intensity = np.sum(pix_ring)
+    intensity_outside_ring = np.sum(pix_outside_ring * image_mask)
+    n_pixels_in_ring = np.sum(dist_mask & image_mask)
+    mean_intensity_outside_ring = np.sum(pix_ring_2) / len(pix_ring_2)
+
+    return (
+        ring_intensity,
+        intensity_outside_ring,
+        n_pixels_in_ring,
+        mean_intensity_outside_ring,
+    )
+
+
+def radial_light_distribution(
+    center_fov_lon, center_fov_lat, pixel_fov_lon, pixel_fov_lat, image
+):
+    """
+    Calculate the radial distribution of the muon ring.
+
+    Parameters
+    ----------
+    center_fov_lon : float
+        Longitude of the ring center in the TelescopeFrame (in degrees).
+    center_fov_lat : float
+        Latitude of the ring center in the TelescopeFrame (in degrees).
+    pixel_fov_lon : array-like
+        Longitudes (x-coordinates) of the camera pixels in the TelescopeFrame (in degrees).
+    pixel_fov_lat : array-like
+        Latitudes (y-coordinates) of the camera pixels in the TelescopeFrame (in degrees).
+    image : array-like
+        Amplitudes of image pixels.
+
+    Returns
+    -------
+    radial_std_dev : `astropy.units.Quantity`
+        Standard deviation of the light distribution in degrees.
+        Spread of pixel intensities around the mean radial distance from the ring center.
+    skewness : `astropy.units.Quantity`
+        Skewness of the radial light distribution (dimensionless).
+        Measures the asymmetry of the distribution around the mean radius.
+    excess_kurtosis : `astropy.units.Quantity`
+        Excess kurtosis of the radial light distribution (dimensionless).
+        Indicates the "tailedness" of the distribution compared to a normal distribution.
+
+    Notes
+    -----
+    This function calculates the statistical properties of the radial distribution
+    of light in an image with respect to the reconstructed muon ring center. It computes
+    the standard deviation, skewness, and excess kurtosis of the radial distances of the pixels
+    from the center of the ring, weighted by the pixel intensities.
+    """
+
+    if np.sum(image) == 0:
+        return (
+            np.nan * u.deg,
+            np.nan * u.dimensionless_unscaled,
+            np.nan * u.dimensionless_unscaled,
+        )
+
+    pixel_r = np.hypot(pixel_fov_lon - center_fov_lon, pixel_fov_lat - center_fov_lat)
+
+    mean = np.average(pixel_r, weights=image)
+    delta_r = pixel_r - mean
+    radial_std_dev = np.sqrt(np.average(delta_r**2, weights=image))
+    skewness = (
+        np.average(delta_r**3, weights=image)
+        / radial_std_dev**3
+        * u.dimensionless_unscaled
+    )
+    excess_kurtosis = (
+        np.average(delta_r**4, weights=image) / radial_std_dev**4 - 3.0
+    ) * u.dimensionless_unscaled
+
+    return (
+        radial_std_dev,
+        skewness,
+        excess_kurtosis,
+    )
diff --git a/src/ctapipe/image/muon/processor.py b/src/ctapipe/image/muon/processor.py
index 30b382aef2c..4a877b92e08 100644
--- a/src/ctapipe/image/muon/processor.py
+++ b/src/ctapipe/image/muon/processor.py
@@ -1,6 +1,7 @@
 """
 High level muon analysis  (MuonProcessor Component)
 """
+
 import numpy as np
 
 from ctapipe.containers import (
@@ -15,8 +16,10 @@
 from .features import (
     intensity_ratio_inside_ring,
     mean_squared_error,
+    radial_light_distribution,
     ring_completeness,
     ring_containment,
+    ring_intensity_parameters,
 )
 from .intensity_fitter import MuonIntensityFitter
 from .ring_fitter import MuonRingFitter
@@ -95,6 +98,22 @@ class MuonProcessor(TelescopeComponent):
         help="Pedestal noise rms", default_value=1.1
     ).tag(config=True)
 
+    ring_integration_width = FloatTelescopeParameter(
+        default_value=0.25,
+        help=(
+            "Width of the ring in units of the ring radius, "
+            "used for computing the ring size in charge units."
+        ),
+    ).tag(config=True)
+
+    outer_ring_width = FloatTelescopeParameter(
+        default_value=0.2,
+        help=(
+            "Width of the outer ring in units of the ring radius, "
+            "used for computing the charge outside the ring."
+        ),
+    ).tag(config=True)
+
     def __init__(self, subarray, **kwargs):
         super().__init__(subarray, **kwargs)
         self.dl1_query = ImageParameterQuery(parent=self)
@@ -234,17 +253,13 @@ def _calculate_muon_parameters(
         fov_lat = geometry.pix_y
 
         # add ring containment, not filled in fit
-        containment = ring_containment(
-            ring.radius, ring.center_fov_lon, ring.center_fov_lat, fov_radius
-        )
+        containment = ring_containment(ring, fov_radius)
 
         completeness = ring_completeness(
             fov_lon,
             fov_lat,
             image,
-            ring.radius,
-            ring.center_fov_lon,
-            ring.center_fov_lat,
+            ring,
             threshold=self.completeness_threshold.tel[tel_id],
         )
 
@@ -253,9 +268,7 @@ def _calculate_muon_parameters(
             fov_lon[clean_mask],
             fov_lat[clean_mask],
             image[clean_mask],
-            ring.radius,
-            ring.center_fov_lon,
-            ring.center_fov_lat,
+            ring,
             width=self.ratio_width.tel[tel_id] * pixel_width,
         )
 
@@ -263,9 +276,30 @@ def _calculate_muon_parameters(
             fov_lon[clean_mask],
             fov_lat[clean_mask],
             image[clean_mask],
-            ring.radius,
+            ring,
+        )
+
+        (
+            ring_intensity,
+            intensity_outside_ring,
+            n_pixels_in_ring,
+            mean_intensity_outside_ring,
+        ) = ring_intensity_parameters(
+            ring,
+            fov_lon,
+            fov_lat,
+            self.ring_integration_width.tel[tel_id],
+            self.outer_ring_width.tel[tel_id],
+            image,
+            clean_mask,
+        )
+
+        radial_std_dev, skewness, excess_kurtosis = radial_light_distribution(
             ring.center_fov_lon,
             ring.center_fov_lat,
+            fov_lon,
+            fov_lat,
+            image,
         )
 
         return MuonParametersContainer(
@@ -273,4 +307,11 @@ def _calculate_muon_parameters(
             completeness=completeness,
             intensity_ratio=intensity_ratio,
             mean_squared_error=mse,
+            ring_intensity=ring_intensity,
+            intensity_outside_ring=intensity_outside_ring,
+            n_pixels_in_ring=n_pixels_in_ring,
+            mean_intensity_outside_ring=mean_intensity_outside_ring,
+            radial_std_dev=radial_std_dev,
+            skewness=skewness,
+            excess_kurtosis=excess_kurtosis,
         )
diff --git a/src/ctapipe/image/muon/tests/test_muon_features.py b/src/ctapipe/image/muon/tests/test_muon_features.py
index 85a950ec68e..0ed8e104a01 100644
--- a/src/ctapipe/image/muon/tests/test_muon_features.py
+++ b/src/ctapipe/image/muon/tests/test_muon_features.py
@@ -3,32 +3,36 @@
 import astropy.units as u
 import numpy as np
 
-from ctapipe.image.muon.features import ring_completeness, ring_containment
+from ctapipe.containers import MuonRingContainer
+from ctapipe.image.muon.features import (
+    radial_light_distribution,
+    ring_completeness,
+    ring_containment,
+    ring_intensity_parameters,
+)
 
 
 def test_ring_containment():
-    ring_radius = 1 * u.deg
+    ring = MuonRingContainer(
+        radius=1 * u.deg,
+        center_fov_lon=0 * u.deg,
+        center_fov_lat=0 * u.deg,
+        center_phi=0 * u.deg,
+        center_distance=0 * u.deg,
+    )
     cam_radius = 4 * u.deg
 
-    ring_center_x = 0 * u.deg
-    ring_center_y = 0 * u.deg
-    containment = ring_containment(
-        ring_radius, ring_center_x, ring_center_y, cam_radius
-    )
+    containment = ring_containment(ring, cam_radius)
     assert containment == 1.0
 
-    ring_center_x = 0 * u.deg
-    ring_center_y = cam_radius
-    containment = ring_containment(
-        ring_radius, ring_center_x, ring_center_y, cam_radius
-    )
+    ring.center_fov_lat = cam_radius
+    ring.center_distance = cam_radius
+    containment = ring_containment(ring, cam_radius)
     assert 0.4 <= containment <= 0.5
 
-    ring_center_x = 0 * u.deg
-    ring_center_y = cam_radius + 1.1 * ring_radius
-    containment = ring_containment(
-        ring_radius, ring_center_x, ring_center_y, cam_radius
-    )
+    ring.center_fov_lat = cam_radius + 1.1 * ring.radius
+    ring.center_distance = cam_radius + 1.1 * ring.radius
+    containment = ring_containment(ring, cam_radius)
     assert containment == 0.0
 
 
@@ -36,17 +40,124 @@ def test_ring_completeness():
     rng = np.random.default_rng(0)
 
     angle_ring = np.linspace(0, 2 * math.pi, 360)
-    x = np.cos(angle_ring) * u.m
-    y = np.sin(angle_ring) * u.m
-    pe = rng.uniform(0, 100, len(x))
-    ring_radius = 1.0 * u.m
+    lon = np.cos(angle_ring) * u.deg
+    lat = np.sin(angle_ring) * u.deg
+    pe = rng.uniform(50, 100, len(lon))
+    ring = MuonRingContainer(
+        radius=1.0 * u.deg,
+        center_fov_lon=0.0 * u.deg,
+        center_fov_lat=0.0 * u.deg,
+    )
+
+    ring_comp = ring_completeness(lon, lat, pe, ring, threshold=30, bins=30)
+
+    # Since we are generating a complete ring with uniform weights, we expect the completeness to be 1.0
+    assert ring_comp == 1.0
+
+    # Test with a partial ring
+    partial_pe = np.concatenate([pe[:180], np.zeros(180)])
+    ring_comp_partial = ring_completeness(
+        lon, lat, partial_pe, ring, threshold=30, bins=30
+    )
+
+    # Since half of the ring is missing, we expect the completeness to be around 0.5
+    assert 0.4 <= ring_comp_partial <= 0.6
+
+
+def test_ring_intensity_parameters():
+    ring = MuonRingContainer(
+        radius=1 * u.deg,
+        center_fov_lon=0 * u.deg,
+        center_fov_lat=0 * u.deg,
+    )
+    pixel_fov_lon = np.linspace(-2, 2, 1855) * u.deg
+    pixel_fov_lat = np.linspace(-2, 2, 1855) * u.deg
+    ring_integration_width = 0.25
+    outer_ring_width = 0.2
+
+    # Create a synthetic image with known properties
+    image = np.ones(1855)  # Uniform intensity
+    image_mask = np.ones(1855, dtype=bool)  # All pixels are valid
+
+    # Calculate expected values
+    dist = np.hypot(
+        pixel_fov_lon - ring.center_fov_lon, pixel_fov_lat - ring.center_fov_lat
+    )
+    dist_mask = np.abs(dist - ring.radius) < (ring.radius * ring_integration_width)
+    ring_intensity_expected = np.sum(image[dist_mask])
+    intensity_outside_ring_expected = np.sum(image[~dist_mask])
+    n_pixels_in_ring_expected = np.sum(dist_mask)
+    dist_mask_2 = np.logical_and(
+        ~dist_mask,
+        np.abs(dist - ring.radius)
+        < ring.radius * (ring_integration_width + outer_ring_width),
+    )
+    mean_intensity_outside_ring_expected = np.sum(image[dist_mask_2]) / len(
+        image[dist_mask_2]
+    )
+
+    (
+        ring_intensity,
+        intensity_outside_ring,
+        n_pixels_in_ring,
+        mean_intensity_outside_ring,
+    ) = ring_intensity_parameters(
+        ring,
+        pixel_fov_lon,
+        pixel_fov_lat,
+        ring_integration_width,
+        outer_ring_width,
+        image,
+        image_mask,
+    )
+
+    assert np.isclose(ring_intensity, ring_intensity_expected, rtol=1e-2)
+    assert np.isclose(
+        intensity_outside_ring, intensity_outside_ring_expected, rtol=1e-2
+    )
+    assert n_pixels_in_ring == n_pixels_in_ring_expected
+    assert np.isclose(
+        mean_intensity_outside_ring, mean_intensity_outside_ring_expected, rtol=1e-2
+    )
+
+
+def test_radial_light_distribution():
+    center_x = 0.0 * u.deg
+    center_y = 0.0 * u.deg
+    pixel_x = np.linspace(-2, 2, 1855) * u.deg
+    pixel_y = np.linspace(-2, 2, 1855) * u.deg
+
+    # Create a synthetic image with known properties
+    image = np.ones(1855)  # Uniform intensity
+
+    # Calculate expected values
+    expected_std_dev = (
+        np.sqrt(8) / np.sqrt(12) * u.deg
+    )  # (b - a) / sqrt(12) where b-a is a diagonal of a square with the side length 4
+    expected_skewness = 0.0  # Uniform distribution has zero skewness
+    expected_excess_kurtosis = -1.2  # Uniform distribution has excess kurtosis of -1.2
+
+    radial_std_dev, skewness, excess_kurtosis = radial_light_distribution(
+        center_x, center_y, pixel_x, pixel_y, image
+    )
+
+    assert radial_std_dev.unit == u.deg
+    assert np.isclose(radial_std_dev, expected_std_dev, atol=1e-2)
+    assert np.isclose(skewness, expected_skewness, atol=1e-2)
+    assert np.isclose(excess_kurtosis, expected_excess_kurtosis, atol=1e-2)
+
 
-    ring_center_x = 0.0 * u.m
-    ring_center_y = 0.0 * u.m
+def test_radial_light_distribution_zero_image():
+    center_x = 0.0 * u.deg
+    center_y = 0.0 * u.deg
+    pixel_x = np.linspace(-10, 10, 1855) * u.deg
+    pixel_y = np.linspace(-10, 10, 1855) * u.deg
+    image = np.zeros(1855)
 
-    ring_comp = ring_completeness(
-        x, y, pe, ring_radius, ring_center_x, ring_center_y, 30, 30
+    radial_std_dev, skewness, excess_kurtosis = radial_light_distribution(
+        center_x, center_y, pixel_x, pixel_y, image
     )
 
-    assert ring_comp <= 1
-    assert ring_comp >= 0
+    assert np.isnan(radial_std_dev)
+    assert np.isnan(skewness)
+    assert np.isnan(excess_kurtosis)


Reviews:
user: login: maxnoe
body: Hi @jstvdk 

Thanks a lot for preparing this. This looks like a good starting point. I have a couple of first comments mainly about the names of the fields and their descriptions.

I will do second round on more technical issues of the code later.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2024-12-13T11:38:48Z
user: login: kosack
state: DISMISSED
author_association: MEMBER
submitted_at: 2024-12-18T14:28:42Z
user: login: mexanick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-19T10:50:53Z
user: login: jstvdk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-19T10:58:28Z
user: login: mexanick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-19T13:14:08Z
user: login: mexanick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-19T13:14:51Z
user: login: mexanick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-19T13:15:53Z
user: login: jstvdk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-19T13:34:43Z
user: login: jstvdk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-19T13:36:33Z
user: login: mexanick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-12-19T15:31:42Z
user: login: maxnoe
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T09:33:11Z
user: login: mexanick
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T10:03:37Z
user: login: maxnoe
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T10:59:32Z
user: login: maxnoe
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-11T12:08:59Z
user: login: maxnoe
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T12:25:26Z
user: login: maxnoe
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T16:20:28Z
user: login: kosack
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-27T17:03:40Z

Review comments:
diff_hunk: @@ -1189,6 +1189,18 @@ class MuonParametersContainer(Container):
         nan * u.deg**2,
         "MSE of the deviation of all pixels after cleaning from the ring fit",
     )
+    ring_size = Field(nan, "Number of pixels in the ring")
path: src/ctapipe/containers.py
user: login: maxnoe
body: Is this really number of pixels? The code below computes some of the pixel intensities.

If this is actually the sum of pixel charges, the name should be `ring_intensity` and the help string match what is actually computed.
created_at: 2024-12-13T11:03:15Z
author_association: MEMBER
diff_hunk: @@ -1189,6 +1189,18 @@ class MuonParametersContainer(Container):
         nan * u.deg**2,
         "MSE of the deviation of all pixels after cleaning from the ring fit",
     )
+    ring_size = Field(nan, "Number of pixels in the ring")
+    size_outside = Field(nan, "Number of pixels outside the ring")
path: src/ctapipe/containers.py
user: login: maxnoe
body: same here: probably this should be `intensity_outside_ring`
created_at: 2024-12-13T11:03:36Z
author_association: MEMBER
diff_hunk: @@ -1189,6 +1189,18 @@ class MuonParametersContainer(Container):
         nan * u.deg**2,
         "MSE of the deviation of all pixels after cleaning from the ring fit",
     )
+    ring_size = Field(nan, "Number of pixels in the ring")
+    size_outside = Field(nan, "Number of pixels outside the ring")
+    num_pixels_in_ring = Field(nan, "Number of pixels in the ring")
path: src/ctapipe/containers.py
user: login: maxnoe
body: We use the `n_` prefix for variables that are numbers, not `num_`
created_at: 2024-12-13T11:04:17Z
author_association: MEMBER
diff_hunk: @@ -1189,6 +1189,18 @@ class MuonParametersContainer(Container):
         nan * u.deg**2,
         "MSE of the deviation of all pixels after cleaning from the ring fit",
     )
+    ring_size = Field(nan, "Number of pixels in the ring")
+    size_outside = Field(nan, "Number of pixels outside the ring")
+    num_pixels_in_ring = Field(nan, "Number of pixels in the ring")
+    mean_pixel_outside_ring = Field(nan, "Mean pixel charge outside the ring")
path: src/ctapipe/containers.py
user: login: maxnoe
body: there is an `intensity` or similar missing in the field name. 

I'd call it `mean_intensity_outside_ring`
created_at: 2024-12-13T11:04:56Z
author_association: MEMBER
diff_hunk: @@ -1189,6 +1189,18 @@ class MuonParametersContainer(Container):
         nan * u.deg**2,
         "MSE of the deviation of all pixels after cleaning from the ring fit",
     )
+    ring_size = Field(nan, "Number of pixels in the ring")
+    size_outside = Field(nan, "Number of pixels outside the ring")
+    num_pixels_in_ring = Field(nan, "Number of pixels in the ring")
+    mean_pixel_outside_ring = Field(nan, "Mean pixel charge outside the ring")
+    standard_dev = Field(
+        nan * u.deg,
+        "Standard deviation of the light distribution along the ring radius.",
path: src/ctapipe/containers.py
user: login: maxnoe
body: "along the ring radius" sounds like this is somehow using spatial information.

But I think this is just "of pixels on the ring", right?
created_at: 2024-12-13T11:19:56Z
author_association: MEMBER
diff_hunk: @@ -50,3 +55,70 @@ def test_ring_completeness():
 
     assert ring_comp <= 1
     assert ring_comp >= 0
+
+
+def test_ring_size_parameters():
+    radius = 1
+    center_x = 0
+    center_y = 0
+    pixel_x = np.linspace(-2, 2, 1855)
+    pixel_y = np.linspace(-2, 2, 1855)
+    ring_integration_width = 0.25
+    outer_ring_width = 0.2
+    image = np.random.normal(loc=100, scale=10, size=1855)
+    image_mask = np.random.choice([True, False], size=1855)
+
+    (
+        ring_size,
+        size_outside,
+        num_pixels_in_ring,
+        mean_pixel_outside_ring,
+    ) = ring_size_parameters(
+        radius,
+        center_x,
+        center_y,
+        pixel_x,
+        pixel_y,
+        ring_integration_width,
+        outer_ring_width,
+        image,
+        image_mask,
+    )
+
+    assert ring_size > 0
path: src/ctapipe/image/muon/tests/test_muon_features.py
user: login: maxnoe
body: I think this test is not definitive enough. Please consider writing a test actually being sensitive on changes other than the value being <= 0.

I.e. use the ring gaussian toymodel to create a ring with known properties:
https://github.com/cta-observatory/ctapipe/blob/7858ddc3575681d59a266ecc1f20d1510f2f3555/src/ctapipe/image/toymodel.py#L375
created_at: 2024-12-13T11:23:23Z
author_association: MEMBER
diff_hunk: @@ -158,3 +158,113 @@ def ring_containment(radius, center_x, center_y, camera_radius):
 
     a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
     return np.arccos(a / radius) / np.pi
+
+
+def ring_size_parameters(
+    radius,
+    center_x,
+    center_y,
+    pixel_x,
+    pixel_y,
+    ring_integration_width,
+    outer_ring_width,
+    image,
+    image_mask,
+):
+    """
+    Calculate the parameters related to the size of the ring image.
+
+    Parameters
+    ----------
+    radius: float
+        radius of the ring
+    center_x: float
+        x coordinate of the ring center
+    center_y: float
+        y coordinate of the ring center
+    pixel_x: array-like
+        x coordinates of the camera pixels
+    pixel_y: array-like
+        y coordinates of the camera pixels
+    ring_integration_width: float
+        Width of the ring in fractions of ring radius
+    outer_ring_width: float
+        Width of the outer ring in fractions of ring radius
+    image: array-like
+        Amplitude of image pixels
+    image_mask: array-like
+        mask of the camera pixels after cleaning
+
+    Returns
+    -------
+    ring_size: float
+        Sum of the p.e. inside the integration area of the ring
+    size_outside: float
+        Sum of the photons outside the ring integration area that passed the cleaning
+    num_pixels_in_ring: int
+        Number of pixels inside the ring integration area that passed the cleaning
+    mean_pixel_outside_ring: float
+        Mean intensity of the pixels outside the ring, but still close to it
+    """
+
+    dist = np.sqrt((pixel_x - center_x) ** 2 + (pixel_y - center_y) ** 2)
+    dist_mask = np.abs(dist - radius) < (radius * ring_integration_width)
+    pix_ring = image * dist_mask
+    pix_outside_ring = image * ~dist_mask
+
+    dist_mask_2 = np.logical_and(
+        ~dist_mask,
+        np.abs(dist - radius) < radius * (ring_integration_width + outer_ring_width),
+    )
+    pix_ring_2 = image[dist_mask_2]
+
+    ring_size = np.sum(pix_ring)
+    size_outside = np.sum(pix_outside_ring * image_mask)
+    num_pixels_in_ring = np.sum(dist_mask & image_mask)
+    mean_pixel_outside_ring = np.sum(pix_ring_2) / len(pix_ring_2)
+
+    return ring_size, size_outside, num_pixels_in_ring, mean_pixel_outside_ring
+
+
+def radial_light_distribution(center_x, center_y, pixel_x, pixel_y, image):
+    """
+    Calculate the radial distribution of the muon ring.
+
+    Parameters
+    ----------
+    center_x : float
+        x coordinate of the ring center.
+    center_y : float
+        y coordinate of the ring center.
+    pixel_x : array-like
+        x coordinates of the camera pixels.
+    pixel_y : array-like
+        y coordinates of the camera pixels.
+        Amplitude of image pixels.
+
+    Returns
+    -------
+    standard_dev : float
+        Standard deviation of the light distribution along the ring radius.
+    skewness : float
+        Skewness of the light distribution along the ring radius.
+    excess_kurtosis : float
+        Excess kurtosis of the light distribution along the ring radius.
+    """
+
+    if np.sum(image) == 0:
+        return np.nan * u.deg, np.nan, np.nan
+
+    x0 = center_x.to_value(u.deg)
+    y0 = center_y.to_value(u.deg)
+    pix_x = pixel_x.to_value(u.deg)
+    pix_y = pixel_y.to_value(u.deg)
+    pixel_r = np.sqrt((pix_x - x0) ** 2 + (pix_y - y0) ** 2)
path: src/ctapipe/image/muon/features.py
user: login: kosack
body: ```suggestion
    pixel_r = np.hypot(pix_x - x0, pix_y - y0)
```
created_at: 2024-12-18T14:18:52Z
author_association: MEMBER
diff_hunk: @@ -158,3 +158,113 @@ def ring_containment(radius, center_x, center_y, camera_radius):
 
     a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
     return np.arccos(a / radius) / np.pi
+
+
+def ring_size_parameters(
+    radius,
+    center_x,
+    center_y,
+    pixel_x,
+    pixel_y,
+    ring_integration_width,
+    outer_ring_width,
+    image,
+    image_mask,
+):
+    """
+    Calculate the parameters related to the size of the ring image.
+
+    Parameters
+    ----------
+    radius: float
+        radius of the ring
+    center_x: float
+        x coordinate of the ring center
+    center_y: float
+        y coordinate of the ring center
+    pixel_x: array-like
+        x coordinates of the camera pixels
+    pixel_y: array-like
+        y coordinates of the camera pixels
+    ring_integration_width: float
+        Width of the ring in fractions of ring radius
+    outer_ring_width: float
+        Width of the outer ring in fractions of ring radius
+    image: array-like
+        Amplitude of image pixels
+    image_mask: array-like
+        mask of the camera pixels after cleaning
+
+    Returns
+    -------
+    ring_size: float
+        Sum of the p.e. inside the integration area of the ring
+    size_outside: float
+        Sum of the photons outside the ring integration area that passed the cleaning
+    num_pixels_in_ring: int
+        Number of pixels inside the ring integration area that passed the cleaning
+    mean_pixel_outside_ring: float
+        Mean intensity of the pixels outside the ring, but still close to it
+    """
+
+    dist = np.sqrt((pixel_x - center_x) ** 2 + (pixel_y - center_y) ** 2)
+    dist_mask = np.abs(dist - radius) < (radius * ring_integration_width)
+    pix_ring = image * dist_mask
+    pix_outside_ring = image * ~dist_mask
+
+    dist_mask_2 = np.logical_and(
+        ~dist_mask,
+        np.abs(dist - radius) < radius * (ring_integration_width + outer_ring_width),
+    )
+    pix_ring_2 = image[dist_mask_2]
+
+    ring_size = np.sum(pix_ring)
+    size_outside = np.sum(pix_outside_ring * image_mask)
+    num_pixels_in_ring = np.sum(dist_mask & image_mask)
+    mean_pixel_outside_ring = np.sum(pix_ring_2) / len(pix_ring_2)
+
+    return ring_size, size_outside, num_pixels_in_ring, mean_pixel_outside_ring
+
+
+def radial_light_distribution(center_x, center_y, pixel_x, pixel_y, image):
+    """
+    Calculate the radial distribution of the muon ring.
+
+    Parameters
+    ----------
+    center_x : float
+        x coordinate of the ring center.
+    center_y : float
+        y coordinate of the ring center.
+    pixel_x : array-like
+        x coordinates of the camera pixels.
+    pixel_y : array-like
+        y coordinates of the camera pixels.
+        Amplitude of image pixels.
+
+    Returns
+    -------
+    standard_dev : float
+        Standard deviation of the light distribution along the ring radius.
+    skewness : float
+        Skewness of the light distribution along the ring radius.
+    excess_kurtosis : float
+        Excess kurtosis of the light distribution along the ring radius.
+    """
+
+    if np.sum(image) == 0:
+        return np.nan * u.deg, np.nan, np.nan
+
+    x0 = center_x.to_value(u.deg)
+    y0 = center_y.to_value(u.deg)
+    pix_x = pixel_x.to_value(u.deg)
+    pix_y = pixel_y.to_value(u.deg)
path: src/ctapipe/image/muon/features.py
user: login: kosack
body: I don't think you need to convert and take the value here. Numpy works fine with unit quantities, and then the function will work no matter what unit the inputs are in.
created_at: 2024-12-18T14:20:07Z
author_association: MEMBER
diff_hunk: @@ -200,11 +200,13 @@ def ring_size_parameters(
     ring_intensity: float
         Sum of the p.e. inside the integration area of the ring
     intensity_outside_ring: float
-        Sum of the photons outside the ring integration area that passed the cleaning
+        Sum of the p.e. outside the ring integration area that passed the cleaning
     n_pixels_in_ring: int
         Number of pixels inside the ring integration area that passed the cleaning
     mean_intensity_outside_ring: float
-        Mean intensity of the pixels outside the ring, but still close to it
+        Mean intensity of the pixels outside the integration area of the ring,
path: src/ctapipe/image/muon/features.py
user: login: mexanick
body: Is this the same as l1201 of `ctapipe.containers`? If so, why the docstrings are different?
created_at: 2024-12-19T10:50:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,11 +200,13 @@ def ring_size_parameters(
     ring_intensity: float
         Sum of the p.e. inside the integration area of the ring
     intensity_outside_ring: float
-        Sum of the photons outside the ring integration area that passed the cleaning
+        Sum of the p.e. outside the ring integration area that passed the cleaning
     n_pixels_in_ring: int
         Number of pixels inside the ring integration area that passed the cleaning
     mean_intensity_outside_ring: float
-        Mean intensity of the pixels outside the ring, but still close to it
+        Mean intensity of the pixels outside the integration area of the ring,
path: src/ctapipe/image/muon/features.py
user: login: jstvdk
body: They are the same, but I didn't want to duplicate the exact same text. 
Or should I just duplicate the doc strings from `ctapipe.containers` into the doc strings in the methods from `ctapipe.image.muon.features`?
created_at: 2024-12-19T10:58:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,11 +200,13 @@ def ring_size_parameters(
     ring_intensity: float
         Sum of the p.e. inside the integration area of the ring
     intensity_outside_ring: float
-        Sum of the photons outside the ring integration area that passed the cleaning
+        Sum of the p.e. outside the ring integration area that passed the cleaning
     n_pixels_in_ring: int
         Number of pixels inside the ring integration area that passed the cleaning
     mean_intensity_outside_ring: float
-        Mean intensity of the pixels outside the ring, but still close to it
+        Mean intensity of the pixels outside the integration area of the ring,
path: src/ctapipe/image/muon/features.py
user: login: mexanick
body: Perhaps this function simply should return `MuonParametersContainer`?
Also, the number of input parameters is very high, consider to wrap them into a structure (dict, container, ...)
created_at: 2024-12-19T13:14:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,3 +158,125 @@ def ring_containment(radius, center_x, center_y, camera_radius):
 
     a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
     return np.arccos(a / radius) / np.pi
+
+
+def ring_size_parameters(
+    radius,
+    center_x,
+    center_y,
+    pixel_x,
+    pixel_y,
+    ring_integration_width,
+    outer_ring_width,
+    image,
+    image_mask,
+):
+    """
+    Calculate the parameters related to the size of the ring image.
+
+    Parameters
+    ----------
+    radius: float
+        radius of the ring
+    center_x: float
+        x coordinate of the ring center
+    center_y: float
+        y coordinate of the ring center
+    pixel_x: array-like
+        x coordinates of the camera pixels
+    pixel_y: array-like
+        y coordinates of the camera pixels
+    ring_integration_width: float
+        Width of the ring in fractions of ring radius
+    outer_ring_width: float
+        Width of the outer ring in fractions of ring radius
+    image: array-like
+        Amplitude of image pixels
+    image_mask: array-like
+        mask of the camera pixels after cleaning
+
+    Returns
+    -------
+    ring_intensity: float
+        Sum of the p.e. inside the integration area of the ring
+    intensity_outside_ring: float
+        Sum of the p.e. outside the ring integration area that passed the cleaning
+    n_pixels_in_ring: int
+        Number of pixels inside the ring integration area that passed the cleaning
+    mean_intensity_outside_ring: float
+        Mean intensity of the pixels outside the integration area of the ring,
+        and restricted by the outer ring width, i.e. in the strip between
+        ring integration width and outer ring width.
+    """
+
+    dist = np.sqrt((pixel_x - center_x) ** 2 + (pixel_y - center_y) ** 2)
+    dist_mask = np.abs(dist - radius) < (radius * ring_integration_width)
+    pix_ring = image * dist_mask
+    pix_outside_ring = image * ~dist_mask
+
+    dist_mask_2 = np.logical_and(
+        ~dist_mask,
+        np.abs(dist - radius) < radius * (ring_integration_width + outer_ring_width),
+    )
+    pix_ring_2 = image[dist_mask_2]
+
+    ring_intensity = np.sum(pix_ring)
+    intensity_outside_ring = np.sum(pix_outside_ring * image_mask)
+    n_pixels_in_ring = np.sum(dist_mask & image_mask)
+    mean_intensity_outside_ring = np.sum(pix_ring_2) / len(pix_ring_2)
+
+    return (
+        ring_intensity,
+        intensity_outside_ring,
+        n_pixels_in_ring,
+        mean_intensity_outside_ring,
+    )
+
+
+def radial_light_distribution(center_x, center_y, pixel_x, pixel_y, image):
+    """
+    Calculate the radial distribution of the muon ring.
+
+    Parameters
+    ----------
+    center_x : float
+        x coordinate of the ring center.
+    center_y : float
+        y coordinate of the ring center.
+    pixel_x : array-like
+        x coordinates of the camera pixels.
+    pixel_y : array-like
+        y coordinates of the camera pixels.
+        Amplitude of image pixels.
path: src/ctapipe/image/muon/features.py
user: login: mexanick
body: "image" is missing?
created_at: 2024-12-19T13:14:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,3 +158,125 @@ def ring_containment(radius, center_x, center_y, camera_radius):
 
     a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
     return np.arccos(a / radius) / np.pi
+
+
+def ring_size_parameters(
+    radius,
+    center_x,
+    center_y,
+    pixel_x,
+    pixel_y,
+    ring_integration_width,
+    outer_ring_width,
+    image,
+    image_mask,
+):
+    """
+    Calculate the parameters related to the size of the ring image.
+
+    Parameters
+    ----------
+    radius: float
+        radius of the ring
+    center_x: float
+        x coordinate of the ring center
+    center_y: float
+        y coordinate of the ring center
+    pixel_x: array-like
+        x coordinates of the camera pixels
+    pixel_y: array-like
+        y coordinates of the camera pixels
+    ring_integration_width: float
+        Width of the ring in fractions of ring radius
+    outer_ring_width: float
+        Width of the outer ring in fractions of ring radius
+    image: array-like
+        Amplitude of image pixels
+    image_mask: array-like
+        mask of the camera pixels after cleaning
+
+    Returns
+    -------
+    ring_intensity: float
+        Sum of the p.e. inside the integration area of the ring
+    intensity_outside_ring: float
+        Sum of the p.e. outside the ring integration area that passed the cleaning
+    n_pixels_in_ring: int
+        Number of pixels inside the ring integration area that passed the cleaning
+    mean_intensity_outside_ring: float
+        Mean intensity of the pixels outside the integration area of the ring,
+        and restricted by the outer ring width, i.e. in the strip between
+        ring integration width and outer ring width.
+    """
+
+    dist = np.sqrt((pixel_x - center_x) ** 2 + (pixel_y - center_y) ** 2)
+    dist_mask = np.abs(dist - radius) < (radius * ring_integration_width)
+    pix_ring = image * dist_mask
+    pix_outside_ring = image * ~dist_mask
+
+    dist_mask_2 = np.logical_and(
+        ~dist_mask,
+        np.abs(dist - radius) < radius * (ring_integration_width + outer_ring_width),
+    )
+    pix_ring_2 = image[dist_mask_2]
+
+    ring_intensity = np.sum(pix_ring)
+    intensity_outside_ring = np.sum(pix_outside_ring * image_mask)
+    n_pixels_in_ring = np.sum(dist_mask & image_mask)
+    mean_intensity_outside_ring = np.sum(pix_ring_2) / len(pix_ring_2)
+
+    return (
+        ring_intensity,
+        intensity_outside_ring,
+        n_pixels_in_ring,
+        mean_intensity_outside_ring,
+    )
+
+
+def radial_light_distribution(center_x, center_y, pixel_x, pixel_y, image):
path: src/ctapipe/image/muon/features.py
user: login: mexanick
body: Consider merging this and the above functions, implementing along the way unification of the return type.
created_at: 2024-12-19T13:15:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,11 +200,13 @@ def ring_size_parameters(
     ring_intensity: float
         Sum of the p.e. inside the integration area of the ring
     intensity_outside_ring: float
-        Sum of the photons outside the ring integration area that passed the cleaning
+        Sum of the p.e. outside the ring integration area that passed the cleaning
     n_pixels_in_ring: int
         Number of pixels inside the ring integration area that passed the cleaning
     mean_intensity_outside_ring: float
-        Mean intensity of the pixels outside the ring, but still close to it
+        Mean intensity of the pixels outside the integration area of the ring,
path: src/ctapipe/image/muon/features.py
user: login: jstvdk
body: > Perhaps this function simply should return `MuonParametersContainer`? 

All this module in fact is returning just one `MuonParametersContainer` and I adopted already existing style where each method returning some logically/technically connected part of `MuonParametersContainer`.  Should I reconsider this and return calculated parts of `MuonParametersContainer` from every method?

> Also, the number of input parameters is very high, consider to wrap them into a structure (dict, container, ...)

Good point
created_at: 2024-12-19T13:34:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,3 +158,125 @@ def ring_containment(radius, center_x, center_y, camera_radius):
 
     a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
     return np.arccos(a / radius) / np.pi
+
+
+def ring_size_parameters(
+    radius,
+    center_x,
+    center_y,
+    pixel_x,
+    pixel_y,
+    ring_integration_width,
+    outer_ring_width,
+    image,
+    image_mask,
+):
+    """
+    Calculate the parameters related to the size of the ring image.
+
+    Parameters
+    ----------
+    radius: float
+        radius of the ring
+    center_x: float
+        x coordinate of the ring center
+    center_y: float
+        y coordinate of the ring center
+    pixel_x: array-like
+        x coordinates of the camera pixels
+    pixel_y: array-like
+        y coordinates of the camera pixels
+    ring_integration_width: float
+        Width of the ring in fractions of ring radius
+    outer_ring_width: float
+        Width of the outer ring in fractions of ring radius
+    image: array-like
+        Amplitude of image pixels
+    image_mask: array-like
+        mask of the camera pixels after cleaning
+
+    Returns
+    -------
+    ring_intensity: float
+        Sum of the p.e. inside the integration area of the ring
+    intensity_outside_ring: float
+        Sum of the p.e. outside the ring integration area that passed the cleaning
+    n_pixels_in_ring: int
+        Number of pixels inside the ring integration area that passed the cleaning
+    mean_intensity_outside_ring: float
+        Mean intensity of the pixels outside the integration area of the ring,
+        and restricted by the outer ring width, i.e. in the strip between
+        ring integration width and outer ring width.
+    """
+
+    dist = np.sqrt((pixel_x - center_x) ** 2 + (pixel_y - center_y) ** 2)
+    dist_mask = np.abs(dist - radius) < (radius * ring_integration_width)
+    pix_ring = image * dist_mask
+    pix_outside_ring = image * ~dist_mask
+
+    dist_mask_2 = np.logical_and(
+        ~dist_mask,
+        np.abs(dist - radius) < radius * (ring_integration_width + outer_ring_width),
+    )
+    pix_ring_2 = image[dist_mask_2]
+
+    ring_intensity = np.sum(pix_ring)
+    intensity_outside_ring = np.sum(pix_outside_ring * image_mask)
+    n_pixels_in_ring = np.sum(dist_mask & image_mask)
+    mean_intensity_outside_ring = np.sum(pix_ring_2) / len(pix_ring_2)
+
+    return (
+        ring_intensity,
+        intensity_outside_ring,
+        n_pixels_in_ring,
+        mean_intensity_outside_ring,
+    )
+
+
+def radial_light_distribution(center_x, center_y, pixel_x, pixel_y, image):
path: src/ctapipe/image/muon/features.py
user: login: jstvdk
body: Sorry, let me repeat if I understood correctly. You are proposing to merge all the methods into one, which calculates and returns `MuonParametersContainer`?
created_at: 2024-12-19T13:36:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,3 +158,125 @@ def ring_containment(radius, center_x, center_y, camera_radius):
 
     a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
     return np.arccos(a / radius) / np.pi
+
+
+def ring_size_parameters(
+    radius,
+    center_x,
+    center_y,
+    pixel_x,
+    pixel_y,
+    ring_integration_width,
+    outer_ring_width,
+    image,
+    image_mask,
+):
+    """
+    Calculate the parameters related to the size of the ring image.
+
+    Parameters
+    ----------
+    radius: float
+        radius of the ring
+    center_x: float
+        x coordinate of the ring center
+    center_y: float
+        y coordinate of the ring center
+    pixel_x: array-like
+        x coordinates of the camera pixels
+    pixel_y: array-like
+        y coordinates of the camera pixels
+    ring_integration_width: float
+        Width of the ring in fractions of ring radius
+    outer_ring_width: float
+        Width of the outer ring in fractions of ring radius
+    image: array-like
+        Amplitude of image pixels
+    image_mask: array-like
+        mask of the camera pixels after cleaning
+
+    Returns
+    -------
+    ring_intensity: float
+        Sum of the p.e. inside the integration area of the ring
+    intensity_outside_ring: float
+        Sum of the p.e. outside the ring integration area that passed the cleaning
+    n_pixels_in_ring: int
+        Number of pixels inside the ring integration area that passed the cleaning
+    mean_intensity_outside_ring: float
+        Mean intensity of the pixels outside the integration area of the ring,
+        and restricted by the outer ring width, i.e. in the strip between
+        ring integration width and outer ring width.
+    """
+
+    dist = np.sqrt((pixel_x - center_x) ** 2 + (pixel_y - center_y) ** 2)
+    dist_mask = np.abs(dist - radius) < (radius * ring_integration_width)
+    pix_ring = image * dist_mask
+    pix_outside_ring = image * ~dist_mask
+
+    dist_mask_2 = np.logical_and(
+        ~dist_mask,
+        np.abs(dist - radius) < radius * (ring_integration_width + outer_ring_width),
+    )
+    pix_ring_2 = image[dist_mask_2]
+
+    ring_intensity = np.sum(pix_ring)
+    intensity_outside_ring = np.sum(pix_outside_ring * image_mask)
+    n_pixels_in_ring = np.sum(dist_mask & image_mask)
+    mean_intensity_outside_ring = np.sum(pix_ring_2) / len(pix_ring_2)
+
+    return (
+        ring_intensity,
+        intensity_outside_ring,
+        n_pixels_in_ring,
+        mean_intensity_outside_ring,
+    )
+
+
+def radial_light_distribution(center_x, center_y, pixel_x, pixel_y, image):
path: src/ctapipe/image/muon/features.py
user: login: mexanick
body: yes
created_at: 2024-12-19T15:31:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,88 +107,243 @@ def intensity_ratio_inside_ring(
 
 
 def ring_completeness(
-    pixel_x, pixel_y, weights, radius, center_x, center_y, threshold=30, bins=30
-):
+    pixel_fov_lon: Quantity,
+    pixel_fov_lat: Quantity,
+    weights: Quantity | np.ndarray | Sequence[float],
+    ring: MuonRingContainer,
+    threshold: float = 30,
+    bins: int = 30,
+) -> float:
     """
-    Estimate how complete a ring is.
-    Bin the light distribution along the the ring and apply a threshold to the
-    bin content.
+    Estimate how complete a muon ring is by binning the light distribution along the ring
+    and applying a threshold to the bin content.
 
     Parameters
     ----------
-    pixel_x: array-like
-        x coordinates of the camera pixels
-    pixel_y: array-like
-        y coordinates of the camera pixels
-    weights: array-like
-        weights for the camera pixels, will usually be the pe charges
-    radius: float
-        radius of the ring
-    center_x: float
-        x coordinate of the ring center
-    center_y: float
-        y coordinate of the ring center
-    threshold: float
-        number of photons a bin must contain to be counted
-    bins: int
-        number of bins to use for the histogram
+    pixel_fov_lon : Quantity
+        Longitudes (x-coordinates) of the camera pixels in the TelescopeFrame.
+    pixel_fov_lat : Quantity
+        Latitudes (y-coordinates) of the camera pixels in the TelescopeFrame.
+    weights : array-like
+        Weights for the camera pixels, usually the photoelectron charges.
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    threshold : float, optional
+        Number of photoelectrons a bin must contain to be counted. Default is 30.
+    bins : int, optional
+        Number of bins to use for the histogram. Default is 30.
 
     Returns
     -------
-    ring_completeness: float
-        the ratio of bins above threshold
+    float
+        The ratio of bins above the threshold, representing the completeness of the ring.
+
+    Notes
+    -----
+    This function calculates the completeness of the muon ring by dividing the ring into
+    segments and counting the number of segments that have a light intensity above a given
+    threshold. The completeness is the ratio of the number of segments above the threshold
+    to the total number of segments.
     """
 
-    angle = np.arctan2(pixel_y - center_y, pixel_x - center_x)
-    if hasattr(angle, "unit"):
-        angle = angle.to_value(u.rad)
+    if hasattr(weights, "unit"):
+        weights = weights.to_value(u.dimensionless_unscaled)
+    angle = np.arctan2(
+        (pixel_fov_lat - ring.center_fov_lat).to_value(u.rad),
+        (pixel_fov_lon - ring.center_fov_lon).to_value(u.rad),
+    )
 
-    hist, _ = np.histogram(angle, bins=bins, range=[-np.pi, np.pi], weights=weights)
+    hist, _ = np.histogram(
+        angle,
+        bins=bins,
+        range=[-np.pi, np.pi],
+        weights=weights,
+    )
 
     bins_above_threshold = hist > threshold
 
     return np.sum(bins_above_threshold) / bins
 
 
-def ring_containment(radius, center_x, center_y, camera_radius):
+def ring_containment(ring: MuonRingContainer, camera_radius: Quantity) -> float:
     """
-    Estimate angular containment of a ring inside the camera
-    (camera center is (0,0))
+    Estimate the angular containment of a muon ring inside the camera's field of view.
 
-    Improve: include the case of an arbitrary
-    center for the camera
-
-    See https://stackoverflow.com/questions/3349125/circle-circle-intersection-points
+    This function calculates the fraction of the muon ring that is contained within
+    the camera's field of view. It uses geometric properties to determine the intersection
+    of the ring with the circular boundary of the camera.
 
     Parameters
     ----------
-    radius: float or quantity
-        radius of the muon ring
-    center_x: float or quantity
-        x coordinate of the center of the muon ring
-    center_y: float or quantity
-        y coordinate of the center of the muon ring
-    camera_radius: float or quantity
-        radius of the camera
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    camera_radius : `astropy.units.Quantity`
+        Radius of the camera's field of view (in degrees).
 
     Returns
     -------
-    ringcontainment: float
-        the ratio of ring inside the camera
-    """
-    if hasattr(radius, "unit"):
-        radius, center_x, center_y, camera_radius = all_to_value(
-            radius, center_x, center_y, camera_radius, unit=radius.unit
-        )
-    d = np.sqrt(center_x**2 + center_y**2)
+    float
+        The fraction of the ring that is inside the camera's field of view, ranging from 0.0 to 1.0.
 
+    Notes
+    -----
+    The calculation is based on the geometric intersection of two circles:
+    the muon ring and the camera's field of view. The method handles cases where:
+    - The ring is fully contained within the camera.
+    - The ring is partially contained within the camera.
+    - The ring is completely outside the camera.
+
+    References
+    ----------
+    See https://stackoverflow.com/questions/3349125/circle-circle-intersection-points
+    for the geometric approach to circle-circle intersection.
+    """
     # one circle fully contained in the other
-    if d <= np.abs(camera_radius - radius):
+    if ring.center_distance <= np.abs(camera_radius - ring.radius):
         return 1.0
 
     # no intersection
-    if d > (radius + camera_radius):
+    if ring.center_distance > (ring.radius + camera_radius):
         return 0.0
 
-    a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
-    return np.arccos(a / radius) / np.pi
+    a = (ring.radius**2 - camera_radius**2 + ring.center_distance**2) / (
+        2 * ring.center_distance
+    )
+    return np.arccos((a / ring.radius).to_value(u.dimensionless_unscaled)) / np.pi
+
+
+def ring_size_parameters(
path: src/ctapipe/image/muon/features.py
user: login: maxnoe
body: `ring_intensity_parameters`?
created_at: 2025-03-11T09:33:11Z
author_association: MEMBER
diff_hunk: @@ -73,88 +107,243 @@ def intensity_ratio_inside_ring(
 
 
 def ring_completeness(
-    pixel_x, pixel_y, weights, radius, center_x, center_y, threshold=30, bins=30
-):
+    pixel_fov_lon: Quantity,
+    pixel_fov_lat: Quantity,
+    weights: Quantity | np.ndarray | Sequence[float],
+    ring: MuonRingContainer,
+    threshold: float = 30,
+    bins: int = 30,
+) -> float:
     """
-    Estimate how complete a ring is.
-    Bin the light distribution along the the ring and apply a threshold to the
-    bin content.
+    Estimate how complete a muon ring is by binning the light distribution along the ring
+    and applying a threshold to the bin content.
 
     Parameters
     ----------
-    pixel_x: array-like
-        x coordinates of the camera pixels
-    pixel_y: array-like
-        y coordinates of the camera pixels
-    weights: array-like
-        weights for the camera pixels, will usually be the pe charges
-    radius: float
-        radius of the ring
-    center_x: float
-        x coordinate of the ring center
-    center_y: float
-        y coordinate of the ring center
-    threshold: float
-        number of photons a bin must contain to be counted
-    bins: int
-        number of bins to use for the histogram
+    pixel_fov_lon : Quantity
+        Longitudes (x-coordinates) of the camera pixels in the TelescopeFrame.
+    pixel_fov_lat : Quantity
+        Latitudes (y-coordinates) of the camera pixels in the TelescopeFrame.
+    weights : array-like
+        Weights for the camera pixels, usually the photoelectron charges.
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    threshold : float, optional
+        Number of photoelectrons a bin must contain to be counted. Default is 30.
+    bins : int, optional
+        Number of bins to use for the histogram. Default is 30.
 
     Returns
     -------
-    ring_completeness: float
-        the ratio of bins above threshold
+    float
+        The ratio of bins above the threshold, representing the completeness of the ring.
+
+    Notes
+    -----
+    This function calculates the completeness of the muon ring by dividing the ring into
+    segments and counting the number of segments that have a light intensity above a given
+    threshold. The completeness is the ratio of the number of segments above the threshold
+    to the total number of segments.
     """
 
-    angle = np.arctan2(pixel_y - center_y, pixel_x - center_x)
-    if hasattr(angle, "unit"):
-        angle = angle.to_value(u.rad)
+    if hasattr(weights, "unit"):
+        weights = weights.to_value(u.dimensionless_unscaled)
+    angle = np.arctan2(
+        (pixel_fov_lat - ring.center_fov_lat).to_value(u.rad),
+        (pixel_fov_lon - ring.center_fov_lon).to_value(u.rad),
+    )
 
-    hist, _ = np.histogram(angle, bins=bins, range=[-np.pi, np.pi], weights=weights)
+    hist, _ = np.histogram(
+        angle,
+        bins=bins,
+        range=[-np.pi, np.pi],
+        weights=weights,
+    )
 
     bins_above_threshold = hist > threshold
 
     return np.sum(bins_above_threshold) / bins
 
 
-def ring_containment(radius, center_x, center_y, camera_radius):
+def ring_containment(ring: MuonRingContainer, camera_radius: Quantity) -> float:
     """
-    Estimate angular containment of a ring inside the camera
-    (camera center is (0,0))
+    Estimate the angular containment of a muon ring inside the camera's field of view.
 
-    Improve: include the case of an arbitrary
-    center for the camera
-
-    See https://stackoverflow.com/questions/3349125/circle-circle-intersection-points
+    This function calculates the fraction of the muon ring that is contained within
+    the camera's field of view. It uses geometric properties to determine the intersection
+    of the ring with the circular boundary of the camera.
 
     Parameters
     ----------
-    radius: float or quantity
-        radius of the muon ring
-    center_x: float or quantity
-        x coordinate of the center of the muon ring
-    center_y: float or quantity
-        y coordinate of the center of the muon ring
-    camera_radius: float or quantity
-        radius of the camera
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    camera_radius : `astropy.units.Quantity`
+        Radius of the camera's field of view (in degrees).
 
     Returns
     -------
-    ringcontainment: float
-        the ratio of ring inside the camera
-    """
-    if hasattr(radius, "unit"):
-        radius, center_x, center_y, camera_radius = all_to_value(
-            radius, center_x, center_y, camera_radius, unit=radius.unit
-        )
-    d = np.sqrt(center_x**2 + center_y**2)
+    float
+        The fraction of the ring that is inside the camera's field of view, ranging from 0.0 to 1.0.
 
+    Notes
+    -----
+    The calculation is based on the geometric intersection of two circles:
+    the muon ring and the camera's field of view. The method handles cases where:
+    - The ring is fully contained within the camera.
+    - The ring is partially contained within the camera.
+    - The ring is completely outside the camera.
+
+    References
+    ----------
+    See https://stackoverflow.com/questions/3349125/circle-circle-intersection-points
+    for the geometric approach to circle-circle intersection.
+    """
     # one circle fully contained in the other
-    if d <= np.abs(camera_radius - radius):
+    if ring.center_distance <= np.abs(camera_radius - ring.radius):
         return 1.0
 
     # no intersection
-    if d > (radius + camera_radius):
+    if ring.center_distance > (ring.radius + camera_radius):
         return 0.0
 
-    a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
-    return np.arccos(a / radius) / np.pi
+    a = (ring.radius**2 - camera_radius**2 + ring.center_distance**2) / (
+        2 * ring.center_distance
+    )
+    return np.arccos((a / ring.radius).to_value(u.dimensionless_unscaled)) / np.pi
+
+
+def ring_size_parameters(
path: src/ctapipe/image/muon/features.py
user: login: mexanick
body: I was thinking about this change, but this function also computes the number of pixels in the ring, so I left it as is. If you have a strong feeling that ring_intensity_parameters will be better, let me know and I will update.
created_at: 2025-03-11T10:03:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -73,88 +107,243 @@ def intensity_ratio_inside_ring(
 
 
 def ring_completeness(
-    pixel_x, pixel_y, weights, radius, center_x, center_y, threshold=30, bins=30
-):
+    pixel_fov_lon: Quantity,
+    pixel_fov_lat: Quantity,
+    weights: Quantity | np.ndarray | Sequence[float],
+    ring: MuonRingContainer,
+    threshold: float = 30,
+    bins: int = 30,
+) -> float:
     """
-    Estimate how complete a ring is.
-    Bin the light distribution along the the ring and apply a threshold to the
-    bin content.
+    Estimate how complete a muon ring is by binning the light distribution along the ring
+    and applying a threshold to the bin content.
 
     Parameters
     ----------
-    pixel_x: array-like
-        x coordinates of the camera pixels
-    pixel_y: array-like
-        y coordinates of the camera pixels
-    weights: array-like
-        weights for the camera pixels, will usually be the pe charges
-    radius: float
-        radius of the ring
-    center_x: float
-        x coordinate of the ring center
-    center_y: float
-        y coordinate of the ring center
-    threshold: float
-        number of photons a bin must contain to be counted
-    bins: int
-        number of bins to use for the histogram
+    pixel_fov_lon : Quantity
+        Longitudes (x-coordinates) of the camera pixels in the TelescopeFrame.
+    pixel_fov_lat : Quantity
+        Latitudes (y-coordinates) of the camera pixels in the TelescopeFrame.
+    weights : array-like
+        Weights for the camera pixels, usually the photoelectron charges.
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    threshold : float, optional
+        Number of photoelectrons a bin must contain to be counted. Default is 30.
+    bins : int, optional
+        Number of bins to use for the histogram. Default is 30.
 
     Returns
     -------
-    ring_completeness: float
-        the ratio of bins above threshold
+    float
+        The ratio of bins above the threshold, representing the completeness of the ring.
+
+    Notes
+    -----
+    This function calculates the completeness of the muon ring by dividing the ring into
+    segments and counting the number of segments that have a light intensity above a given
+    threshold. The completeness is the ratio of the number of segments above the threshold
+    to the total number of segments.
     """
 
-    angle = np.arctan2(pixel_y - center_y, pixel_x - center_x)
-    if hasattr(angle, "unit"):
-        angle = angle.to_value(u.rad)
+    if hasattr(weights, "unit"):
+        weights = weights.to_value(u.dimensionless_unscaled)
+    angle = np.arctan2(
+        (pixel_fov_lat - ring.center_fov_lat).to_value(u.rad),
+        (pixel_fov_lon - ring.center_fov_lon).to_value(u.rad),
+    )
 
-    hist, _ = np.histogram(angle, bins=bins, range=[-np.pi, np.pi], weights=weights)
+    hist, _ = np.histogram(
+        angle,
+        bins=bins,
+        range=[-np.pi, np.pi],
+        weights=weights,
+    )
 
     bins_above_threshold = hist > threshold
 
     return np.sum(bins_above_threshold) / bins
 
 
-def ring_containment(radius, center_x, center_y, camera_radius):
+def ring_containment(ring: MuonRingContainer, camera_radius: Quantity) -> float:
     """
-    Estimate angular containment of a ring inside the camera
-    (camera center is (0,0))
+    Estimate the angular containment of a muon ring inside the camera's field of view.
 
-    Improve: include the case of an arbitrary
-    center for the camera
-
-    See https://stackoverflow.com/questions/3349125/circle-circle-intersection-points
+    This function calculates the fraction of the muon ring that is contained within
+    the camera's field of view. It uses geometric properties to determine the intersection
+    of the ring with the circular boundary of the camera.
 
     Parameters
     ----------
-    radius: float or quantity
-        radius of the muon ring
-    center_x: float or quantity
-        x coordinate of the center of the muon ring
-    center_y: float or quantity
-        y coordinate of the center of the muon ring
-    camera_radius: float or quantity
-        radius of the camera
+    ring : MuonRingContainer
+        Container with the fitted ring parameters, including center coordinates and radius.
+    camera_radius : `astropy.units.Quantity`
+        Radius of the camera's field of view (in degrees).
 
     Returns
     -------
-    ringcontainment: float
-        the ratio of ring inside the camera
-    """
-    if hasattr(radius, "unit"):
-        radius, center_x, center_y, camera_radius = all_to_value(
-            radius, center_x, center_y, camera_radius, unit=radius.unit
-        )
-    d = np.sqrt(center_x**2 + center_y**2)
+    float
+        The fraction of the ring that is inside the camera's field of view, ranging from 0.0 to 1.0.
 
+    Notes
+    -----
+    The calculation is based on the geometric intersection of two circles:
+    the muon ring and the camera's field of view. The method handles cases where:
+    - The ring is fully contained within the camera.
+    - The ring is partially contained within the camera.
+    - The ring is completely outside the camera.
+
+    References
+    ----------
+    See https://stackoverflow.com/questions/3349125/circle-circle-intersection-points
+    for the geometric approach to circle-circle intersection.
+    """
     # one circle fully contained in the other
-    if d <= np.abs(camera_radius - radius):
+    if ring.center_distance <= np.abs(camera_radius - ring.radius):
         return 1.0
 
     # no intersection
-    if d > (radius + camera_radius):
+    if ring.center_distance > (ring.radius + camera_radius):
         return 0.0
 
-    a = (radius**2 - camera_radius**2 + d**2) / (2 * d)
-    return np.arccos(a / radius) / np.pi
+    a = (ring.radius**2 - camera_radius**2 + ring.center_distance**2) / (
+        2 * ring.center_distance
+    )
+    return np.arccos((a / ring.radius).to_value(u.dimensionless_unscaled)) / np.pi
+
+
+def ring_size_parameters(
path: src/ctapipe/image/muon/features.py
user: login: maxnoe
body: Yes, I think the `size` here came from when everything related to `intensity` was called `size`.

 So I'd say we rename
created_at: 2025-03-11T10:59:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,10 @@
+Add new fields to the MuonParametersContainer and refactor the code using the existing containers.
path: docs/changes/2670.feature.rst
user: login: maxnoe
body: This reads like you only modified the container, but you also actually implemented the computation of these new features, right?

Maybe emphasize that a bit? 
created_at: 2025-03-11T12:25:26Z
author_association: MEMBER

PR comments:
user: login: ctao-dpps-sonarqube[bot]
created_at: 2024-12-18T15:36:24Z
body: ![Passed](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/QualityGateBadge/passed.svg?sanitize=true)

# Analysis Details
## 0 Issues
- ![Bug](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/bug.svg?sanitize=true) 0 Bugs
- ![Vulnerability](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/vulnerability.svg?sanitize=true) 0 Vulnerabilities
- ![Code Smell](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/code_smell.svg?sanitize=true) 0 Code Smells

## Coverage and Duplications
- ![Coverage](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/CoverageChart/100.svg?sanitize=true) 100.00% Coverage (94.10% Estimated after merge)
- ![Duplications](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/Duplications/3.svg?sanitize=true) 0.00% Duplicated Code (0.70% Estimated after merge)

**Project ID:** cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs

[View in SonarQube](https://sonar-cta-dpps.zeuthen.desy.de/dashboard?id=cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs&pullRequest=2670)


author_association: NONE
user: login: ctao-dpps-sonarqube[bot]
created_at: 2024-12-19T11:05:26Z
body: ![Passed](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/QualityGateBadge/passed.svg?sanitize=true)

# Analysis Details
## 0 Issues
- ![Bug](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/bug.svg?sanitize=true) 0 Bugs
- ![Vulnerability](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/vulnerability.svg?sanitize=true) 0 Vulnerabilities
- ![Code Smell](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/code_smell.svg?sanitize=true) 0 Code Smells

## Coverage and Duplications
- ![Coverage](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/CoverageChart/100.svg?sanitize=true) 100.00% Coverage (94.10% Estimated after merge)
- ![Duplications](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/Duplications/3.svg?sanitize=true) 0.00% Duplicated Code (0.70% Estimated after merge)

**Project ID:** cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs

[View in SonarQube](https://sonar-cta-dpps.zeuthen.desy.de/dashboard?id=cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs&pullRequest=2670)


author_association: NONE
user: login: ctao-dpps-sonarqube[bot]
created_at: 2024-12-19T13:42:10Z
body: ![Passed](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/QualityGateBadge/passed.svg?sanitize=true)

# Analysis Details
## 0 Issues
- ![Bug](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/bug.svg?sanitize=true) 0 Bugs
- ![Vulnerability](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/vulnerability.svg?sanitize=true) 0 Vulnerabilities
- ![Code Smell](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/code_smell.svg?sanitize=true) 0 Code Smells

## Coverage and Duplications
- ![Coverage](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/CoverageChart/100.svg?sanitize=true) 100.00% Coverage (94.10% Estimated after merge)
- ![Duplications](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/Duplications/3.svg?sanitize=true) 0.00% Duplicated Code (0.70% Estimated after merge)

**Project ID:** cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs

[View in SonarQube](https://sonar-cta-dpps.zeuthen.desy.de/dashboard?id=cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs&pullRequest=2670)


author_association: NONE
user: login: ctao-dpps-sonarqube[bot]
created_at: 2024-12-19T14:12:22Z
body: ![Passed](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/QualityGateBadge/passed.svg?sanitize=true)

# Analysis Details
## 0 Issues
- ![Bug](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/bug.svg?sanitize=true) 0 Bugs
- ![Vulnerability](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/vulnerability.svg?sanitize=true) 0 Vulnerabilities
- ![Code Smell](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/code_smell.svg?sanitize=true) 0 Code Smells

## Coverage and Duplications
- ![Coverage](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/CoverageChart/100.svg?sanitize=true) 100.00% Coverage (94.10% Estimated after merge)
- ![Duplications](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/Duplications/3.svg?sanitize=true) 0.00% Duplicated Code (0.70% Estimated after merge)

**Project ID:** cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs

[View in SonarQube](https://sonar-cta-dpps.zeuthen.desy.de/dashboard?id=cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs&pullRequest=2670)


author_association: NONE
user: login: mexanick
created_at: 2025-03-10T18:45:53Z
body: docs are failing due to an issue with the type hint resolution with a few similar warnings:
```
/home/runner/work/ctapipe/ctapipe/src/ctapipe/image/muon/features.py:docstring of ctapipe.image.muon.features.intensity_ratio_inside_ring:1: WARNING: py:class reference target not found: ctapipe.containers.MuonRingContainer [ref.class]
```
@maxnoe any idea how to fix it (apart from adding more ctapipe-related stuff in `nitpick_ignore_regex`?

author_association: CONTRIBUTOR
user: login: maxnoe
created_at: 2025-03-10T19:16:12Z
body: Is the `MuonRingContainer` present in `__all__` in containers.py?
author_association: MEMBER
user: login: ctao-dpps-sonarqube[bot]
created_at: 2025-03-11T09:23:42Z
body: ![Passed](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/QualityGateBadge/passed.svg?sanitize=true)

# Analysis Details
## 0 Issues
- ![Bug](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/bug.svg?sanitize=true) 0 Bugs
- ![Vulnerability](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/vulnerability.svg?sanitize=true) 0 Vulnerabilities
- ![Code Smell](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/code_smell.svg?sanitize=true) 0 Code Smells

## Coverage and Duplications
- ![Coverage](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/CoverageChart/90.svg?sanitize=true) 99.10% Coverage (93.70% Estimated after merge)
- ![Duplications](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/Duplications/3.svg?sanitize=true) 0.00% Duplicated Code (0.70% Estimated after merge)

**Project ID:** cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs

[View in SonarQube](https://sonar-cta-dpps.zeuthen.desy.de/dashboard?id=cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs&pullRequest=2670)


author_association: NONE
user: login: ctao-dpps-sonarqube[bot]
created_at: 2025-03-11T12:22:04Z
body: ![Passed](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/QualityGateBadge/passed.svg?sanitize=true)

# Analysis Details
## 0 Issues
- ![Bug](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/bug.svg?sanitize=true) 0 Bugs
- ![Vulnerability](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/vulnerability.svg?sanitize=true) 0 Vulnerabilities
- ![Code Smell](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/code_smell.svg?sanitize=true) 0 Code Smells

## Coverage and Duplications
- ![Coverage](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/CoverageChart/90.svg?sanitize=true) 99.10% Coverage (93.70% Estimated after merge)
- ![Duplications](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/Duplications/3.svg?sanitize=true) 0.00% Duplicated Code (0.70% Estimated after merge)

**Project ID:** cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs

[View in SonarQube](https://sonar-cta-dpps.zeuthen.desy.de/dashboard?id=cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs&pullRequest=2670)


author_association: NONE
user: login: ctao-dpps-sonarqube[bot]
created_at: 2025-03-11T15:31:06Z
body: ![Passed](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/QualityGateBadge/passed.svg?sanitize=true)

# Analysis Details
## 0 Issues
- ![Bug](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/bug.svg?sanitize=true) 0 Bugs
- ![Vulnerability](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/vulnerability.svg?sanitize=true) 0 Vulnerabilities
- ![Code Smell](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/common/code_smell.svg?sanitize=true) 0 Code Smells

## Coverage and Duplications
- ![Coverage](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/CoverageChart/90.svg?sanitize=true) 99.10% Coverage (93.70% Estimated after merge)
- ![Duplications](https://sonar-cta-dpps.zeuthen.desy.de/static/communityBranchPlugin/checks/Duplications/3.svg?sanitize=true) 0.00% Duplicated Code (0.70% Estimated after merge)

**Project ID:** cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs

[View in SonarQube](https://sonar-cta-dpps.zeuthen.desy.de/dashboard?id=cta-observatory_ctapipe_AY52EYhuvuGcMFidNyUs&pullRequest=2670)


author_association: NONE
user: login: maxnoe
created_at: 2025-03-27T11:43:01Z
body: ping @kosack 
author_association: MEMBER