1. Load the catalogΒΆ

The code first looks for the catalog in a local checkout of the website. If it is not present, Astropy reads the same file from the public data URL. This makes the notebook work both inside the repository and as a standalone download.

InΒ [1]:
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from astropy.stats import binom_conf_interval
from astropy.table import Table

CATALOG_NAME = "ELVES-Dwarf_master_cat_v1.fits"
CATALOG_URL = "https://elves-surveys.github.io/data/elves-dwarf/" + CATALOG_NAME
LOCAL_CANDIDATES = (Path("../../data/elves-dwarf") / CATALOG_NAME, Path("public/data/elves-dwarf") / CATALOG_NAME)
catalog_source = next((path for path in LOCAL_CANDIDATES if path.exists()), CATALOG_URL)

catalog = Table.read(catalog_source)
for column in catalog.colnames:
    if catalog[column].dtype.kind == "S":
        catalog[column] = catalog[column].astype(str)

print(f"Loaded {len(catalog)} rows from {catalog_source}")
print("Membership classes:")
for status in np.unique(catalog["status"]):
    print(f"  {status}: {np.count_nonzero(catalog['status'] == status)}")
Loaded 207 rows from ../../data/elves-dwarf/ELVES-Dwarf_master_cat_v1.fits
Membership classes:
  Confirmed: 39
  Not Observed: 17
  Rejected: 131
  Unconfirmed: 20

2. Set the plotting styleΒΆ

The figures below follow the same look as the original analysis notebook (serif fonts, inward-facing major/minor ticks on all four sides, a frameless legend). We set this with plt.rcParams.update instead of loading the project's .mplstyle file directly, so the tutorial has no dependency on it and does not require a local LaTeX installation.

InΒ [2]:
plt.rcParams.update({
    "font.family": "serif", "font.size": 12, "mathtext.fontset": "dejavuserif",
    "axes.linewidth": 1.3, "axes.titlesize": "large", "axes.titlepad": 10.0,
    "axes.labelpad": 4.0, "axes.formatter.use_mathtext": True,
    "xtick.top": True, "xtick.bottom": True, "xtick.direction": "in", "xtick.minor.visible": True,
    "xtick.major.size": 6.0, "xtick.minor.size": 3.5, "xtick.major.width": 1.5, "xtick.minor.width": 0.8, "xtick.major.pad": 7.0,
    "ytick.left": True, "ytick.right": True, "ytick.direction": "in", "ytick.minor.visible": True,
    "ytick.major.size": 6.0, "ytick.minor.size": 3.5, "ytick.major.width": 1.5, "ytick.minor.width": 0.8, "ytick.major.pad": 7.0,
    "grid.linestyle": "--", "grid.alpha": 0.6,
    "legend.frameon": False, "legend.borderpad": 0.5, "legend.labelspacing": 0.3,
    "legend.handletextpad": 0.8, "legend.borderaxespad": 0.8, "errorbar.capsize": 3,
    "figure.figsize": (7.2, 4.5), "figure.dpi": 100, "savefig.dpi": 200, "savefig.bbox": "tight",
})

3. Define the analysis sampleΒΆ

For this tutorial we use:

  • objects whose catalog status is Confirmed;
  • an absolute-magnitude limit of $M_V < -9$; and
  • objects with a usable $g-i$ color, either measured directly or converted from a valid $g-r$ color.

We calculate absolute magnitude from the apparent Sersic magnitude and the host distance:

$$M_V = m_V - 5\log_{10}(D_{\rm host}/{\rm Mpc}) - 25.$$

When gi_sersic is missing but gr_sersic is valid, we use the relation from the original notebook, $(g-i)=1.53(g-r)-0.032$. Catalog placeholders such as 999 are treated as missing, not as physical colors.

InΒ [3]:
M_V_LIMIT = -9.0

confirmed = catalog[np.asarray(catalog["status"]).astype(str) == "Confirmed"].copy()
confirmed["M_V"] = confirmed["m_V_sersic"] - (5 * np.log10(confirmed["host_dist"]) + 25)
bright = confirmed[confirmed["M_V"] < M_V_LIMIT].copy()

gi = np.asarray(np.ma.filled(bright["gi_sersic"], np.nan), dtype=float)
gr = np.asarray(np.ma.filled(bright["gr_sersic"], np.nan), dtype=float)
valid_gi, valid_gr = np.isfinite(gi) & (np.abs(gi) < 5), np.isfinite(gr) & (np.abs(gr) < 5)
converted = ~valid_gi & valid_gr
gi[converted] = 1.53 * gr[converted] - 0.032
valid_color = np.isfinite(gi) & (np.abs(gi) < 5)

sample = bright[valid_color].copy()
sample["g_i_used"] = gi[valid_color]
sample["color_source"] = np.where(valid_gi[valid_color], "catalog g-i", "converted from g-r")

print(f"Confirmed satellites: {len(confirmed)}")
print(f"After M_V < {M_V_LIMIT:g}: {len(bright)}")
print(f"With a usable color: {len(sample)}")
print(f"Excluded for missing color: {len(bright) - len(sample)}")
sample[["name", "host", "M_V", "g_i_used", "color_source"]][:10]
Confirmed satellites: 39
After M_V < -9: 34
With a usable color: 31
Excluded for missing color: 3
Out[3]:
Table length=10
namehostM_Vg_i_usedcolor_source
str13str10float64float64str18
Antlia BNGC3109-9.70.7322349999999991converted from g-r
ESO294-010NGC0055-11.30.6510000000000016catalog g-i
DDO044NGC2403-12.90.8000000000000007catalog g-i
MADCASH-2NGC4214-9.1500000000000020.6258999999999996converted from g-r
DDO113NGC4214-12.20.7314700000000008converted from g-r
NGC4190NGC4214-14.50.5800000000000005converted from g-r
NGC4163NGC4214-14.30.6259000000000022converted from g-r
SMDG0740+4032DDO046-11.9669867675621940.74371converted from g-r
dw0741p4005DDO046-11.7219867675621930.22045converted from g-r
KK65DDO047-14.7241102826620780.5019699999999999converted from g-r

4. Classify quenched satellitesΒΆ

Following the color--magnitude definition used in the original notebook, a satellite is classified as quenched when

$$(g-i) > -0.067M_V - 0.23.$$

This is a photometric classification. It is not a direct measurement of star-formation rate, and objects without a usable color are excluded from the denominator.

InΒ [4]:
sample["quenching_boundary"] = -0.067 * sample["M_V"] - 0.23
sample["quenched"] = sample["g_i_used"] > sample["quenching_boundary"]

n_total = len(sample)
n_quenched = int(np.count_nonzero(sample["quenched"]))
fraction = n_quenched / n_total
interval = binom_conf_interval(n_quenched, n_total, confidence_level=0.68, interval="jeffreys")

print(f"Quenched: {n_quenched}/{n_total}")
print(f"Overall quenched fraction: {fraction:.3f} (68% Jeffreys interval: {interval[0]:.3f}--{interval[1]:.3f})")
sample[["name", "host", "M_V", "g_i_used", "quenched"]]
Quenched: 11/31
Overall quenched fraction: 0.355 (68% Jeffreys interval: 0.275--0.444)
Out[4]:
Table length=31
namehostM_Vg_i_usedquenched
str13str10float64float64bool
Antlia BNGC3109-9.70.7322349999999991True
ESO294-010NGC0055-11.30.6510000000000016True
DDO044NGC2403-12.90.8000000000000007True
MADCASH-2NGC4214-9.1500000000000020.6258999999999996True
DDO113NGC4214-12.20.7314700000000008True
NGC4190NGC4214-14.50.5800000000000005False
NGC4163NGC4214-14.30.6259000000000022False
SMDG0740+4032DDO046-11.9669867675621940.74371True
dw0741p4005DDO046-11.7219867675621930.22045False
...............
dw0605m3325NGC2188-13.3568098041435020.244False
AGC731457NGC3274-13.9430000000000010.285False
dw1030p2746NGC3274-11.9250.769True
dw1245p6158NGC4605-10.9554649156133830.89518True
LEDA104868NGC4700-15.1564133607508410.362False
dw1250m1045NGC4700-14.2044133607508410.479False
UGC07950NGC4707-15.7312379786596030.37192000000000003False
dw1250p5056NGC4707-10.2972379786596020.24645999999999998False
dw1008p7038bUGC05423-12.0806180991586340.4729False
dw1009p7032UGC05423-11.9836180991586330.44994999999999996False
InΒ [5]:
is_quenched = np.asarray(sample["quenched"], dtype=bool)
magnitude_grid = np.linspace(-17.5, -8.5, 200)

fig, ax = plt.subplots(figsize=(6.7, 4.6))
ax.scatter(sample["M_V"][~is_quenched], sample["g_i_used"][~is_quenched], label="Star-forming side of cut",
           color="dodgerblue", edgecolor="k", linewidth=0.8, s=70, zorder=5)
ax.scatter(sample["M_V"][is_quenched], sample["g_i_used"][is_quenched], label="Quenched side of cut",
           color="firebrick", edgecolor="k", linewidth=0.8, s=70, zorder=5)
ax.plot(magnitude_grid, -0.067 * magnitude_grid - 0.23, color="0.25", linestyle="--", lw=2,
        label="Quenching boundary", zorder=2)

ax.set(xlabel=r"$M_V$ [mag]", ylabel=r"$(g-i)$ used for classification")
ax.invert_xaxis()
ax.legend(loc="upper center", bbox_to_anchor=(0.5, 1.18), ncol=3, fontsize=11)
plt.show()
Color-magnitude diagram of the ELVES-Dwarf satellite sample with the photometric quenching boundary.

5. Measure the quenched fraction versus stellar massΒΆ

For each stellar-mass bin, the estimator is simply $f_q=k/n$, where $k$ is the number classified as quenched and $n$ is the number with a usable color. We use a 68% Jeffreys binomial interval because the bins contain small samples and may have $k=0$ or $k=n$.

The bin edges below follow the compact calculation in the original notebook. Try changing them to see how small-number statistics affect the result.

InΒ [6]:
def binned_quenched_fraction(log_mass, quenched, bin_edges):
    """Return f_q and 68% Jeffreys intervals in stellar-mass bins."""
    rows = []
    for left, right in zip(bin_edges[:-1], bin_edges[1:]):
        in_bin = (log_mass >= left) & (log_mass < right)
        n = int(np.count_nonzero(in_bin))
        if n == 0:
            continue
        k = int(np.count_nonzero(quenched & in_bin))
        lower, upper = binom_conf_interval(k, n, confidence_level=0.68, interval="jeffreys")
        rows.append((left, right, np.mean(log_mass[in_bin]), n, k, k / n, lower, upper))

    names = ("logM_left", "logM_right", "mean_logM", "N", "N_quenched", "f_quenched", "f_lower", "f_upper")
    return Table(rows=rows, names=names)
InΒ [7]:
mass_bins = np.array([5.0, 6.0, 6.6, 7.5, 9.1])
binned = binned_quenched_fraction(
    np.asarray(sample["log_m_star"], dtype=float),
    np.asarray(sample["quenched"], dtype=bool),
    mass_bins,
)
for column in ("mean_logM", "f_quenched", "f_lower", "f_upper"):
    binned[column].info.format = ".3f"
binned
Out[7]:
Table length=4
logM_leftlogM_rightmean_logMNN_quenchedf_quenchedf_lowerf_upper
float64float64float64int64int64float64float64float64
5.06.05.733430.7500.5000.894
6.06.66.4131040.4000.2620.556
6.67.57.0331240.3330.2160.477
7.59.17.706500.0000.0000.171

6. Compare with reference quenched-fraction relationsΒΆ

To put the ELVES-Dwarf measurement in context, we overplot three published/derived reference relations that were digitized from the original analysis notebook's figure, each with a 68% confidence band:

  • MW + M31: the quenched fraction among Milky Way and M31 satellites.
  • ELVES: the average quenched fraction of confirmed satellites in the ELVES survey (Carlsten et al. 2022).
  • ELVES-Field: the quenched fraction of isolated, ELVES-selected field dwarfs, i.e. the non-satellite comparison sample from the same survey.

These three curves are fixed arrays, not recomputed from raw data here β€” they only serve as a visual comparison for the ELVES-Dwarf result measured above.

InΒ [8]:
# Each array holds (log M_star/Msun, f_quenched) rows; "_lower"/"_upper" share the
# same x-values with the 68% confidence band edges. Digitized from the original
# analysis notebook's figure and rounded to 4 decimals (well below digitization error).

MW_M31_q = np.array([(5.4949, 0.9487), (6.4964, 1.0000), (7.4920, 0.8326), (8.4993, 0.6667), (9.5007, 0.0000)])
MW_M31_q_lower = np.array([(5.4949, 0.8507), (6.4964, 0.8326), (7.4978, 0.6003), (8.4993, 0.3816), (9.5007, 0.0000)])
MW_M31_q_upper = np.array([(5.5007, 0.9834), (6.4964, 1.0000), (7.4978, 0.8959), (8.4934, 0.8130), (9.5007, 0.4570)])

# ELVES average quenched fraction (Carlsten et al. 2022, confirmed satellites)
elves_confirmed_q = np.array([(5.7423, 0.8506), (6.2526, 0.8651), (6.7423, 0.7523), (7.2474, 0.6612),
                               (7.7474, 0.7335), (8.2474, 0.3923), (8.7474, 0.3634), (9.2474, 0.1263)])
elves_confirmed_q_lower = np.array([(5.7526, 0.7928), (6.2526, 0.8145), (6.7423, 0.6959), (7.2474, 0.5961),
                                     (7.7474, 0.6684), (8.2474, 0.2983), (8.7474, 0.2376), (9.2320, 0.0583)])
elves_confirmed_q_upper = np.array([(5.7526, 0.8954), (6.2423, 0.9027), (6.7577, 0.7957), (7.2474, 0.7205),
                                     (7.7474, 0.7913), (8.2474, 0.4949), (8.7474, 0.5137), (9.2474, 0.2839)])

# ELVES-Field, isolated field dwarfs (Carlsten et al. 2022)
elves_field_q = np.array([(6.5, 0.3309), (7.5, 0.1577), (8.5, 0.0000)])
elves_field_q_lower = np.array([(6.5, 0.2263), (7.5, 0.0848), (8.5, 0.0000)])
elves_field_q_upper = np.array([(6.5, 0.4543), (7.5, 0.2736), (8.5, 0.1172)])
InΒ [9]:
from matplotlib.lines import Line2D

x = np.asarray(binned["mean_logM"], dtype=float)
y = np.asarray(binned["f_quenched"], dtype=float)
lower = np.asarray(binned["f_lower"], dtype=float)
upper = np.asarray(binned["f_upper"], dtype=float)
field_color = "#3A9D5D"

fig, ax = plt.subplots(figsize=(6.9, 5.4))

# ELVES (Carlsten+22 average, confirmed satellites)
ax.fill_between(elves_confirmed_q[:, 0], elves_confirmed_q_upper[:, 1], elves_confirmed_q_lower[:, 1],
                facecolor="firebrick", edgecolor="firebrick", alpha=0.25, lw=1.5, zorder=1)
ax.errorbar(elves_confirmed_q[:, 0], elves_confirmed_q[:, 1], fmt="s", mfc="firebrick", color="firebrick",
            markersize=6, lw=1.5, zorder=2,
            yerr=[elves_confirmed_q[:, 1] - elves_confirmed_q_lower[:, 1],
                  elves_confirmed_q_upper[:, 1] - elves_confirmed_q[:, 1]])

# MW + M31
ax.fill_between(MW_M31_q[:, 0], MW_M31_q_upper[:, 1], MW_M31_q_lower[:, 1], color="0.55", alpha=0.14, lw=0, zorder=0)
ax.errorbar(MW_M31_q[:, 0], MW_M31_q[:, 1], fmt="s", mfc="0.55", color="0.55", markersize=6, elinewidth=1.3, zorder=1,
            yerr=[MW_M31_q[:, 1] - MW_M31_q_lower[:, 1], MW_M31_q_upper[:, 1] - MW_M31_q[:, 1]])

# ELVES-Field (isolated field dwarfs)
ax.fill_between(elves_field_q[:, 0], elves_field_q_upper[:, 1], elves_field_q_lower[:, 1],
                color=field_color, alpha=0.14, lw=0, zorder=0)
ax.errorbar(elves_field_q[:, 0], elves_field_q[:, 1], fmt="D", mfc=field_color, mec="#357048", color=field_color,
            markersize=6, elinewidth=1.3, zorder=1,
            yerr=[elves_field_q[:, 1] - elves_field_q_lower[:, 1], elves_field_q_upper[:, 1] - elves_field_q[:, 1]])

# ELVES-Dwarf, computed above
ax.errorbar(x, y, yerr=np.vstack((y - lower, upper - y)), fmt="D", color="dodgerblue", mec="k", mew=1.2,
            markersize=10, lw=2, capsize=0, zorder=10)
ax.fill_between(x, lower, upper, color="dodgerblue", alpha=0.25, lw=0, zorder=9)

handles = [
    Line2D([], [], marker="D", ls="", mfc="dodgerblue", mec="k", ms=9, label="ELVES-Dwarf"),
    Line2D([], [], marker="D", ls="", mfc=field_color, mec="#357048", ms=9, label="ELVES-Field"),
    Line2D([], [], marker="s", ls="", color="firebrick", ms=8, label="ELVES"),
    Line2D([], [], marker="s", ls="", color="0.55", ms=9, label="MW+M31"),
]
ax.legend(handles=handles, loc="upper center", bbox_to_anchor=(0.47, 1.22), ncols=2,
          fontsize=12, handletextpad=0.5, labelspacing=0.3, columnspacing=1.8)

ax.set(xlabel=r"$\log_{10}(M_\star/M_\odot)$", ylabel=r"Quenched fraction $f_q$", xlim=(5.4, 9.6), ylim=(-0.03, 1.03))
plt.show()
ELVES-Dwarf quenched fraction versus stellar mass with 68 percent Jeffreys binomial intervals.

Interpretation and next stepsΒΆ

This tutorial reports the fraction for the explicitly selected color-valid sample. It does not perform completeness weighting, statistical background subtraction, or a sensitivity analysis for alternative quenching definitions. Those choices should be revisited for a publication-level measurement. The MW+M31, ELVES, and ELVES-Field curves are fixed reference arrays for visual comparison, not independently reproduced here.

Useful experiments:

  • change M_V_LIMIT and the stellar-mass bins;
  • compare direct $g-i$ measurements with colors converted from $g-r$;
  • inspect how the result changes if objects without a usable color are assigned a classification from independent star-formation indicators;
  • split the sample by host stellar mass or isolation; and
  • join the satellite and host catalogs for additional host properties.

When publishing results from these data, cite the release paper listed in the ELVES-Dwarf data documentation.