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.
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.
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
statusisConfirmed; - 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.
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
| name | host | M_V | g_i_used | color_source |
|---|---|---|---|---|
| str13 | str10 | float64 | float64 | str18 |
| Antlia B | NGC3109 | -9.7 | 0.7322349999999991 | converted from g-r |
| ESO294-010 | NGC0055 | -11.3 | 0.6510000000000016 | catalog g-i |
| DDO044 | NGC2403 | -12.9 | 0.8000000000000007 | catalog g-i |
| MADCASH-2 | NGC4214 | -9.150000000000002 | 0.6258999999999996 | converted from g-r |
| DDO113 | NGC4214 | -12.2 | 0.7314700000000008 | converted from g-r |
| NGC4190 | NGC4214 | -14.5 | 0.5800000000000005 | converted from g-r |
| NGC4163 | NGC4214 | -14.3 | 0.6259000000000022 | converted from g-r |
| SMDG0740+4032 | DDO046 | -11.966986767562194 | 0.74371 | converted from g-r |
| dw0741p4005 | DDO046 | -11.721986767562193 | 0.22045 | converted from g-r |
| KK65 | DDO047 | -14.724110282662078 | 0.5019699999999999 | converted 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.
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)
| name | host | M_V | g_i_used | quenched |
|---|---|---|---|---|
| str13 | str10 | float64 | float64 | bool |
| Antlia B | NGC3109 | -9.7 | 0.7322349999999991 | True |
| ESO294-010 | NGC0055 | -11.3 | 0.6510000000000016 | True |
| DDO044 | NGC2403 | -12.9 | 0.8000000000000007 | True |
| MADCASH-2 | NGC4214 | -9.150000000000002 | 0.6258999999999996 | True |
| DDO113 | NGC4214 | -12.2 | 0.7314700000000008 | True |
| NGC4190 | NGC4214 | -14.5 | 0.5800000000000005 | False |
| NGC4163 | NGC4214 | -14.3 | 0.6259000000000022 | False |
| SMDG0740+4032 | DDO046 | -11.966986767562194 | 0.74371 | True |
| dw0741p4005 | DDO046 | -11.721986767562193 | 0.22045 | False |
| ... | ... | ... | ... | ... |
| dw0605m3325 | NGC2188 | -13.356809804143502 | 0.244 | False |
| AGC731457 | NGC3274 | -13.943000000000001 | 0.285 | False |
| dw1030p2746 | NGC3274 | -11.925 | 0.769 | True |
| dw1245p6158 | NGC4605 | -10.955464915613383 | 0.89518 | True |
| LEDA104868 | NGC4700 | -15.156413360750841 | 0.362 | False |
| dw1250m1045 | NGC4700 | -14.204413360750841 | 0.479 | False |
| UGC07950 | NGC4707 | -15.731237978659603 | 0.37192000000000003 | False |
| dw1250p5056 | NGC4707 | -10.297237978659602 | 0.24645999999999998 | False |
| dw1008p7038b | UGC05423 | -12.080618099158634 | 0.4729 | False |
| dw1009p7032 | UGC05423 | -11.983618099158633 | 0.44994999999999996 | False |
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()
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.
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)
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
| logM_left | logM_right | mean_logM | N | N_quenched | f_quenched | f_lower | f_upper |
|---|---|---|---|---|---|---|---|
| float64 | float64 | float64 | int64 | int64 | float64 | float64 | float64 |
| 5.0 | 6.0 | 5.733 | 4 | 3 | 0.750 | 0.500 | 0.894 |
| 6.0 | 6.6 | 6.413 | 10 | 4 | 0.400 | 0.262 | 0.556 |
| 6.6 | 7.5 | 7.033 | 12 | 4 | 0.333 | 0.216 | 0.477 |
| 7.5 | 9.1 | 7.706 | 5 | 0 | 0.000 | 0.000 | 0.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.
# 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)])
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()
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_LIMITand 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.