import io
import urllib.request
from datetime import datetime
from math import ceil, log10

import matplotlib.image as mpimg
import matplotlib.pyplot as plt
import numpy as np
from matplotlib import gridspec
from matplotlib.backends.backend_pdf import PdfPages
from scipy.interpolate import interp1d

import lephare as lp

# Compatibility shim
trapezoid = np.trapezoid if hasattr(np, "trapezoid") else np.trapz

__all__ = ["PlotUtils"]


class PlotUtils:
    """
    Utility class for generating diagnostic plots and statistical summaries
    from LePHARE output tables.

    This class provides a suite of methods for analyzing photometric redshift
    and physical parameters catalogs generated by LePHARE, including comparisons between photometric
    and spectroscopic redshifts, redshift error distributions, chi-squared
    diagnostics, magnitude and color relations, and other common validation plots.

    Examples
    --------
    Typical usage example::

        from astropy.table import Table
        import lp

        t = Table.read("outputphotoz.fits")
        utils = lp.PlotUtils(
            t,
            sel_filt=3,
            pos_filt=[0, 1, 2, 4, 5, 5],
            range_z=[0, 0.5, 1, 1.5, 3],
            range_mag=[19, 20.5, 21.5, 22.5, 25]
        )
        utils.zml_zs()
        utils.zp_zs()
        utils.zml_zp()
        utils.dist_z()
        utils.dist_chi2()
        utils.dist_filt()
        utils.dist_model()
        utils.dist_ebv()
        utils.secondpeak()
        utils.bzk()
        utils.cumulative68()
        utils.check_error()
        utils.errormag()
        utils.errorz()
        utils.pit_qq()
        utils.absmag_z()
        utils.rf_color()
        utils.william()
        utils.dist_mass()
        utils.dist_sfr()
        utils.dist_ssfr()
        utils.mass_med_best()
        utils.sfr_med_best()
        utils.mass_sfr()
        utils.mass_z()
        utils.sfr_z()
        utils.lnuv_sfr()
        utils.absmagu_sfr()
        utils.absmagk_mass()
        utils.masstolight_z()

        utils.save_all_plots_pdf()

    Parameters
    ----------
    t : astropy.table.Table
        Table containing the output from a LePHARE run. Must include columns such as
        ``Z_BEST``, ``Z_MED``, ``MAG_OBS()``, ``MAG_ABS()``, ``CHI_BEST``, etc.

    sel_filt : int, optional
        Index (starting at 0) of the filter used to select objects by observed magnitude.
        Default is 0.

    pos_filt : list of int, optional
        Indices (starting at 0) of the filters corresponding to u, g, r, z, J, and Ks bands.
        Used to generate color-based plots. Default is ``[0, 0, 0, 0, 0, 0]``.

    range_z : list of float, optional
        Redshift bin edges to use in diagnostics. If not provided, quartiles of the
        spectroscopic redshift distribution (0, 0.25, 0.5, 0.75, 1 quantiles) are used.

    range_mag : list of float, optional
        Observed magnitude bin edges to use in diagnostics. If not provided, quartiles of
        the selected-band magnitude distribution (0, 0.25, 0.5, 0.75, 1 quantiles) are used.

    Attributes
    ----------
    Id : ndarray
        Object identifiers.
    zp, zml, zs : ndarray
        Best-fit, median, and spectroscopic redshifts, respectively.
    chi, chi2 : ndarray
        Chi-squared values for best-fit and secondary models.
    mag, magu, magb, magr, magz, magj, magk : ndarray
        Observed magnitudes in various filters.
    mabsu, mabsb, mabsr, mabsz, mabsj, mabsk : ndarray
        Absolute magnitudes in various filters.
    range_z, range_mag : ndarray
        Bin edges used for redshift and magnitude-based diagnostics.
    cond, condgal, condstar, condspec : ndarray of bool
        Boolean selection masks for general, galaxy, star, and spectroscopic subsets.
    z_min, z_max, mag_min, mag_max : float
        Minimum and maximum values for redshift and magnitude selection.
    nbRowM, nbColM, nbRowZ, nbColZ : int
        Number of subplot rows and columns used in magnitude and redshift binnings.

    Notes
    -----
    - The filter numbering starts at 0.
    - The table must contain all expected columns from a LePHARE output table.
    - The class methods produce a variety of plots to assess photometric redshift
      performance and SED fitting reliability.
    """

    def __init__(self, t, sel_filt=0, pos_filt=None, range_z=None, range_mag=None, pdf_col=None):
        if pos_filt is None:
            pos_filt = [0, 0, 0, 0, 0, 0]
        if range_z is None:
            range_z = [-1]
        if range_mag is None:
            range_mag = [-1]
        if pdf_col is None:
            pdf_col = "PDF_BAY_ZG()"

        # Get the number of columns in MAG_OBS
        mag_obs = t["MAG_OBS()"]
        n_cols = mag_obs.shape[1]  # Number of columns

        # Check if sel_filt is within valid range
        if sel_filt >= n_cols or sel_filt < 0:
            print("Warning: sel_filt out of bounds. Setting to 0.")
            self.sel_filt = 0  # Valeur par défaut

        # Check if each element in pos_filt is within valid range
        for i, filt in enumerate(pos_filt):
            if filt >= n_cols or filt < 0:
                print(f"Warning: pos_filt[{i}] out of bounds. Setting to 0.")
                pos_filt[i] = 0  # Valeur par défaut

        # Number of the filter start at 0
        self.sel_filt = sel_filt  # filter for the selection in mag
        self.uFilt = pos_filt[0]
        self.bFilt = pos_filt[1]
        self.rFilt = pos_filt[2]
        self.zFilt = pos_filt[3]
        self.jFilt = pos_filt[4]
        self.kFilt = pos_filt[5]
        # Check the position of the filter is compatible with the maximum number of filter

        # Read the input file
        self.Id = t["IDENT"]
        self.zp = t["Z_BEST"]
        self.zl68 = t["Z_BEST68_LOW"]
        self.zu68 = t["Z_BEST68_HIGH"]
        self.zml = t["Z_MED"]
        self.zmll68 = t["Z_MED68_LOW"]
        self.zmlu68 = t["Z_MED68_HIGH"]
        self.chi = t["CHI_BEST"]
        self.mod = t["MOD_BEST"]
        self.law = t["EXTLAW_BEST"]
        self.ebv = t["EBV_BEST"]
        self.zp2 = t["Z_SEC"]
        self.chi2 = t["CHI_SEC"]
        self.mod2 = t["MOD_SEC"]
        self.ebv2 = t["EBV_SEC"]
        self.zq = t["ZQ_BEST"]
        self.chiq = t["CHI_QSO"]
        self.modq = t["MOD_QSO"]
        self.mods = t["MOD_STAR"]
        self.chis = t["CHI_STAR"]
        self.mag = t["MAG_OBS()"][:, self.sel_filt]
        self.magu = t["MAG_OBS()"][:, self.uFilt]
        self.magb = t["MAG_OBS()"][:, self.bFilt]
        self.magr = t["MAG_OBS()"][:, self.rFilt]
        self.magz = t["MAG_OBS()"][:, self.zFilt]
        self.magj = t["MAG_OBS()"][:, self.jFilt]
        self.magk = t["MAG_OBS()"][:, self.kFilt]
        self.mabsu = t["MAG_ABS()"][:, self.uFilt]
        self.mabsb = t["MAG_ABS()"][:, self.bFilt]
        self.mabsr = t["MAG_ABS()"][:, self.rFilt]
        self.mabsz = t["MAG_ABS()"][:, self.zFilt]
        self.mabsj = t["MAG_ABS()"][:, self.jFilt]
        self.mabsk = t["MAG_ABS()"][:, self.kFilt]
        self.scale = t["SCALE_BEST"]
        self.nbFilt = t["NBAND_USED"]
        self.context = t["CONTEXT"]
        self.zs = t["ZSPEC"]
        self.ageb = t["AGE_BEST"]
        self.agel = t["AGE_INF"]
        self.agem = t["AGE_MED"]
        self.ages = t["AGE_SUP"]
        self.ldustb = t["LDUST_BEST"]
        self.ldustl = t["LDUST_INF"]
        self.ldustm = t["LDUST_MED"]
        self.ldusts = t["LDUST_SUP"]
        self.ltirb = t["LUM_TIR_BEST"]
        self.ltirl = t["LUM_TIR_INF"]
        self.ltirm = t["LUM_TIR_MED"]
        self.ltirs = t["LUM_TIR_SUP"]
        self.massb = t["MASS_BEST"]
        self.massl = t["MASS_INF"]
        self.massm = t["MASS_MED"]
        self.masss = t["MASS_SUP"]
        self.sfrb = t["SFR_BEST"]
        self.sfrl = t["SFR_INF"]
        self.sfrm = t["SFR_MED"]
        self.sfrs = t["SFR_SUP"]
        self.ssfrb = t["SSFR_BEST"]
        self.ssfrl = t["SSFR_INF"]
        self.ssfrm = t["SSFR_MED"]
        self.ssfrs = t["SSFR_SUP"]
        self.lnuv = t["LUM_NUV_BEST"]
        self.lr = t["LUM_R_BEST"]
        self.lk = t["LUM_K_BEST"]
        self.pdfs = np.array(t[pdf_col])

        # Define the panels with the binning in redshift an magnitude
        if len(range_z) <= 1:
            self.range_z = np.quantile(self.zp[(self.zp > -1) & (self.zp < 9)], [0, 0.25, 0.5, 0.75, 1])
        else:
            self.range_z = range_z
        if len(range_mag) <= 1:
            self.range_mag = np.quantile(self.mag[(self.mag > 10) & (self.mag < 40)], [0, 0.25, 0.5, 0.75, 1])
        else:
            self.range_mag = range_mag
        # Define the maximums in redshift and magnitude
        self.z_min, self.z_max = np.amin(self.range_z), np.amax(self.range_z)
        self.mag_min, self.mag_max = np.amin(self.range_mag), np.amax(self.range_mag)

        # Define the number panels
        # case with several bin of mag
        if len(self.range_mag) > 2:
            # Number of row with 2 columns
            self.nbRowM = int(ceil(float(len(self.range_mag) - 1) / 2.0))
            self.nbColM = 2
        else:
            self.nbColM = 1
            self.nbRowM = 1
        # case with several bin of z
        if len(self.range_z) > 2:
            # Number of row with 2 columns
            self.nbRowZ = int(ceil(float(len(self.range_z) - 1) / 2.0))
            self.nbColZ = 2
        else:
            self.nbColZ = 1
            self.nbRowZ = 1

        # Condition for selection
        # general condition to select the galaxies in the expected z/mag range
        self.cond = (
            (self.zp > self.z_min)
            & (self.zp < self.z_max)
            & (self.zml > self.z_min)
            & (self.zml < self.z_max)
            & (self.mag > self.mag_min)
            & (self.mag < self.mag_max)
        )
        # condition to select stars
        self.condstar = self.chis < self.chi
        # condition to select galaxies
        self.condgal = ~self.condstar
        # condition to select spectroscopic redshifts
        self.condspec = (self.zs > 0) & (self.zs < 9)

        return

    def save_photoz_plots_pdf(self, filename="all_plots.pdf", **kwargs):
        """
        Generate all photoz plotting methods in the class and save them in a single PDF.

        Parameters
        ----------
        filename : str, optional
            The output PDF filename (default "all_photoz_plots.pdf").
        **kwargs : dict, optional
            Keyword arguments to pass to the plotting methods. Common keys include:
            - bins : int
                Number of bins for histograms.
            - show_pit : bool
                Whether to include PIT histogram in applicable plots.
            - show_qq : bool
                Whether to include QQ plots in applicable methods.
            - savefig : bool
                Whether to save individual plots.
            Any method that does not accept a particular key will ignore it.
        """
        # Get all callable public methods
        plot_methods = [
            self.title_page,
            self.zml_zs,
            self.zp_zs,
            self.zml_zp,
            self.dist_z,
            self.dist_chi2,
            self.dist_filt,
            self.dist_model,
            self.dist_ebv,
            self.secondpeak,
            self.bzk,
            self.cumulative68,
            self.check_error,
            self.errormag,
            self.errorz,
            self.pit_qq,
        ]

        with PdfPages(filename) as pdf:
            for method in plot_methods:
                name = method.__name__
                print(f"Running plot method: {name}()")

                plt.close("all")  # clear previous figures

                try:
                    # Try calling with kwargs
                    method(**kwargs)
                except TypeError:
                    # Fallback if method doesn’t accept kwargs
                    method()

                figs = [plt.figure(i) for i in plt.get_fignums()]
                if not figs:
                    print(f"No figure created by {name}(), skipping.")
                    continue

                for fig in figs:
                    if not fig.axes:
                        plt.close(fig)
                        continue
                    pdf.savefig(fig, bbox_inches="tight", dpi=300)
                plt.close("all")

        print(f"All plots saved to {filename}")

    def save_phys_plots_pdf(self, filename="all_phys_plots.pdf", **kwargs):
        """
        Generate all physical parameter plots in the class and save them in a single PDF.

        Parameters
        ----------
        filename : str, optional
            The output PDF filename (default "all_phys_plots.pdf").
        **kwargs : dict, optional
            Keyword arguments to pass to the plotting methods. Common keys include:
            - bins : int
                Number of bins for histograms.
            - show_pit : bool
                Whether to include PIT histogram in applicable plots.
            - show_qq : bool
                Whether to include QQ plots in applicable methods.
            - savefig : bool
                Whether to save individual plots.
            Any method that does not accept a particular key will ignore it.
        """
        # Get all callable public methods
        plot_methods = [
            self.title_page,
            self.dist_z,
            self.dist_chi2,
            self.dist_filt,
            self.dist_model,
            self.dist_ebv,
            self.absmag_z,
            self.rf_color,
            self.william,
            self.dist_mass,
            self.dist_sfr,
            self.dist_ssfr,
            self.mass_med_best,
            self.sfr_med_best,
            self.mass_sfr,
            self.mass_z,
            self.sfr_z,
            self.lnuv_sfr,
            self.absmagu_sfr,
            self.absmagk_mass,
            self.masstolight_z,
        ]

        with PdfPages(filename) as pdf:
            for method in plot_methods:
                name = method.__name__
                print(f"Running plot method: {name}()")

                plt.close("all")  # clear previous figures

                try:
                    # Try calling with kwargs
                    method(**kwargs)
                except TypeError:
                    # Fallback if method doesn’t accept kwargs
                    method()

                figs = [plt.figure(i) for i in plt.get_fignums()]
                if not figs:
                    print(f"No figure created by {name}(), skipping.")
                    continue

                for fig in figs:
                    if not fig.axes:
                        plt.close(fig)
                        continue
                    pdf.savefig(fig, bbox_inches="tight")
                plt.close("all")

        print(f"All plots saved to {filename}")

    def title_page(self):
        """Create a title page for diagnostic plots.

        This method generates a simple title page summarizing the LePHARE
        diagnostic plots. It includes the LePHARE version and the current date.
        The information is displayed centered on the page.

        The axes are retained (not hidden) so that further annotations or
        plot elements can be added later if needed.
        """

        today = datetime.now().strftime("%Y-%m-%d")

        # Message text
        message = f"LePHARE Diagnostics\n" f"LePHARE version: {lp.__version__}\n" f"Date: {today}"

        fig, ax = plt.subplots(figsize=(8.5, 11), dpi=300)  # standard A4-ish size

        # Try to download and display the logo
        logo_url = "https://raw.githubusercontent.com/lephare-photoz/lephare-logo/main/Logo/On%20White%20Background/Colour/Digital/LePhareLogo_RGB.png"
        try:
            with urllib.request.urlopen(logo_url) as url:
                image_data = io.BytesIO(url.read())
            logo = mpimg.imread(image_data)
            # Original dimensions
            orig_width, orig_height = 3463, 3000
            aspect = orig_height / orig_width  # height / width

            # Desired width fraction of figure
            width_frac = 0.3  # logo spans 50% of figure width
            x_center = 0.5
            y_top = 0.95  # top margin

            # Compute extent while preserving aspect ratio
            x_half = width_frac / 2
            y_height = width_frac * aspect
            ax.imshow(
                logo, extent=[x_center - x_half, x_center + x_half, y_top - y_height, y_top], aspect="auto"
            )
        except Exception as e:
            print(f"Could not load LePHARE logo: {e}")

        ax.axis("off")  # Remove all axes, ticks, and labels
        ax.set_frame_on(False)  # Remove the border/frame
        ax.text(0.5, 0.5, message, ha="center", va="center", fontsize=14, transform=ax.transAxes)

        # Optionally, adjust limits for aesthetics
        ax.set_xlim(0, 1)
        ax.set_ylim(0, 1)

    def zml_zs(self):
        """
        Plot photometric redshifts (median of the PDF) versus spectroscopic redshifts.

        This diagnostic compares the median of PDF(z) (`Z_MED`)
        derived by LePHARE with the spectroscopic redshifts (`ZSPEC`) for all
        objects meeting the selection criteria. It is commonly used to assess
        the accuracy, scatter, and presence of catastrophic outliers in
        photometric redshift estimates.

        The plot is divided into subpanels based on magnitude bins defined in
        `range_mag`. Each panel displays `Z_MED` versus `ZSPEC`, with reference
        lines indicating the 1:1 relation and typical deviation thresholds.

        Notes
        -----
        - Only objects with valid spectroscopic redshifts (`0 < ZSPEC < 9`) and
          within the defined redshift and magnitude ranges are included.
        - The number and layout of subpanels depend on the magnitude binning.
        - This plot is typically one of the first diagnostics used to check the
          performance of photometric redshift estimation.

        See Also
        --------
        zml_zp : Compare median and best-fit photometric redshifts.
        zp_zs : Compare best-fit photometric redshifts versus spectroscopic redshifts.
        dist_z : Show distribution of redshift differences.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.zml_zs()
        # Produces a Z_MED vs ZSPEC scatter plot by magnitude bin.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "$z_{spec}$", ha="center", fontsize=15)
        f.text(0.04, 0.5, r"$z_{phot}\; median\; PDF(z)$", va="center", rotation="vertical", fontsize=15)

        # Loop over the magnitude bins
        for rm in range(len(self.range_mag) - 1):
            # Define the subplots and pass the panel
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # mag limits
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            ax.axis([self.z_min, self.z_max, self.z_min, self.z_max])
            conda = self.cond & self.condspec & (self.mag > magmin) & (self.mag < magmax)
            ax.scatter(
                self.zs[conda], self.zml[conda], s=1, color="b", alpha=0.5, marker="s", rasterized=True
            )

            # Check that we have some sources before performing statistics
            ngal = len(self.zml[conda])
            if ngal > 0:
                # statistics
                arg_bias = self.zml[conda] - self.zs[conda]
                arg_std = arg_bias / (1.0 + self.zs[conda])
                nmad = 1.4821 * np.median(abs(arg_std))
                cond_outl = abs(arg_std) > 0.15
                outl_rate = len(arg_std[cond_outl]) / float(ngal)
                ax.annotate(
                    r"$"
                    + str(round(magmin, 2))
                    + " < mag < "
                    + str(round(magmax, 2))
                    + "$ \n"
                    + "$N_{gal}  = "
                    + str(ngal)
                    + "$ \n"
                    + r"$\eta  ="
                    + str(round(100 * outl_rate, 2))
                    + "  \\%$\n"
                    + r"$ \sigma_{\Delta z /(1+z)}  = "
                    + str(round(nmad, 5))
                    + "$",
                    xy=(0.05 * self.z_max, 0.65 * self.z_max),
                    color="black",
                    fontsize=12,
                )

            # Trace the limits 0.15(1+z)
            x_zs = np.array([0, 6])
            ax.plot(x_zs, x_zs * 1.15 + 0.15, "c--")
            ax.plot(x_zs, x_zs, "r-")
            ax.plot(x_zs, x_zs * 0.85 - 0.15, "c--")

        return

    def zp_zs(self):
        """
        Plot photometric redshifts (minimum chi-squared value) versus spectroscopic redshifts.

        This diagnostic compares the best-fit photometric redshifts (`Z_BEST`)
        obtained from the minimum χ² solution in LePHARE with the spectroscopic
        redshifts (`ZSPEC`). It is useful for assessing the quality and bias of
        the primary redshift solution and identifying catastrophic outliers.

        The figure is divided into subpanels according to the magnitude bins
        defined in `range_mag`. Each panel shows `Z_BEST` versus `ZSPEC` along
        with reference lines for the one-to-one relation and typical redshift
        deviation thresholds (e.g., Δz / (1 + z) = ±0.05).

        Notes
        -----
        - Only sources with valid spectroscopic redshifts (`0 < ZSPEC < 9`) and
          within the defined redshift and magnitude ranges are included.
        - The panel layout is determined by the number of magnitude bins.
        - This plot complements :func:`zml_zs` by using the minimum χ² redshift
          rather than the PDF median.

        See Also
        --------
        zml_zs : Compare median photometric redshifts versus spectroscopic redshifts.
        zml_zp : Compare median and best-fit photometric redshifts.
        dist_z : Show distribution of redshift differences.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.zp_zs()
        # Produces a Z_BEST vs ZSPEC scatter plot by magnitude bin.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "$z_{spec}$", ha="center", fontsize=15)
        f.text(0.04, 0.5, r"$z_{phot}\; median\; PDF(z)$", va="center", rotation="vertical", fontsize=15)

        # Loop over the magnitude bins
        for rm in range(len(self.range_mag) - 1):
            # Define the subplots and pass the panel
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # mag limits
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            ax.axis([self.z_min, self.z_max, self.z_min, self.z_max])
            conda = self.cond & self.condspec & (self.mag > magmin) & (self.mag < magmax)
            ax.scatter(self.zs[conda], self.zp[conda], s=1, color="b", alpha=0.5, marker="s", rasterized=True)

            # Check that we have some sources before performing statistics
            ngal = len(self.zp[conda])
            if ngal > 0:
                # statistics
                arg_bias = self.zp[conda] - self.zs[conda]
                arg_std = arg_bias / (1.0 + self.zs[conda])
                nmad = 1.4821 * np.median(abs(arg_std))
                cond_outl = abs(arg_std) > 0.15
                outl_rate = len(arg_std[cond_outl]) / float(ngal)
                ax.annotate(
                    r"$"
                    + str(round(magmin, 2))
                    + " < mag < "
                    + str(round(magmax, 2))
                    + "$ \n"
                    + "$N_{gal}  = "
                    + str(ngal)
                    + "$ \n"
                    + r"$\eta  ="
                    + str(round(100 * outl_rate, 2))
                    + "  \\%$\n"
                    + r"$ \sigma_{\Delta z /(1+z)}  = "
                    + str(round(nmad, 5))
                    + "$",
                    xy=(0.05 * self.z_max, 0.65 * self.z_max),
                    color="black",
                    fontsize=12,
                )

            # Trace the limits 0.15(1+z)
            x_zs = np.array([0, 6])
            ax.plot(x_zs, x_zs * 1.15 + 0.15, "c--")
            ax.plot(x_zs, x_zs, "r-")
            ax.plot(x_zs, x_zs * 0.85 - 0.15, "c--")

        return

    def zml_zp(self):
        """
        Plot photometric redshifts (median of the PDF) versus photometric redshifts (minimum χ²).

        This diagnostic compares the two photometric redshift estimates produced by
        LePHARE: the median of the posterior probability distribution (`Z_MED`)
        and the best-fit value from the minimum χ² solution (`Z_BEST`).
        The comparison helps identify systematic biases between the two estimators
        and assess the stability of redshift determinations.

        The figure is divided into subpanels based on magnitude bins defined in
        `range_mag`. Each subpanel shows `Z_MED` versus `Z_BEST` with a 1:1
        reference line and scatter indicating consistency between the two measures.

        Notes
        -----
        - Only sources within the defined redshift and magnitude ranges are included.
        - The plot can reveal populations where the PDF and best-fit solutions diverge,
          such as multi-peaked PDFs or poor fits.
        - Strong deviations from the 1:1 line indicate inconsistency between the
          χ²-based and PDF-based estimates.

        See Also
        --------
        zml_zs : Compare median photometric redshifts versus spectroscopic redshifts.
        zp_zs : Compare best-fit photometric redshifts versus spectroscopic redshifts.
        dist_z : Show distribution of redshift distribution.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.zml_zp()
        # Produces a Z_MED vs Z_BEST comparison plot by magnitude bin.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, r"$z_{phot}\; minimum\; \chi^2$", ha="center", fontsize=15)
        f.text(0.04, 0.5, r"$z_{phot}\; median\; PDF(z)$", va="center", rotation="vertical", fontsize=15)

        # Loop over the magnitude bins
        for rm in range(len(self.range_mag) - 1):
            # Define the subplots and pass the panel
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # mag limits
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            # set the axis
            ax.axis([self.z_min, self.z_max, self.z_min, self.z_max])

            # new condition with the magnitude range
            conda = self.cond & self.condgal & (self.mag > magmin) & (self.mag < magmax)

            # Plot photo-z versus spec-z
            ax.scatter(self.zp[conda], self.zml[conda], s=1, color="b", alpha=0.5, marker="s")

            # Trace the limits 0.15(1+z)
            x_zs = np.array([0, 6])
            ax.plot(x_zs, x_zs * 1.15 + 0.15, "c--")
            ax.plot(x_zs, x_zs, "r-")
            ax.plot(x_zs, x_zs * 0.85 - 0.15, "c--")

            # labels
            ax.annotate(
                "$" + str(round(magmin, 2)) + " < mag < " + str(round(magmax, 2)) + "$",
                xy=(0.1 * self.z_max, 0.8 * self.z_max),
                color="black",
                fontsize=15,
            )

        return

    def dist_z(self, nstep=20):
        """
        Plot the redshift distribution of the spectroscopic and photometric samples.

        This diagnostic shows histograms of the spectroscopic redshifts (`ZSPEC`),
        the photometric redshifts from the minimum χ² solution (`Z_BEST`), and the
        median of the PDF (`Z_MED`). It provides an overview of the redshift coverage
        and potential biases between the photometric and spectroscopic distributions.

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use in the redshift histograms. Default is 20.

        Notes
        -----
        - Only objects satisfying the redshift and magnitude selection criteria are used.
        - Overlaid histograms allow direct comparison of the redshift distributions.
        - Discrepancies between spectroscopic and photometric distributions may indicate
          systematic shifts or incomplete coverage in certain redshift ranges.

        See Also
        --------
        zml_zs : Compare median photometric redshifts versus spectroscopic redshifts.
        zp_zs  : Compare best-fit photometric redshifts versus spectroscopic redshifts.
        zml_zp : Compare median and best-fit photometric redshifts.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.dist_z(nstep=30)
        # Produces histograms of ZSPEC, Z_BEST, and Z_MED distributions.
        """

        ## Create a figure with an array with nbRowM*nbColM subpanels
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "$Redshift$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$N_{normalized}$", va="center", rotation="vertical", fontsize=15)

        # Loop over the magnitude bins
        for rm in range(len(self.range_mag) - 1):
            # No legend
            leg = 0

            if rm == 1:
                leg = 1  # legend in the first panel

            # Define the subplots and pass the panel
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # mag limits
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            # set the axis
            ax.axis([0, self.z_max, 0, 2.9])

            # new condition with the magnitude range
            conda = self.cond & self.condgal & (self.mag > magmin) & (self.mag < magmax) & (self.zml > 0)
            # Check that some object are present
            if len(self.zp[conda]) > 0:
                # Histogram with the photometric redshifts median PDF
                ax.hist(
                    self.zml[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="b",
                    label=r"$z_{phot}\; median\; PDF(z)$",
                )

                # Histogram with the photometric redshifts minimum chi2
                ax.hist(
                    self.zp[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="r",
                    alpha=0.5,
                    label=r"$z_{phot}\; minimum\; \chi^2$",
                )

            # new condition with the magnitude range and spectro-z
            conda = self.cond & self.condspec & (self.mag > magmin) & (self.mag < magmax)
            # Check that some object are present
            if len(self.zp[conda]) > 0:
                # Histogram with the photometric redshifts zbest
                ax.hist(
                    self.zs[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="g",
                    alpha=0.2,
                    label="$z_{spectro}$",
                )
                # labels
                ax.annotate(
                    "$" + str(round(magmin, 2)) + " < mag < " + str(round(magmax, 2)) + "$",
                    xy=(0.5 * self.z_max, 1.5),
                    color="black",
                    fontsize=14,
                )

            # print the legend
            if leg == 1:
                ax.legend(prop={"size": 8})

        return

    def dist_chi2(self, nstep=20):
        """
        Plot the reduced χ² distribution as a function of observed magnitude.

        This diagnostic shows the distribution of the minimum χ² values
        (`CHI_BEST`) for different magnitude bins, helping to assess the quality
        of SED fits across brightness ranges. Poorer fits at faint magnitudes
        may indicate increased photometric noise or template limitations.

        We estimate the reduced χ² by dividing the minimum χ² by the number of
        filters used minus 1

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use in the χ² histograms. Default is 20.

        Notes
        -----
        - The figure is typically divided into subpanels according to `range_mag`.
        - Only objects meeting the redshift and magnitude selection criteria are included.
        - A broad or asymmetric χ² distribution at a given magnitude may suggest
          underestimated photometric errors or mismatched templates.

        See Also
        --------
        dist_z : Show redshift distributions.
        dist_model : Show distribution of best-fit SED model identifiers.
        dist_ebv : Show distribution of dust extinction values.
        check_error : Verify internal photometric error estimates.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.dist_chi2(nstep=30)
        # Produces χ² histograms by magnitude bin.
        """
        ## Create a figure with an array with nbRowM*nbColM subpanels
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, r"log($\chi^2$)", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$N_{normalized}$", va="center", rotation="vertical", fontsize=15)

        # Loop over the magnitude bins
        for rm in range(len(self.range_mag) - 1):
            # No legend
            leg = 0

            if rm == 2:
                leg = 1  # legend in the first panel

            # Define the subplots and pass the panel
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # mag limits
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            # set the axis
            ax.axis([-2, 2.9, 0, 1.49])

            # new condition with the magnitude range for galaxies, chi gal< chi star
            conda = (
                self.cond
                & self.condgal
                & (self.mag > magmin)
                & (self.mag < magmax)
                & (self.nbFilt > 2)
                & (self.chi > 0)
            )
            if len(self.zp[conda]) > 1:
                chireduit = self.chi[conda] / (self.nbFilt[conda] - 1)
                # Histogram of log (reduced chi2 gal)
                ax.hist(
                    np.log10(chireduit),
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="b",
                    label=r"$\chi^2_{gal} \; if \; \chi^2_{gal}<\chi^2_{stars}$",
                )

            # new condition with the magnitude range for stars, chi star < chi gal
            conda = (
                self.cond
                & self.condstar
                & (self.mag > magmin)
                & (self.mag < magmax)
                & (self.nbFilt > 2)
                & (self.chi > 0)
            )
            if len(self.zp[conda]) > 1:
                chireduit = self.chis[conda] / (self.nbFilt[conda] - 1)
                # Histogram of log (reduced chi2 gal)
                ax.hist(
                    np.log10(chireduit),
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="r",
                    alpha=0.5,
                    label=r"$\chi^2_{star} \; if \;  \chi^2_{gal}>\chi^2_{stars}$",
                )

            # print the legend
            if leg == 1:
                ax.legend(prop={"size": 8})

            # labels
            ax.annotate(
                "$" + str(round(magmin, 2)) + " < mag < " + str(round(magmax, 2)) + "$",
                xy=(-1.8, 1.2),
                color="black",
                fontsize=15,
            )

        return

    def dist_filt(self, nstep=10):
        """
        Plot the distribution of the number of filters used in each fit.

        This diagnostic shows how many photometric bands were included by LePHARE
        when fitting each object. It helps assess data completeness and detect
        cases where limited filter coverage may affect the reliability of photometric
        redshifts or SED parameters.

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use in the histogram of `NBAND_USED`. Default is 10.

        Notes
        -----
        - Uses the `NBAND_USED` column from the LePHARE output table.
        - Only sources satisfying the redshift and magnitude selection criteria are included.
        - A concentration of objects with a low number of filters may indicate missing
          photometry or incomplete coverage in certain bands.

        See Also
        --------
        dist_chi2 : Show χ² distribution by magnitude bin.
        dist_model : Show distribution of best-fit model identifiers.
        dist_ebv : Show distribution of dust extinction values.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.dist_filt(nstep=15)
        # Produces a histogram of the NBAND_USED distribution.
        """

        ## Create a figure with an array with nbRowM*nbColM subpanels
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowZ, self.nbColZ, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "Number of filters", ha="center", fontsize=15)
        f.text(0.04, 0.5, "N", va="center", rotation="vertical", fontsize=15)

        # Loop over the redshift bins
        for rm in range(len(self.range_z) - 1):
            # Define the subplots and pass the panel
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # mag limits
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]

            # set the axis
            ax.axis([0, 50, 0, 3])

            # new condition with the magnitude range
            conda = self.cond & (self.zp > zmin) & (self.zp < zmax)

            # Histogram with the number of filters
            ax.hist(self.nbFilt[conda], bins=nstep, histtype="stepfilled", density=1, color="r")

            # labels
            ax.annotate(
                "$" + str(round(zmin, 2)) + " < z < " + str(round(zmax, 2)) + "$",
                xy=(0.1, 1),
                color="black",
                fontsize=15,
            )

        return

    def dist_model(self, nstep=20):
        """
        Plot the distribution of best-fit SED model identifiers.

        This diagnostic displays the frequency of SED templates (`MOD_BEST`)
        selected by LePHARE as the best-fitting models across the sample. It helps
        visualize which galaxy, AGN, or stellar templates dominate the fits and
        can reveal biases toward certain template types.

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use in the histogram of model identifiers. Default is 20.

        Notes
        -----
        - Uses the `MOD_BEST` column from the LePHARE output table.
        - Only sources satisfying the redshift and magnitude selection criteria are included.
        - The model identifiers correspond to entries in the LePHARE template library.
        - A dominance of certain templates may indicate population trends or a lack of
          diversity in the input SED library.

        See Also
        --------
        dist_ebv : Show distribution of best-fit dust extinction values.
        dist_chi2 : Show χ² distribution by magnitude bin.
        dist_filt : Show number of filters used in each fit.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.dist_model(nstep=25)
        # Produces a histogram of MOD_BEST template identifiers.
        """

        ## Create a figure with an array with nbRowM*nbColM subpanels
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowZ, self.nbColZ, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "best fit template", ha="center", fontsize=15)
        f.text(0.04, 0.5, "N", va="center", rotation="vertical", fontsize=15)

        # Loop over the redshift bins
        for rm in range(len(self.range_z) - 1):
            # Define the subplots and pass the panel
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # set the axis
            ax.axis([0, 70, 0, 0.3])

            # new condition with the magnitude range
            conda = self.cond & self.condgal & (self.zp > zmin) & (self.zp < zmax)

            # check if some objects exist
            if len(self.mod[conda]) > 1:
                # Histogram of the models
                ax.hist(self.mod[conda], bins=nstep, histtype="stepfilled", density=1, color="b")

                # labels
                ax.annotate(
                    "$" + str(round(zmin, 2)) + " < z < " + str(round(zmax, 2)) + "$",
                    xy=(10, 0.25),
                    color="black",
                    fontsize=15,
                )

        return

    def dist_ebv(self, nstep=10):
        """
        Plot the distribution of best-fit dust extinction values (E(B-V)).

        This diagnostic shows the range and frequency of dust extinction
        (`EBV_BEST`) values derived by LePHARE for the sample. It helps assess
        typical dust content and the effects of extinction on photometric redshift
        or SED fitting results.

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use in the histogram of E(B-V) values. Default is 10.

        Notes
        -----
        - Uses the `EBV_BEST` column from the LePHARE output table.
        - Only sources meeting the redshift and magnitude selection criteria are included.
        - Peaks or concentrations in E(B-V) may indicate preferred extinction values
          in the template set or the typical dust content of the population.

        See Also
        --------
        dist_model : Show distribution of best-fit SED model identifiers.
        dist_chi2 : Show χ² distribution by magnitude bin.
        dist_filt : Show number of filters used in each fit.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.dist_ebv(nstep=15)
        # Produces a histogram of EBV_BEST values.
        """

        ## Create a figure with an array with nbRowZ*nbColZ subpanels
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowZ, self.nbColZ, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "E(B-V)", ha="center", fontsize=15)
        f.text(0.04, 0.5, "N", va="center", rotation="vertical", fontsize=15)

        # Loop over the redshift bins
        for rm in range(len(self.range_z) - 1):
            # Define the subplots and pass the panel
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # set the axis
            ax.axis([0, 0.6, 0, 20])

            # new condition with the magnitude range
            conda = self.cond & self.condgal & (self.zp > zmin) & (self.zp < zmax)

            # check if some objects exist
            if len(self.ebv[conda]) > 1:
                # Histogram with the E(B-V)
                ax.hist(self.ebv[conda], bins=nstep, histtype="stepfilled", density=1, color="b")

                # labels
                ax.annotate(
                    "$" + str(round(zmin, 2)) + " < z < " + str(round(zmax, 2)) + "$",
                    xy=(0.02, 15),
                    color="black",
                    fontsize=15,
                )

        return

    def secondpeak(self, nstep=20):
        """
        Plot the redshift distribution of the secondary photometric redshift peak.

        This diagnostic examines objects where LePHARE identifies a secondary
        photometric redshift solution (`Z_SEC`) and compares it with the
        primary solutions (`Z_BEST` and `Z_MED`). It helps identify
        multi-modal PDFs and potential catastrophic redshift outliers.

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use in the histogram of secondary peak redshifts.
            Default is 20.

        Notes
        -----
        - Only sources with a valid secondary peak (`Z_SEC`) are included.
        - The figure may be divided into subpanels based on magnitude bins.
        - Comparison of primary and secondary peaks can reveal degeneracies in SED fitting
          and ambiguous photometric redshift solutions.
        - Useful for assessing the reliability of photometric redshift catalogs
          and flagging objects with uncertain redshifts.

        See Also
        --------
        zml_zs : Compare median photometric redshift versus spectroscopic redshift.
        zp_zs : Compare best-fit photometric redshift versus spectroscopic redshift.
        zml_zp : Compare median and best-fit photometric redshifts.
        dist_z : Show overall redshift distributions.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.secondpeak(nstep=25)
        # Produces a histogram of secondary peak redshifts compared with primary solutions.
        """

        ## Create a figure with an array with nbRowM*nbColM subpanels
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "$Redshift$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$N_{normalized}$", va="center", rotation="vertical", fontsize=15)

        # Loop over the magnitude bins
        for rm in range(len(self.range_mag) - 1):
            # No legend
            leg = 0

            if rm == 4:
                leg = 1  # legend in the first panel

            # Define the subplots and pass the panel
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # mag limits
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            # set the axis
            ax.axis([0, self.z_max, 0, 5])

            # new condition with the magnitude range
            conda = self.cond & self.condgal & (self.mag > magmin) & (self.mag < magmax) & (self.zp2 > 0)

            # If some sources
            if len(self.zp2[conda]) > 1:
                # Histogram with the photometric redshifts median PDF
                ax.hist(
                    self.zp2[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="b",
                    label=r"$z_{phot}\; second\; peak$",
                )

                # Histogram with the photometric redshifts minimum chi2
                ax.hist(
                    self.zp[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="r",
                    alpha=0.5,
                    label=r"$z_{phot}\; minimum \; \chi^2$",
                )

                # labels
                ax.annotate(
                    "$" + str(round(magmin, 2)) + " < mag < " + str(round(magmax, 2)) + "$",
                    xy=(0.2, 3.5),
                    color="black",
                    fontsize=13,
                )

            # print the legend
            if leg == 1:
                ax.legend(prop={"size": 8})

        return

    def bzk(self):
        """
        Plot BzK diagram for galaxy/star classification per redshift bin.

        This diagnostic produces BzK color-color diagrams to separate star-forming
        galaxies, passive galaxies, and stars based on the observed B, z, and K
        magnitudes. The plots are generated for different redshift bins defined
        in `range_z`, allowing assessment of population segregation at different epochs.

        Notes
        -----
        - Uses the observed magnitudes in B (`magb`), z (`magz`), and K (`magk`) filters.
        - Only sources meeting the redshift and magnitude selection criteria are included.
        - Overlaid regions indicate typical BzK selection areas for star-forming galaxies,
          passive galaxies, and stars.
        - Helps identify misclassified objects or potential contamination by stars
          in galaxy samples.

        See Also
        --------
        dist_filt : Show number of filters used in each fit.
        dist_model : Show distribution of best-fit SED model identifiers.
        secondpeak : Examine secondary photometric redshift peaks.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.bzk()
        # Produces BzK color-color diagrams per redshift bin.
        """
        plt.clf()
        f, axarr = plt.subplots(2, 2, sharex=True, sharey=True, figsize=(12, 8), squeeze=False)
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$B-z$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$z-K$", va="center", rotation="vertical", fontsize=15)

        bzk_conf = [[0.001, 1.4, 0, 0], [1.4, 2.7, 0, 1], [2.7, 6, 1, 0], [-0.001, 6, 1, 1]]

        for rm in range(4):
            zmin = bzk_conf[rm][0]
            zmax = bzk_conf[rm][1]
            ax = axarr[bzk_conf[rm][2], bzk_conf[rm][3]]
            ax.axis([-0.5, 5.5, -1.0, 4.5])

            if rm < 3:
                conda = self.cond & self.condgal & (self.zp > zmin) & (self.zp < zmax)
            else:
                conda = self.cond & self.condstar

            if (self.bFilt >= 0) and (self.zFilt >= 0) and (self.kFilt >= 0):
                colx = self.magb - self.magz
                coly = self.magz - self.magk
                ax.scatter(colx[conda], coly[conda], s=1, color="b", alpha=0.2, marker="s", rasterized=True)

            ax.plot([-1, 6], [-0.5, 6])

            if rm < 3:
                ax.annotate(f"${zmin:.2f} < z < {zmax:.2f}$", xy=(0.1, 3), color="black", fontsize=15)
            else:
                ax.annotate(r"$\chi_s < \chi_g$", xy=(0.1, 3), color="black", fontsize=15)

        return

    def absmag_z(self):
        """
        Plot B-band absolute magnitude versus redshift for the sample.

        This diagnostic shows the relation between absolute magnitudes
        and redshift, providing insight into luminosity evolution, selection effects, and completeness
        limits of the survey.

        Notes
        -----
        - Uses the B-band absolute magnitudes
        - Only objects within the redshift and magnitude selection ranges are included.
        - The figure can be divided into subpanels based on magnitude or redshift bins.
        - Helps identify trends such as brightening or dimming with redshift
          and the distribution of galaxies in the luminosity-redshift plane.

        See Also
        --------
        rf_color : Rest-frame color versus redshift.
        bzk : BzK color-color diagram for star/galaxy classification.
        dist_z : Redshift distributions.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.absmag_z()
        # Produces plots of absolute magnitude versus redshift.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$Redshift$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$M_B$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_mag) - 1):
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            ax.axis([self.z_min, self.z_max, -16, -25.9])

            conda = self.cond & self.condgal & (self.mag > magmin) & (self.mag < magmax)
            ax.scatter(self.zp[conda], self.mabsb[conda], s=1, color="b", alpha=0.2, marker="s")

            ax.annotate(f"${magmin:.2f} < mag < {magmax:.2f}$", xy=(0.1, -25), color="black", fontsize=15)

        return

    def rf_color(self):
        """
        Plot rest-frame colors U-R versus absolute magnitude R.

        This diagnostic shows the relation between rest-frame colors
        and absolute magnitudes in different bands, allowing analysis of galaxy
        populations, color bimodality, and stellar population trends.

        Notes
        -----
        - Uses absolute magnitudes (`MAG_ABS()`) and rest-frame colors derived from
          the best-fit templates.
        - Only objects within the redshift and magnitude selection ranges are included.
        - Can reveal red sequence and blue cloud populations in the color–magnitude diagram.
        - Subpanels can be used to separate objects by magnitude or redshift bins.

        See Also
        --------
        absmag_z : Absolute magnitude versus redshift.
        bzk : BzK color-color diagram for star/galaxy classification.
        dist_model : Distribution of best-fit SED models.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.rf_color()
        # Produces rest-frame color versus absolute magnitude plots.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$M_R$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$M_U-M_R$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_z) - 1):
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            ax.axis([-24.9, -16, -0.7, 2.4])

            conda = self.cond & self.condgal & (self.zp > zmin) & (self.zp < zmax)

            if self.uFilt >= 0 and self.rFilt >= 0:
                magx = self.mabsr
                coly = self.mabsu - self.mabsr

                ax.scatter(magx[conda], coly[conda], s=1, color="b", alpha=0.2, marker="s", rasterized=True)

                ax.annotate(f"${zmin:.2f} < z < {zmax:.2f}$", xy=(-20, 1), color="black", fontsize=15)

        return

    def william(self):
        """
        Create a rest-frame U-R, R-J color-color plot.

        This diagnostic reproduces the rest-frame color-color diagrams of
        ''William'', which are commonly used to separate galaxy populations
        (e.g., quiescent vs star-forming) and study stellar population properties.

        Notes
        -----
        - Uses rest-frame magnitudes or colors derived from the best-fit templates.
        - Only objects within the redshift and magnitude selection ranges are included.
        - Subpanels can be applied based on redshift or magnitude bins to study
          evolution with cosmic time.
        - Helps visualize the bimodality of galaxy populations in rest-frame color space.

        See Also
        --------
        rf_color : Rest-frame color versus absolute magnitude.
        absmag_z : Absolute magnitude versus redshift.
        bzk : BzK color-color diagram for star/galaxy classification.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.william()
        # Produces a William style rest-frame color-color plot.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$M_R-M_J$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$M_U-M_R$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_z) - 1):
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            ax.axis([-2.1, 1.9, 0, 2.5])

            conda = self.cond & self.condgal & (self.zp > zmin) & (self.zp < zmax)

            if self.uFilt >= 0 and self.rFilt >= 0 and self.jFilt >= 0:
                colx = self.mabsr - self.mabsj
                coly = self.mabsu - self.mabsr

                ax.scatter(colx[conda], coly[conda], s=1, color="b", alpha=0.2, marker="s", rasterized=True)

                ax.annotate(f"${zmin:.2f} < z < {zmax:.2f}$", xy=(0.1, 2), color="black", fontsize=15)

        return

    def cumulative68(self, nstep=20):
        """
        Check that 68% of spectroscopic redshifts fall within the 68% photometric error interval.

        This diagnostic evaluates the accuracy of photometric redshift uncertainties
        by computing the fraction of objects for which the spectroscopic redshift
        (`ZSPEC`) lies within the 68% confidence interval of the photometric redshift
        PDF (`Z_MED68_LOW` to `Z_MED68_HIGH` or `Z_BEST68_LOW` to `Z_BEST68_HIGH`).
        It helps validate the reliability of the estimated errors.

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use when plotting cumulative distributions. Default is 20.

        Notes
        -----
        - Only objects with valid spectroscopic redshifts are considered.
        - A well-calibrated photometric redshift error distribution should contain
          ~68% of spectroscopic redshifts within the 68% interval.
        - Deviations from 68% indicate under- or overestimated photometric uncertainties.

        See Also
        --------
        errormag : Compare magnitude errors versus χ².
        errorz : Compare photometric redshift errors with scatter.
        zml_zs : Compare median photometric redshift versus spectroscopic redshift.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.cumulative68(nstep=30)
        # Evaluates the fraction of spectroscopic redshifts within the 68% photometric error interval.
        """

        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$abs((z_{spec}-z_{phot})/z_{phot} uncertainties)$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$cumulative(N)$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_mag) - 1):
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]
            ax.axis([0, 9.9, 0, 0.99])

            diffzml = abs(self.zml - self.zs) / np.maximum(
                abs(self.zmlu68 - self.zml), abs(self.zmll68 - self.zml)
            )
            diffzp = abs(self.zp - self.zs) / np.maximum(abs(self.zu68 - self.zp), abs(self.zl68 - self.zp))

            condazml = (
                self.cond
                & self.condspec
                & (self.mag > magmin)
                & (self.mag < magmax)
                & (diffzml < 10)
                & (self.zml > 0)
            )
            condazp = (
                self.cond
                & self.condspec
                & (self.mag > magmin)
                & (self.mag < magmax)
                & (diffzp < 10)
                & (self.zp > 0)
            )

            if len(self.zml[condazml]) > 1 and len(self.zp[condazp]) > 1:
                ax.hist(
                    diffzp[condazp],
                    bins=nstep,
                    histtype="step",
                    density=1,
                    color="b",
                    cumulative=True,
                    label=r"$z_{phot}\; minimum \; \chi^2$",
                )
                ax.hist(
                    diffzml[condazml],
                    bins=nstep,
                    histtype="step",
                    density=1,
                    color="r",
                    cumulative=True,
                    label=r"$z_{phot}\; median \; PDF$",
                )

                ax.axvline(x=1, color="r", linestyle="--")
                ax.axhline(y=0.68, color="r", linestyle="--")

                ax.legend(prop={"size": 8})

            ax.annotate(f"${magmin:.2f} < mag < {magmax:.2f}$", xy=(0.1, 0.80), color="black", fontsize=15)

        return

    def check_error(self):
        """
        Check photometric redshift errors as a function of magnitude.

        This diagnostic evaluates how the estimated photometric redshift
        uncertainties change as a function of observed magnitude.

        Notes
        -----
        - Uses the columns `Z_BEST68_LOW`, `Z_BEST68_HIGH`, `Z_MED68_LOW`, `Z_MED68_HIGH`,
          and `ZSPEC`.
        - The plot shows error distributions as a function of the selected magnitude band.
        - Can reveal trends where photometric errors are underestimated at faint magnitudes
          or overestimated at bright magnitudes.

        See Also
        --------
        cumulative68 : Check fraction of spectroscopic redshifts within the 68% photometric error.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.check_error()
        # Produces a plot of photometric redshift errors versus observed magnitude.
        """
        # Clear the figure
        plt.clf()
        # Set the axis
        plt.axis([15, 27, -1, 1])

        # 1 sigma error on the photo-z
        diffu = self.zu68 - self.zp
        diffl = self.zl68 - self.zp

        conda = self.cond & self.condgal

        # Error versus mag
        plt.scatter(self.mag[conda], diffu[conda], s=1, color="b", alpha=0.5, marker="s", rasterized=True)
        plt.scatter(self.mag[conda], diffl[conda], s=1, color="r", alpha=0.5, marker="s", rasterized=True)

        # 68%
        plt.plot([15, 27], [0, 0], color="r")  # Adapte les limites x à ton graphique

        plt.xlabel("magnitude", fontsize=18, labelpad=13)
        plt.ylabel(r"$68\% \; photo-z \; error$", fontsize=18, labelpad=13)

        return

    def errormag(self):
        """
        Plot photometric redshift errors as a function of observed magnitude.

        This diagnostic shows how the estimated photometric redshift errors
        (e.g., derived from the PDF width or χ² fitting) vary with object brightness.
        It is useful for assessing the reliability of redshift estimates across
        the magnitude range.

        Notes
        -----
        - Uses the selected magnitude (`mag`) and corresponding photometric redshift
          errors from the LePHARE output.
        - Only objects within the redshift and magnitude selection criteria are included.
        - Typically, errors increase for fainter objects due to lower signal-to-noise.

        See Also
        --------
        check_error : Evaluate 68% error coverage as a function of magnitude.
        errorz : Compare photometric redshift errors as a function of redshift.
        cumulative68 : Check fraction of spectroscopic redshifts within the 68% photometric error.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.errormag()
        # Produces a plot of photometric redshift errors versus magnitude.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$mag$", ha="center", fontsize=15)
        f.text(0.04, 0.5, r"$z_{phot} \; uncertainties$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_z) - 1):
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]
            ax = axarr[(rm // 2) % self.nbRowM, rm % 2]

            mstep = 0.2
            mstart = 16
            mnbstep = 100

            medpvec = []
            mednvec = []
            magvec = []

            ax.axis([15, 27, -0.99, 1])

            for im in range(mnbstep):
                diffp = self.zmlu68 - self.zml
                diffn = self.zmll68 - self.zml

                mlimi = mstart + mstep * im
                mlims = mstart + mstep * (im + 1)

                conda = (
                    self.cond
                    & self.condgal
                    & (self.mag > mlimi)
                    & (self.mag <= mlims)
                    & (self.zml > zmin)
                    & (self.zml < zmax)
                )

                if len(self.zml[conda]) > 3:
                    medp = np.median(diffp[conda])
                    medn = np.median(diffn[conda])

                    medpvec.append(medp)
                    mednvec.append(medn)
                    magvec.append(mstart + mstep * (im + 0.5))

            ax.plot(magvec, medpvec, color="b", linestyle="-")
            ax.plot(magvec, mednvec, color="r", linestyle="-")

            ax.annotate(f"${zmin:.2f} < z < {zmax:.2f}$", xy=(mstart, 0.60), color="black", fontsize=15)

        return

    def errorz(self):
        """
        Plot photometric redshift errors as a function of redshift.

        This diagnostic shows how the estimated photometric redshift uncertainties
        (e.g., derived from the PDF width or χ² fitting) vary with redshift.
        It is useful for identifying redshift ranges where photometric redshifts
        may be less reliable or exhibit larger scatter.

        Notes
        -----
        - Uses the photometric redshifts (`Z_BEST` or `Z_MED`) and their
          associated uncertainties from the LePHARE output.
        - Only objects within the redshift and magnitude selection criteria are included.
        - Errors often increase at high redshift due to fainter magnitudes and
          fewer spectral features in the observed bands.

        See Also
        --------
        errormag : Photometric redshift errors versus observed magnitude.
        check_error : Evaluate 68% error coverage as a function of magnitude.
        cumulative68 : Check fraction of spectroscopic redshifts within the 68% photometric error.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.errorz()
        # Produces a plot of photometric redshift errors versus redshift.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$z_{phot}$", ha="center", fontsize=15)
        f.text(0.04, 0.5, r"$z_{phot} \; uncertainties$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_mag) - 1):
            ax = axarr[(rm // 2) % self.nbRowM, rm % 2]
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            zstep = 0.1
            zstart = 0
            znbstep = 60

            medpvec = []
            mednvec = []
            zvec = []

            ax.axis([0, 5.9, -0.99, 1])

            for iz in range(znbstep):
                diffp = self.zmlu68 - self.zml
                diffn = self.zmll68 - self.zml

                zlimi = zstart + zstep * iz
                zlims = zstart + zstep * (iz + 1)

                conda = (
                    self.cond
                    & self.condgal
                    & (self.zml > zlimi)
                    & (self.zml <= zlims)
                    & (self.mag > magmin)
                    & (self.mag < magmax)
                )

                if len(self.zml[conda]) > 3:
                    medp = np.median(diffp[conda])
                    medn = np.median(diffn[conda])

                    medpvec.append(medp)
                    mednvec.append(medn)
                    zvec.append(zstart + zstep * (iz + 0.5))

            ax.plot(zvec, medpvec, color="b", linestyle="-")
            ax.plot(zvec, mednvec, color="r", linestyle="-")

            ax.annotate(
                f"${magmin:.2f} < mag < {magmax:.2f}$",
                xy=(zstart + 0.2, 0.60),
                color="black",
                fontsize=15,
            )

        return

    def pit_qq(
        self,
        pdfs=None,
        zgrid=None,
        ztrue=None,
        bins=None,
        title=None,
        show_pit=True,
        show_qq=True,
        savefig=False,
    ) -> str:
        """
        Generate a PIT and QQ quantile-quantile plot for photometric redshift PDFs.

        This function creates a visualization to assess the calibration of
        predicted PDFs against true redshifts. It can display a Probability
        Integral Transform (PIT) histogram and/or a QQ plot, optionally saving
        the figure.

        Parameters
        ----------
        pdfs : np.ndarray, shape (m, n), optional
            Array of PDFs for m objects over n z-grid points. If None, use the
            PDFs from the class Metrics object.
        zgrid : np.ndarray, shape (n,), optional
            The z-axis corresponding to the PDF bins. Required if `pdfs` is provided.
        ztrue : np.ndarray, shape (m,), optional
            True redshift values for the m objects. Required if `pdfs` is provided.
        bins : int, optional
            Number of bins for the PIT histogram. If None, use the default
            number of quantiles defined in the sample (`sample.n_quant`).
        title : str, optional
            Title for the plot. If None, a formatted sample name will be used.
        show_pit : bool, default=True
            Whether to include the PIT histogram in the figure.
        show_qq : bool, default=True
            Whether to include the QQ plot in the figure.
        savefig : bool, default=False
            Whether to save the plot to a PNG file. If True, the filename will
            be automatically generated.

        Returns
        -------
        str
            Path to the saved figure if `savefig=True`; otherwise an empty string.

        Notes
        -----
        - PIT (Probability Integral Transform) values should be uniformly
        distributed if the PDFs are well-calibrated.
        - QQ plot compares the quantiles of the PIT distribution against a
        uniform distribution to visually assess calibration.
        """

        if pdfs is None:
            pdfs = self.pdfs
        if zgrid is None:
            zgrid = np.linspace(0, 6, pdfs.shape[1])
        if ztrue is None:
            ztrue = self.zs
        if bins is None:
            bins = 100
        if title is None:
            title = ""

        plt.figure(figsize=[4, 5])
        gs = gridspec.GridSpec(2, 1, height_ratios=[3, 1])
        ax0 = plt.subplot(gs[0])
        # sample = Sample(pdfs, zgrid, ztrue)
        pit_vals = integrate_pdfs_to_ztrue(pdfs, zgrid, ztrue)
        pit_out_rate = np.mean((pit_vals < 0.005) | (pit_vals > 0.995))
        qq = [np.linspace(0, 1, len(ztrue)), pit_vals[np.argsort(pit_vals)]]

        if show_qq:
            ax0.plot(
                qq[0], qq[1], c="r", linestyle="-", linewidth=3, label=f"PIT$_{{out}}$:{pit_out_rate:.2f}"
            )
            ax0.plot([0, 1], [0, 1], color="k", linestyle="--", linewidth=2)
            ax0.set_ylabel("Q$_{data}$", fontsize=18)
            plt.ylim(-0.001, 1.001)
        plt.xlim(-0.001, 1.001)
        plt.title(title)
        if show_pit:
            # fzdata = Ensemble(interp, data=dict(xvals=zgrid, yvals=pdfs))
            # pitobj = PIT(fzdata, ztrue)
            # pit_vals = np.array(pitobj.pit_samps)
            # pit_out_rate = pitobj.evaluate_PIT_outlier_rate()

            try:
                y_uni = float(len(pit_vals)) / float(bins)
            except TypeError:  # when bins is an array
                y_uni = float(len(pit_vals)) / float(len(bins))
            if not show_qq:
                ax0.hist(pit_vals, bins=bins, alpha=0.7, label="LePHARE")
                ax0.set_ylabel("Number")
                ax0.hlines(y_uni, xmin=0, xmax=1, color="k")
                plt.ylim(
                    0,
                )  # -0.001, 1.001)
            else:
                ax1 = ax0.twinx()
                hist = ax1.hist(pit_vals, bins=bins, alpha=0.7)
                ax1.set_ylim([0, 1.1 * np.max(hist[0][1:-1])])
                ax1.set_ylabel("Number")
                ax1.hlines(y_uni, xmin=0, xmax=1, color="k")
        leg = ax0.legend(handlelength=0, handletextpad=0, fancybox=True, loc="upper left")
        for item in leg.legend_handles:
            item.set_visible(False)
        if show_qq:
            ax2 = plt.subplot(gs[1])
            ax2.plot(
                qq[0],
                (qq[1] - qq[0]),
                c="r",
                linestyle="-",
                linewidth=3,
            )
            plt.ylabel(r"$\Delta$Q", fontsize=18)
            ax2.plot([0, 1], [0, 0], color="k", linestyle="--", linewidth=2)
            plt.xlim(-0.001, 1.001)
            plt.ylim(
                np.min([-0.12, np.min(qq[1] - qq[0]) * 1.05]),
                np.max([0.12, np.max(qq[1] - qq[0]) * 1.05]),
            )
        if show_pit:
            if show_qq:
                plt.xlabel("Q$_{theory}$ / PIT Value", fontsize=18)
            else:
                plt.xlabel("PIT Value", fontsize=18)
        else:
            if show_qq:
                plt.xlabel("Q$_{theory}$", fontsize=18)
        if savefig:
            fig_filename = str("plot_pit_qq_" + "lephare.png")
            plt.savefig(fig_filename)
        else:
            fig_filename = None
        return fig_filename

    def dist_mass(self, nstep=10):
        """
        Plot the distribution of stellar mass

        This diagnostic shows the range and frequency of stellar mass values
        derived by LePHARE for the sample.

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use in the histogram of mass values. Default is 10.

        Notes
        -----
        - Uses the `MASS_BEST` and 'MASS_MED' column from the LePHARE output table.
        - Only sources meeting the redshift and magnitude selection criteria are included.

        See Also
        --------
        dist_model : Show distribution of best-fit SED model identifiers.
        dist_filt  : Show number of filters used in each fit.
        dist_SFR   : SFR distribution

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.dist_mass(nstep=15)
        # Produces a histogram of MASS_BEST and MASS_MED values.
        """

        ## Create a figure with an array with nbRowZ*nbColZ subpanels
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowZ, self.nbColZ, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "log10(stellar mass)", ha="center", fontsize=15)
        f.text(0.04, 0.5, "N", va="center", rotation="vertical", fontsize=15)

        # Loop over the redshift bins
        for rm in range(len(self.range_z) - 1):
            # Define the subplots and pass the panel
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # set the axis
            ax.axis([6, 11.9, 0, 1.3])

            # new condition within the redshift range
            conda = self.cond & self.condgal & (self.zp > zmin) & (self.zp < zmax) & (self.massm > 0)

            # check if some objects exist
            if len(self.massm[conda]) > 1:
                # Histogram of the mass
                ax.hist(
                    self.massm[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="b",
                    label="median of the PDF",
                )

            # new condition within the redshift range
            conda = self.cond & self.condgal & (self.zp > zmin) & (self.zp < zmax) & (self.massb > 0)

            # check if some objects exist
            if len(self.massb[conda]) > 1:
                # Histogram of the mass
                ax.hist(
                    self.massb[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="r",
                    alpha=0.5,
                    label=r"$minimum\; \chi^2$",
                )

            # labels
            ax.annotate(
                "$" + str(round(zmin, 2)) + " < z < " + str(round(zmax, 2)) + "$",
                xy=(8, 1),
                color="black",
                fontsize=15,
            )

            # print the legend
            if rm == 1:
                ax.legend(prop={"size": 8})

        return

    def dist_sfr(self, nstep=10):
        """
        Plot the distribution of SFR

        This diagnostic shows the range and frequency of SFR values
        derived by LePHARE for the sample.

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use in the histogram of mass values. Default is 10.

        Notes
        -----
        - Uses the `SFR_BEST` and 'SFR_MED' column from the LePHARE output table.
        - Only sources meeting the redshift and magnitude selection criteria are included.

        See Also
        --------
        dist_model : Show distribution of best-fit SED model identifiers.
        dist_filt  : Show number of filters used in each fit.
        dist_mass  : Mass distribution

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.dist_sfr(nstep=15)
        # Produces a histogram of SFR_BEST and SFR_MED values.
        """

        ## Create a figure with an array with nbRowZ*nbColZ subpanels
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowZ, self.nbColZ, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "log10(SFR)", ha="center", fontsize=15)
        f.text(0.04, 0.5, "N", va="center", rotation="vertical", fontsize=15)

        # Loop over the redshift bins
        for rm in range(len(self.range_z) - 1):
            # Define the subplots and pass the panel
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # set the axis
            ax.axis([-2, 3, 0, 1.3])

            # new condition within the redshift range
            conda = self.cond & self.condgal & (self.zp > zmin) & (self.zp < zmax) & (self.sfrm > 0)

            # check if some objects exist
            if len(self.sfrm[conda]) > 1:
                # Histogram of the SFR
                ax.hist(
                    self.sfrm[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="b",
                    label="median of the PDF",
                )

            # new condition within the redshift range
            conda = self.cond & self.condgal & (self.zp > zmin) & (self.zp < zmax) & (self.sfrb > 0)

            # check if some objects exist
            if len(self.sfrb[conda]) > 1:
                # Histogram of the SFR
                ax.hist(
                    self.sfrb[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="r",
                    alpha=0.5,
                    label=r"$minimum\; \chi^2$",
                )

            # labels
            ax.annotate(
                "$" + str(round(zmin, 2)) + " < z < " + str(round(zmax, 2)) + "$",
                xy=(-1, 1),
                color="black",
                fontsize=15,
            )

            # print the legend
            if rm == 1:
                ax.legend(prop={"size": 8})

        return

    def dist_ssfr(self, nstep=10):
        """
        Plot the distribution of specific SFR

        This diagnostic shows the range and frequency of sSFR values
        derived by LePHARE for the sample.

        Parameters
        ----------
        nstep : int, optional
            Number of bins to use in the histogram of mass values. Default is 10.

        Notes
        -----
        - Uses the `SSFR_BEST` and 'SSFR_MED' column from the LePHARE output table.
        - Only sources meeting the redshift and magnitude selection criteria are included.

        See Also
        --------
        dist_model : Show distribution of best-fit SED model identifiers.
        dist_filt  : Show number of filters used in each fit.
        dist_mass  : Mass distribution

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.dist_ssfr(nstep=15)
        # Produces a histogram of SSFR_BEST and SSFR_MED values.
        """

        ## Create a figure with an array with nbRowZ*nbColZ subpanels
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowZ, self.nbColZ, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, "log10(sSFR)", ha="center", fontsize=15)
        f.text(0.04, 0.5, "N", va="center", rotation="vertical", fontsize=15)

        # Loop over the redshift bins
        for rm in range(len(self.range_z) - 1):
            # Define the subplots and pass the panel
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # set the axis
            ax.axis([-14, -7, 0, 1.3])

            # new condition within the redshift range
            conda = (
                self.cond
                & self.condgal
                & (self.zp > zmin)
                & (self.zp < zmax)
                & (self.ssfrm > -20)
                & (self.ssfrm < -6)
            )

            # check if some objects exist
            if len(self.ssfrm[conda]) > 1:
                # Histogram of the SFR
                ax.hist(
                    self.ssfrm[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="b",
                    label="median of the PDF",
                )

            # new condition within the redshift range
            conda = (
                self.cond
                & self.condgal
                & (self.zp > zmin)
                & (self.zp < zmax)
                & (self.ssfrb > -20)
                & (self.ssfrb < -6)
            )

            # check if some objects exist
            if len(self.ssfrb[conda]) > 1:
                # Histogram of the SFR
                ax.hist(
                    self.ssfrb[conda],
                    bins=nstep,
                    histtype="stepfilled",
                    density=1,
                    color="r",
                    alpha=0.5,
                    label=r"$minimum\; \chi^2$",
                )

            # labels
            ax.annotate(
                "$" + str(round(zmin, 2)) + " < z < " + str(round(zmax, 2)) + "$",
                xy=(-13.5, 1),
                color="black",
                fontsize=15,
            )

            # print the legend
            if rm == 1:
                ax.legend(prop={"size": 8})

        return

    def mass_med_best(self):
        """
        Plot stellar mass median of the PDF versus minimum χ²

        This diagnostic compares the two mass estimates produced by
        LePHARE: the median of the posterior probability distribution (`MASS_MED`)
        and the best-fit value from the minimum χ² solution (`MASS_BEST`).
        The comparison helps identify systematic biases between the two estimators
        and assess the stability of mass determinations.

        The figure is divided into subpanels based on magnitude bins defined in
        `range_mag`. Each subpanel shows `MASS_MED` versus `MASS_BEST` with a 1:1
        reference line and scatter indicating consistency between the two measures.

        Notes
        -----
        - Only sources within the defined redshift and magnitude ranges are included.
        - The plot can reveal populations where the PDF and best-fit solutions diverge,
          such as multi-peaked PDFs or poor fits.
        - Strong deviations from the 1:1 line indicate inconsistency between the
          χ²-based and PDF-based estimates.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.mass_med_best()
        # Produces a MASS_MED vs MASS_BEST comparison plot by magnitude bin.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, r"$mass \; minimum\; \chi^2$", ha="center", fontsize=15)
        f.text(0.04, 0.5, r"$mass \; median\; PDF(z)$", va="center", rotation="vertical", fontsize=15)

        # Loop over the magnitude bins
        for rm in range(len(self.range_mag) - 1):
            # Define the subplots and pass the panel
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # mag limits
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            # set the axis
            ax.axis([6, 11.9, 6, 11.9])

            # new condition with the magnitude range
            conda = self.cond & self.condgal & (self.mag > magmin) & (self.mag < magmax)

            # Plot mass med versus best
            ax.scatter(self.massb[conda], self.massm[conda], s=1, color="b", alpha=0.5, marker="s")

            # Trace the limits 0.15(1+z)
            x_zs = np.array([6, 15])
            ax.plot(x_zs, x_zs, "r-")

            # labels
            ax.annotate(
                "$" + str(round(magmin, 2)) + " < mag < " + str(round(magmax, 2)) + "$",
                xy=(7, 11),
                color="black",
                fontsize=15,
            )

        return

    def sfr_med_best(self):
        """
        Plot SFR median of the PDF versus minimum χ²

        This diagnostic compares the two mass estimates produced by
        LePHARE: the median of the posterior probability distribution (`SFR_MED`)
        and the best-fit value from the minimum χ² solution (`SFR_BEST`).
        The comparison helps identify systematic biases between the two estimators
        and assess the stability of mass determinations.

        The figure is divided into subpanels based on magnitude bins defined in
        `range_mag`. Each subpanel shows `SFR_MED` versus `SFR_BEST` with a 1:1
        reference line and scatter indicating consistency between the two measures.

        Notes
        -----
        - Only sources within the defined redshift and magnitude ranges are included.
        - The plot can reveal populations where the PDF and best-fit solutions diverge,
          such as multi-peaked PDFs or poor fits.
        - Strong deviations from the 1:1 line indicate inconsistency between the
          χ²-based and PDF-based estimates.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.sfr_med_best()
        # Produces a SFR_MED vs SFR_BEST comparison plot by magnitude bin.
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        # No space between the figures
        plt.subplots_adjust(hspace=0, wspace=0)

        # label of the figure
        f.text(0.5, 0.04, r"$SFR \; minimum\; \chi^2$", ha="center", fontsize=15)
        f.text(0.04, 0.5, r"$SFR \; median\; PDF(z)$", va="center", rotation="vertical", fontsize=15)

        # Loop over the magnitude bins
        for rm in range(len(self.range_mag) - 1):
            # Define the subplots and pass the panel
            ax = axarr[int(rm // 2.0), int(rm % 2)]

            # mag limits
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            # set the axis
            ax.axis([-2, 3.9, -2, 3.9])

            # new condition with the magnitude range
            conda = self.cond & self.condgal & (self.mag > magmin) & (self.mag < magmax)

            # Plot SFR med versus best
            ax.scatter(self.sfrb[conda], self.sfrm[conda], s=1, color="b", alpha=0.5, marker="s")

            # Trace the limits 0.15(1+z)
            x_zs = np.array([-6, 15])
            ax.plot(x_zs, x_zs, "r-")

            # labels
            ax.annotate(
                "$" + str(round(magmin, 2)) + " < mag < " + str(round(magmax, 2)) + "$",
                xy=(-1, 3),
                color="black",
                fontsize=15,
            )

        return

    def mass_z(self):
        """
        Plot stellar mass versus redshift for the sample.

        This diagnostic shows the relation between stellar masses
        and redshift, providing insight into mass evolution, selection effects, and completeness
        limits of the survey.

        Notes
        -----
        - Uses the stellar mass
        - Only objects within the redshift and magnitude selection ranges are included.
        - The figure can be divided into subpanels based on magnitude or redshift bins.
        - Helps identify trends such as brightening or dimming with redshift
          and the distribution of galaxies in the mass-redshift plane.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.mass_z()
        # Produces plots of mass versus redshift
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$redshift$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$log10(mass)$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_mag) - 1):
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            ax.axis([self.z_min, self.z_max, 7, 11.9])

            conda = self.cond & self.condgal & (self.mag > magmin) & (self.mag < magmax)
            ax.scatter(self.zp[conda], self.massb[conda], s=1, color="b", alpha=0.2, marker="s")

            ax.annotate(f"${magmin:.2f} < mag < {magmax:.2f}$", xy=(0.2, 11.5), color="black", fontsize=15)

        return

    def sfr_z(self):
        """
        Plot SFR versus redshift for the sample.

        This diagnostic shows the relation between SFR
        and redshift, providing insight into mass evolution, selection effects, and completeness
        limits of the survey.

        Notes
        -----
        - Uses the SFR
        - Only objects within the redshift and magnitude selection ranges are included.
        - The figure can be divided into subpanels based on magnitude or redshift bins.
        - Helps identify trends such as brightening or dimming with redshift
          and the distribution of galaxies in the mass-redshift plane.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.sfr_z()
        # Produces plots of mass versus redshift
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$redshift$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$log10(SFR)$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_mag) - 1):
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            ax.axis([self.z_min, self.z_max, -3, 3.9])

            conda = self.cond & self.condgal & (self.mag > magmin) & (self.mag < magmax)
            ax.scatter(self.zp[conda], self.sfrb[conda], s=1, color="b", alpha=0.2, marker="s")

            ax.annotate(f"${magmin:.2f} < mag < {magmax:.2f}$", xy=(0.2, 3.0), color="black", fontsize=15)

        return

    def mass_sfr(self):
        """
        Plot stellar mass versus SFR for the sample.

        This diagnostic shows the main sequence

        Notes
        -----
        - Uses the stellar mass and SFR
        - Only objects within the redshift and magnitude selection ranges are included.
        - The figure can be divided into subpanels based on magnitude or redshift bins.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.mass_sfr()
        # Produces plots of mass versus SFR
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$log10(mass)$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$log10(SFR)$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_z) - 1):
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]

            ax.axis([7, 12, -3, 4])

            # new condition with the redshift range and star-forming
            conda = self.cond & (self.zp > zmin) & (self.zp < zmax) & (self.ssfrb > -11) & (self.ssfrb < 90)
            ax.scatter(self.massb[conda], self.sfrb[conda], s=1, color="b", alpha=0.2, marker="s")

            # new condition with the redshift range and quiescent
            conda = self.cond & (self.zp > zmin) & (self.zp < zmax) & (self.ssfrb < -11)
            ax.scatter(self.massb[conda], self.sfrb[conda], s=1, color="r", alpha=0.2, marker="s")

            ax.annotate(f"${zmin:.2f} < z < {zmax:.2f}$", xy=(8, 3), color="black", fontsize=15)

        return

    def lnuv_sfr(self):
        """
        Plot dust-corrected L(NUV) versus SFR for the sample.


        Notes
        -----
        - Uses the Lnuv and SFR
        - Only objects within the redshift and magnitude selection ranges are included.
        - The figure can be divided into subpanels based on magnitude or redshift bins.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.lnuv_sfr()
        # Produces plots of L(NUV) versus SFR
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$log10(SFR)$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$L(NUV)$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_z) - 1):
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]

            ax.axis([-4, 3, 6, 11.9])

            # new condition with the redshift range and star-forming
            conda = self.cond & (self.zp > zmin) & (self.zp < zmax) & (self.sfrb > -90)
            ax.scatter(self.sfrb[conda], self.lnuv[conda], s=1, color="b", alpha=0.2, marker="s")

            ax.annotate(f"${zmin:.2f} < z < {zmax:.2f}$", xy=(-3, 10), color="black", fontsize=15)

        return

    def masstolight_z(self):
        """
        Plot mass-to-light ratio versus redshift for the sample.

        This diagnostic shows the relation between mass-to-light ratio
        and redshift, providing insight into SED fitting

        Notes
        -----
        - Uses the L(K) and massb
        - Only objects within the redshift and magnitude selection ranges are included.
        - The figure can be divided into subpanels based on magnitude or redshift bins.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.masstolight_z()
        # Produces plots of mass-to-light versus redshift
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$redshift$", ha="center", fontsize=15)
        f.text(0.04, 0.5, r"$log10(M/L \, ratio)$", va="center", rotation="vertical", fontsize=15)

        # Define the mass-to-light ratio
        mlratio = (
            self.massm
            - self.lk
            + (0.4 * (51.605 - 5.14))
            + log10(3.0e18 * 2000.0 / pow(22000.0, 2.0))
            - log10(3.826e33)
        )

        for rm in range(len(self.range_mag) - 1):
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            magmin = self.range_mag[rm]
            magmax = self.range_mag[rm + 1]

            ax.axis([self.z_min, self.z_max, -1.4, 0.2])

            conda = self.cond & self.condgal & (self.mag > magmin) & (self.mag < magmax)
            ax.scatter(self.zp[conda], mlratio[conda], s=1, color="b", alpha=0.2, marker="s")

            ax.annotate(f"${magmin:.2f} < mag < {magmax:.2f}$", xy=(0.2, 0), color="black", fontsize=15)

            # Trace the limits
            x = np.array([0, 6])
            ax.plot(x, -0.27 * x - 0.05 - 0.24, "r-")
            ax.plot(x, -0.18 * x - 0.05 - 0.24, "r-")

        return

    def absmagk_mass(self):
        """
        Plot M(Ks) versus mass for the sample.


        Notes
        -----
        - Uses the M(Ks) and mass
        - Only objects within the redshift and magnitude selection ranges are included.
        - The figure can be divided into subpanels based on magnitude or redshift bins.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.absmagk_mass()
        # Produces plots of M(K) versus mass
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$log10(mass)$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$M(K)$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_z) - 1):
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]

            ax.axis([6, 11.9, -24.9, -13])

            # new condition with the magnitude range and star-forming
            conda = self.cond & (self.zp > zmin) & (self.zp < zmax) & (self.ssfrm > -11) & (self.ssfrm < 0)
            if len(self.massb[conda]) > 0:
                ax.scatter(self.massb[conda], self.mabsk[conda], s=1, color="b", alpha=0.2, marker="s")

            # new condition with the magnitude range and quiescent
            conda = self.cond & (self.zp > zmin) & (self.zp < zmax) & (self.ssfrm < -11)
            if len(self.massb[conda]) > 0:
                ax.scatter(self.massb[conda], self.mabsk[conda], s=1, color="r", alpha=0.2, marker="s")

            ax.annotate(f"${zmin:.2f} < z < {zmax:.2f}$", xy=(6.5, -23), color="black", fontsize=15)

        return

    def absmagu_sfr(self):
        """
        Plot M(U) versus SFR for the sample.


        Notes
        -----
        - Uses the M(U) and SFR
        - Only objects within the redshift and magnitude selection ranges are included.
        - The figure can be divided into subpanels based on magnitude or redshift bins.

        Examples
        --------
        >>> utils = lp.PlotUtils(t, sel_filt=3)
        >>> utils.absmagu_sfr()
        # Produces plots of M(U) versus SFR
        """
        plt.clf()
        f, axarr = plt.subplots(
            self.nbRowM, self.nbColM, sharex=True, sharey=True, figsize=(12, 8), squeeze=False
        )
        plt.subplots_adjust(hspace=0, wspace=0)

        f.text(0.5, 0.04, "$log10(SFR)$", ha="center", fontsize=15)
        f.text(0.04, 0.5, "$M(U)$", va="center", rotation="vertical", fontsize=15)

        for rm in range(len(self.range_z) - 1):
            ax = axarr[int(rm // 2.0), int(rm % 2)]
            zmin = self.range_z[rm]
            zmax = self.range_z[rm + 1]

            ax.axis([-4, 4, -24.9, -13])

            # new condition with the redshift range and star-forming
            conda = self.cond & (self.zp > zmin) & (self.zp < zmax) & (self.sfrb > -90)
            condb = conda & (self.ebv <= 0.1)
            ax.scatter(self.sfrb[condb], self.mabsu[condb], s=1, color="b", alpha=0.2, marker="s")
            condb = conda & (self.ebv > 0.1) & (self.ebv < 0.3)
            ax.scatter(self.sfrb[condb], self.mabsu[condb], s=1, color="g", alpha=0.2, marker="s")
            condb = conda & (self.ebv >= 0.3)
            ax.scatter(self.sfrb[condb], self.mabsu[condb], s=1, color="r", alpha=0.2, marker="s")

            ax.annotate(f"${zmin:.2f} < z < {zmax:.2f}$", xy=(-3, -23), color="black", fontsize=15)

        return


def integrate_pdfs_to_ztrue(pdfs, zgrid, ztrue):
    """
    Integrate each PDF up to its corresponding true z value.

    Parameters
    ----------
    pdfs : np.ndarray, shape (m, n)
        PDFs for m objects over n bins.
    zgrid : np.ndarray, shape (n,)
        Grid for the PDF bins.
    ztrue : np.ndarray, shape (m,)
        Value up to which to integrate each PDF.

    Returns
    -------
    integrated : np.ndarray, shape (m,)
        Integrated value of each PDF up to the corresponding ztrue.
    """
    m, n = pdfs.shape
    integrated = np.zeros(m)

    for i in range(m):
        # Interpolate PDF for this object
        f = interp1d(zgrid, pdfs[i], kind="linear", bounds_error=False, fill_value=0.0)

        # Determine the integration points: all zgrid points <= ztrue[i]
        mask = zgrid <= ztrue[i]
        if np.any(mask):
            zvals = zgrid[mask]
            pdfvals = f(zvals)
            integrated[i] = trapezoid(pdfvals, zvals)
        else:
            integrated[i] = 0.0  # if ztrue[i] < zgrid[0]

    return integrated
