astrophotography/session-scripts/gc-plots.py
laurence 5286a2e81b Processing and analysis code for remote-telescope imaging sessions
The scripts that processed the NGC 5128 session of 2026-07-21 previously
lived inside the data directory and addressed it with absolute paths.
Code and data are now separated: the code lives here, and a session is
located at runtime through the ASTRO_SESSION environment variable.

layout.py is what makes that work. It maps a FILENAME to the
subdirectory that file belongs in, using the same rules the session
directories are organised with, so a script can go on asking for
'master-Red.fit' or '_stars.npz' without any call site knowing the
directory structure. Anything unrecognised resolves to the session root,
which is visible and correctable rather than silently wrong.

restructure.py reorganises a flat session directory into that layout. It
is idempotent and dry-run by default.

The 50 session scripts are kept as they were run rather than tidied into
a library. They were written in sequence as the work went along, several
of them by parallel agents, and they show it - but they are the honest
provenance of a published set of results, and the productionised pipeline
should be able to reproduce those results exactly.

Verified before committing: all 51 files compile without warnings, and
verify_core.py, closeup.py and triptych.py were run end to end against
the reorganised session, correctly finding inputs across calibrated/,
stacks/masters/ and final/ and writing outputs back to the right places.
2026-07-21 15:29:49 +01:00

491 lines
23 KiB
Python

"""
gc-plots.py -- step 4. All figures for the NGC 5128 globular cluster survey.
NGC5128-gc-background-check.png how the galaxy light was removed
NGC5128-gc-finder.png annotated finder chart with zoom insets
NGC5128-gc-cmd.png colour-magnitude diagram
NGC5128-gc-completeness.png artificial-star recovery
NGC5128-gc-radial-profile.png THE key plot: surface density vs radius
"""
import os, numpy as np, warnings
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Rectangle
from matplotlib.lines import Line2D
from astropy.io import fits
from astropy.wcs import WCS
import layout
warnings.filterwarnings("ignore")
S = layout.SESSION
PIXSCALE = 0.5376
NUC_RA, NUC_DEC = 201.365063, -43.019113
KPC_PER_ARCMIN = 3.8 * 1000.0 * (np.pi / 180.0) / 60.0
# validated categorical palette (see notes)
C_CAND, C_EXT, C_KNOWN, C_ACC = "#2563eb", "#e8710a", "#127a5a", "#8b5cf6"
C_INK, C_MUTED, C_GRID = "#1a1a1a", "#5c5c5c", "#d8d8d4"
SURF = "#fcfcfb"
plt.rcParams.update({
"figure.facecolor": SURF, "axes.facecolor": SURF,
"axes.edgecolor": C_MUTED, "axes.labelcolor": C_INK,
"text.color": C_INK, "xtick.color": C_MUTED, "ytick.color": C_MUTED,
"axes.grid": True, "grid.color": C_GRID, "grid.linewidth": 0.6,
"axes.axisbelow": True, "font.size": 10,
"axes.spines.top": False, "axes.spines.right": False,
"legend.frameon": False,
})
def asinh_stretch(a, lo=1.0, hi=99.7, soft=8.0):
v0, v1 = np.percentile(a[np.isfinite(a)], [lo, hi])
x = np.clip((a - v0) / max(v1 - v0, 1e-9), 0, 1)
return np.arcsinh(soft * x) / np.arcsinh(soft)
def load_cat():
return dict(np.load(layout.path("_gc_cat.npz"), allow_pickle=True))
# --------------------------------------------------------------------------
def fig_background(d):
import sep
img = np.ascontiguousarray(
fits.getdata(layout.path("master-Luminance.fit")).astype(np.float32))
b16 = sep.Background(img, bw=16, bh=16, fw=3, fh=3)
b256 = sep.Background(img, bw=256, bh=256, fw=3, fh=3)
raw = img[::4, ::4]
back = b16.back()[::4, ::4]
sub16 = raw - back
sub256 = raw - b256.back()[::4, ::4]
del img, b16, b256
# ONE stretch for the two image-scale panels and ONE for the two residuals,
# so the panels are actually comparable to each other.
v0, v1 = np.percentile(raw, [1.0, 99.7])
r0, r1 = np.percentile(sub16, [1.0, 99.85])
def show(a, im, lo, hi, t, sub=""):
x = np.clip((im - lo) / (hi - lo), 0, 1)
a.imshow(np.arcsinh(8 * x) / np.arcsinh(8.0), origin="lower", cmap="gray",
interpolation="nearest", vmin=0, vmax=1)
a.set_title(t, fontsize=10.5, loc="left", pad=4)
if sub:
a.text(0.5, -0.045, sub, transform=a.transAxes, ha="center", va="top",
fontsize=9, color=C_MUTED)
a.set_xticks([]); a.set_yticks([]); a.grid(False)
fig, ax = plt.subplots(2, 2, figsize=(12.4, 8.0))
show(ax[0, 0], raw, v0, v1, "a) luminance master")
show(ax[0, 1], back, v0, v1, "b) background model, 16 px mesh (adopted)",
"same stretch as (a): the model reproduces the galaxy and the dust lane")
show(ax[1, 0], sub16, r0, r1, "c) residual after (b) - what detection runs on",
"galaxy gone; point sources remain at every radius")
show(ax[1, 1], sub256, r0, r1, "d) residual after a 256 px mesh - the failure mode",
"the galaxy survives, swamping the inner field and hiding its clusters")
fig.suptitle("Removing NGC 5128's own light before detection", fontsize=13,
x=0.008, ha="left")
fig.tight_layout(rect=[0, 0.02, 1, 0.97])
fig.savefig(layout.path("NGC5128-gc-background-check.png"), dpi=110,
bbox_inches="tight", facecolor=SURF)
plt.close(fig)
print("wrote NGC5128-gc-background-check.png")
# --------------------------------------------------------------------------
def fig_finder(d):
hdr = fits.getheader(layout.path("master-Luminance.fit"))
w = WCS(hdr)
img = fits.getdata(layout.path("master-Luminance.fit")).astype(np.float32)
B = 4
small = img[::B, ::B]
disp = asinh_stretch(small, 20, 99.5, 12)
cx, cy = [float(v) for v in w.all_world2pix(NUC_RA, NUC_DEC, 0)]
cand = d["cand"]
known = cand & ((d["simbad_type"] == "GlC") | d["scabs_hit"])
newc = cand & ~known
ny_s, nx_s = small.shape
# Main panel keeps the detector's 1.5:1 aspect; insets sit in a row beneath.
fig = plt.figure(figsize=(14.0, 13.0))
gs = fig.add_gridspec(2, 4, height_ratios=[nx_s / ny_s * 0.98, 1.0],
hspace=0.09, wspace=0.06,
left=0.012, right=0.988, top=0.945, bottom=0.015)
ax = fig.add_subplot(gs[0, :])
ax.imshow(disp, origin="lower", cmap="gray", interpolation="bilinear",
aspect="equal")
ax.set_xlim(0, nx_s); ax.set_ylim(0, ny_s)
ax.grid(False); ax.set_xticks([]); ax.set_yticks([])
for m, col, lab in ((known, C_KNOWN, "matches a published catalogue (%d)" % known.sum()),
(newc, C_CAND, "no published counterpart (%d)" % newc.sum())):
ax.scatter(d["x"][m] / B, d["y"][m] / B, s=110, facecolors="none",
edgecolors=col, linewidths=1.2, label=lab)
# nucleus
import matplotlib.patheffects as pe
halo = [pe.withStroke(linewidth=3.0, foreground="black")]
ax.plot(cx / B, cy / B, marker="+", ms=20, mew=2.2, color=C_ACC, zorder=6)
ax.annotate("NGC 5128 nucleus", (cx / B, cy / B), xytext=(-95, -78),
textcoords="offset points", color=C_ACC, fontsize=11,
weight="bold", ha="center", zorder=7, path_effects=halo,
arrowprops=dict(arrowstyle="-", color=C_ACC, lw=1.3,
shrinkA=2, shrinkB=10))
# scale bar: 5 arcmin, bottom left, inside the frame
L = 5 * 60 / PIXSCALE / B
x0, y0 = nx_s * 0.035, ny_s * 0.062
ax.plot([x0, x0 + L], [y0, y0], color="white", lw=3.5, solid_capstyle="butt")
ax.text(x0 + L / 2, y0 + ny_s * 0.018,
"5' = %.1f kpc at 3.8 Mpc" % (5 * KPC_PER_ARCMIN),
ha="center", color="white", fontsize=10)
# compass, derived from the WCS itself rather than from the quoted PA:
# step 1 arcmin north and 1 arcmin east of the nucleus and see where it lands
cxo, cyo = nx_s * 0.915, ny_s * 0.16
for dra, ddec, lab in ((0.0, 1 / 60.0, "N"), (1 / 60.0, 0.0, "E")):
px, py = w.all_world2pix(NUC_RA + dra / np.cos(np.radians(NUC_DEC)),
NUC_DEC + ddec, 0)
vx, vy = float(px) - cx, float(py) - cy
n = np.hypot(vx, vy)
dx, dy = vx / n * 48, vy / n * 48
ax.annotate("", (cxo + dx, cyo + dy), (cxo, cyo),
arrowprops=dict(arrowstyle="->", color="white", lw=1.8))
ax.text(cxo + dx * 1.38, cyo + dy * 1.38, lab, color="white",
ha="center", va="center", fontsize=11, weight="bold")
ax.legend(loc="upper left", fontsize=10.5, labelcolor=C_INK,
handletextpad=0.4, borderpad=0.6, markerscale=1.1,
facecolor="white", framealpha=0.82, frameon=True, edgecolor="none")
ax.set_title("NGC 5128 globular cluster candidates: %d circled, G < 20 "
"(42.9' x 28.6' luminance master, asinh stretch, 4x downsampled)"
% cand.sum(), fontsize=12.5, loc="left", pad=9)
# ---- zoom insets, full resolution ----
zooms = [(2.5, 55), (5.5, 340), (9.0, 130), (16.0, 205)]
HW = 230 # half-width in full-res px -> 4.1 arcmin box
for k, (rr, pa) in enumerate(zooms):
az = fig.add_subplot(gs[1, k])
ang = np.radians(pa)
zx = cx + rr * 60 / PIXSCALE * np.cos(ang)
zy = cy + rr * 60 / PIXSCALE * np.sin(ang)
zx = float(np.clip(zx, HW, img.shape[1] - HW))
zy = float(np.clip(zy, HW, img.shape[0] - HW))
cut = img[int(zy - HW):int(zy + HW), int(zx - HW):int(zx + HW)]
az.imshow(asinh_stretch(cut, 15, 99.7, 14), origin="lower", cmap="gray",
interpolation="nearest", aspect="equal",
extent=[zx - HW, zx + HW, zy - HW, zy + HW])
for m, col in ((known, C_KNOWN), (newc, C_CAND)):
sel = m & (np.abs(d["x"] - zx) < HW) & (np.abs(d["y"] - zy) < HW)
az.scatter(d["x"][sel], d["y"][sel], s=230, facecolors="none",
edgecolors=col, linewidths=1.6)
az.set_xticks([]); az.set_yticks([]); az.grid(False)
az.set_title("%.1f' from the nucleus" % rr, fontsize=10.5, loc="left", pad=5)
ax.add_patch(Rectangle(((zx - HW) / B, (zy - HW) / B), 2 * HW / B, 2 * HW / B,
fill=False, ec="white", lw=1.1, ls="-", alpha=0.8))
ax.text((zx) / B, (zy + HW) / B + 6, "%.1f'" % rr, color="white",
fontsize=9, ha="center")
fig.text(0.012, 0.002,
"Lower row: full-resolution %.1f' cut-outs at the marked positions, same "
"circles. The innermost arcminute is empty of candidates: the nucleus and "
"dust lane are impenetrable at this depth." % (2 * HW * PIXSCALE / 60),
fontsize=9.5, color=C_MUTED)
fig.savefig(layout.path("NGC5128-gc-finder.png"), dpi=100, facecolor=SURF)
plt.close(fig)
del img
print("wrote NGC5128-gc-finder.png")
# --------------------------------------------------------------------------
def fig_cmd(d):
fig, ax = plt.subplots(1, 2, figsize=(12.6, 5.6))
stars = d["base"] & d["is_star"] & np.isfinite(d["BmR"]) & (d["mag"] > 15) & (d["mag"] < 21.5)
cand = d["cand"] & np.isfinite(d["BmR"])
known = cand & ((d["simbad_type"] == "GlC") | d["scabs_hit"])
a = ax[0]
a.hexbin(d["BmR"][stars], d["mag"][stars], gridsize=60, cmap="Greys",
mincnt=1, extent=(-1.0, 3.5, 15, 21.6), linewidths=0)
a.scatter(d["BmR"][cand & ~known], d["mag"][cand & ~known], s=20, c=C_CAND,
alpha=0.85, lw=0, label="candidate, no published counterpart")
a.scatter(d["BmR"][known], d["mag"][known], s=22, c=C_KNOWN, alpha=0.9,
lw=0, marker="D", label="candidate, published GC")
a.set_xlim(-1.0, 3.5); a.set_ylim(21.6, 15)
a.set_xlabel("B - R (instrumental, calibrated to Gaia BP - RP)")
a.set_ylabel("G (luminance)")
a.set_title("a) candidates against the foreground stellar field", fontsize=10.5, loc="left")
a.legend(fontsize=9, loc="lower left", labelcolor=C_INK)
a.text(0.98, 0.03, "grey = %d astrometric\nMilky Way stars" % stars.sum(),
transform=a.transAxes, ha="right", va="bottom", fontsize=9, color=C_MUTED)
b = ax[1]
bins = np.arange(-1.0, 3.51, 0.2)
b.hist(d["BmR"][stars], bins=bins, density=True, color=C_MUTED, alpha=0.35,
label="foreground stars (n=%d)" % stars.sum())
b.hist(d["BmR"][cand], bins=bins, density=True, histtype="step", lw=2.0,
color=C_CAND, label="all candidates (n=%d)" % cand.sum())
b.hist(d["BmR"][known], bins=bins, density=True, histtype="step", lw=2.0,
color=C_KNOWN, ls="--", label="published GCs (n=%d)" % known.sum())
b.set_xlabel("B - R"); b.set_ylabel("normalised density")
b.set_title("b) colour distributions", fontsize=10.5, loc="left")
b.legend(fontsize=9, labelcolor=C_INK)
med = np.nanmedian(d["BmR"][cand])
b.axvline(med, color=C_CAND, lw=1, ls=":")
b.text(med + 0.06, b.get_ylim()[1] * 0.93, "candidate median %.2f" % med,
fontsize=9, color=C_CAND)
fig.suptitle("Colour-magnitude diagram, NGC 5128 cluster candidates",
fontsize=12.5, x=0.008, ha="left")
fig.tight_layout()
fig.savefig(layout.path("NGC5128-gc-cmd.png"), dpi=115, bbox_inches="tight", facecolor=SURF)
plt.close(fig)
print("wrote NGC5128-gc-cmd.png")
# --------------------------------------------------------------------------
def completeness_grid():
"""Prefer the merged uniform + inner-field artificial-star runs.
The uniform run alone puts only ~2% of its fakes inside 3 arcmin, which left
the innermost completeness bins too noisy to correct with.
"""
for name in ("_gc_complete_all.npz", "_gc_complete.npz"):
p = layout.path(name)
if os.path.exists(p):
print("completeness from %s" % name)
return np.load(p)
return None
def fig_completeness(cp):
fig, ax = plt.subplots(1, 2, figsize=(12.6, 5.0))
mag, rad, ok = cp["mag"], cp["rad"], cp["ok"]
mb = np.arange(17.5, 22.51, 0.25)
mc = 0.5 * (mb[1:] + mb[:-1])
rbins = [(0, 2), (2, 4), (4, 8), (8, 30)]
cols = [C_ACC, C_EXT, C_KNOWN, C_CAND]
a = ax[0]
for (lo, hi), c in zip(rbins, cols):
f = []
for j in range(len(mb) - 1):
s = (mag >= mb[j]) & (mag < mb[j + 1]) & (rad >= lo) & (rad < hi)
f.append(ok[s].mean() if s.sum() > 15 else np.nan)
a.plot(mc, f, lw=2.0, color=c, label="%d - %d arcmin" % (lo, hi))
a.axhline(0.5, color=C_MUTED, lw=1, ls=":")
a.text(17.6, 0.53, "50%", fontsize=9, color=C_MUTED)
a.set_xlabel("injected G magnitude"); a.set_ylabel("recovery fraction")
a.set_ylim(0, 1.05)
a.set_title("a) artificial-star completeness by projected radius", fontsize=10.5, loc="left")
a.legend(fontsize=9, title="projected radius", title_fontsize=9, labelcolor=C_INK)
b = ax[1]
rb = np.array([0, 1, 2, 3, 4, 6, 8, 11, 15, 20, 30])
rc = 0.5 * (rb[1:] + rb[:-1])
for mlo, mhi, c, ls in ((17.5, 19.0, C_KNOWN, "-"), (19.0, 20.0, C_CAND, "-"),
(20.0, 21.0, C_EXT, "-"), (21.0, 21.5, C_ACC, "--")):
f = []
for j in range(len(rb) - 1):
s = (mag >= mlo) & (mag < mhi) & (rad >= rb[j]) & (rad < rb[j + 1])
f.append(ok[s].mean() if s.sum() > 15 else np.nan)
b.plot(rc, f, lw=2.0, color=c, ls=ls, label="G %.1f - %.1f" % (mlo, mhi))
b.set_xlabel("projected radius (arcmin)"); b.set_ylabel("recovery fraction")
b.set_ylim(0, 1.05); b.set_xscale("log")
b.set_xticks([1, 2, 3, 5, 10, 20]); b.set_xticklabels(["1", "2", "3", "5", "10", "20"])
b.set_title("b) the same, as a function of radius", fontsize=10.5, loc="left")
b.legend(fontsize=9, labelcolor=C_INK)
fig.suptitle("Completeness: how much harder is a cluster to find near the nucleus?",
fontsize=12.5, x=0.008, ha="left")
fig.tight_layout()
fig.savefig(layout.path("NGC5128-gc-completeness.png"), dpi=115, bbox_inches="tight",
facecolor=SURF)
plt.close(fig)
print("wrote NGC5128-gc-completeness.png")
# --------------------------------------------------------------------------
def annulus_area(rb, cx, cy, nx=4788, ny=3194, step=4):
"""effective area of each annulus that actually lies on the detector"""
yy, xx = np.mgrid[0:ny:step, 0:nx:step]
r = np.hypot(xx - cx, yy - cy) * PIXSCALE / 60.0
px_area = (step * PIXSCALE / 60.0) ** 2
return np.array([((r >= rb[i]) & (r < rb[i + 1])).sum() * px_area
for i in range(len(rb) - 1)]), r
def completeness_of(cp, mag, rad):
"""Completeness for each object, interpolated from the artificial-star grid.
A 2D lookup on (magnitude, radius). No luminosity function is assumed: each
surviving candidate is weighted by 1/C, the standard inverse-completeness
(Vmax-style) estimator.
"""
fm, fr, fok = cp["mag"], cp["rad"], cp["ok"]
mb = np.arange(17.5, 20.01, 0.5)
rbg = np.array([0, 1.5, 2.25, 3, 4, 5, 6, 8, 11, 15, 20, 30])
grid = np.full((len(mb) - 1, len(rbg) - 1), np.nan)
for i in range(len(mb) - 1):
for j in range(len(rbg) - 1):
s = ((fm >= mb[i]) & (fm < mb[i + 1]) & (fr >= rbg[j]) & (fr < rbg[j + 1]))
if s.sum() >= 15:
grid[i, j] = fok[s].mean()
# completeness is a strong function of magnitude and a weak one of radius
# outside the innermost annuli, so fill empty cells with the row median
for i in range(grid.shape[0]):
row = grid[i]
if np.isfinite(row).any():
row[~np.isfinite(row)] = np.nanmedian(row)
mi = np.clip(np.digitize(mag, mb) - 1, 0, len(mb) - 2)
ri = np.clip(np.digitize(rad, rbg) - 1, 0, len(rbg) - 2)
return grid[mi, ri], grid
def fig_radial(d, cp):
hdr = fits.getheader(layout.path("master-Luminance.fit"))
w = WCS(hdr)
cx, cy = [float(v) for v in w.all_world2pix(NUC_RA, NUC_DEC, 0)]
rb = np.array([0, 1.5, 3, 4.5, 6, 8, 11, 15, 20, 27])
rc = 0.5 * (rb[1:] + rb[:-1])
area, _ = annulus_area(rb, cx, cy)
r = d["r_arcmin"]
mag = d["mag"]
# Magnitude-limited sample: G < 19.5, where the measured completeness is
# 25-70% and the 1/C weights are therefore stable.
MLIM = 19.5
lim = mag < MLIM
cand = d["cand"] & lim
ext = (d["cls"] == "extended") & lim
star = (d["cls"] == "foreground-star") & lim
C, grid = completeness_of(cp, mag, r)
usable = np.isfinite(C) & (C > 0.15)
wt = np.where(usable, 1.0 / np.clip(C, 0.15, None), 0.0)
print("median completeness of the candidate sample: %.2f (%d of %d usable)"
% (np.nanmedian(C[cand]), (cand & usable).sum(), cand.sum()))
def prof(mask, weights=None):
n, sw = [], []
for i in range(len(rb) - 1):
s = mask & (r >= rb[i]) & (r < rb[i + 1])
n.append(s.sum())
sw.append(np.sum(weights[s]) if weights is not None else s.sum())
n = np.array(n, float); sw = np.array(sw, float)
with np.errstate(divide="ignore", invalid="ignore"):
mw = np.where(n > 0, sw / np.maximum(n, 1), 0.0) # mean weight
return n, sw / area, mw * np.sqrt(n) / area # Poisson error
n_c, s_c, e_c = prof(cand)
n_cc, s_cc, e_cc = prof(cand & usable, wt)
n_e, s_e, e_e = prof(ext)
n_s, s_s, e_s = prof(star)
# The SAME correction applied to the foreground stars. Milky Way stars are
# uniformly distributed on this scale, so their corrected profile MUST come
# out flat. That is the check on the whole correction: if it does not
# flatten them, the correction is wrong and so is the candidate profile.
n_sc, s_sc, e_sc = prof(star & usable, wt)
corr = np.where(s_c > 0, s_cc / np.maximum(s_c, 1e-12), np.nan)
# outer-field level from the two outermost annuli of the corrected profile
denom = np.nansum(area[-2:])
bg = np.nansum(s_cc[-2:] * area[-2:]) / denom
bg_e = np.sqrt(np.nansum(e_cc[-2:] ** 2 * area[-2:] ** 2)) / denom
fig, ax = plt.subplots(1, 2, figsize=(13.2, 5.6))
a = ax[0]
a.errorbar(rc, s_c, yerr=e_c, color=C_MUTED, lw=1.4, marker="o", ms=6,
capsize=3, label="candidates, raw counts", ls="--", zorder=3)
a.errorbar(rc, s_cc, yerr=e_cc, color=C_CAND, lw=2.4, marker="o", ms=8,
capsize=3, label="candidates, completeness-corrected", zorder=5)
a.errorbar(rc, s_e, yerr=e_e, color=C_EXT, lw=1.8, marker="s", ms=6,
capsize=3, label="extended sources, raw", zorder=4)
a.errorbar(rc, s_sc / 20.0, yerr=e_sc / 20.0, color=C_KNOWN, lw=1.8, marker="^",
ms=6, capsize=3, label="foreground stars / 20, corrected", zorder=2)
a.errorbar(rc, s_s / 20.0, yerr=e_s / 20.0, color=C_KNOWN, lw=1.1, marker="^",
ms=4, capsize=2, ls=":", alpha=0.55,
label="foreground stars / 20, raw", zorder=1)
a.axhline(bg, color=C_ACC, lw=1.4, ls="-.")
a.fill_between([0.8, 30], bg - bg_e, bg + bg_e, color=C_ACC, alpha=0.15, lw=0)
a.annotate("outer-field level", xy=(23, bg), xytext=(23, bg * 0.42),
color=C_ACC, fontsize=8.5, ha="center",
arrowprops=dict(arrowstyle="->", color=C_ACC, lw=1))
a.set_xscale("log"); a.set_yscale("log")
a.set_xlim(0.8, 30)
a.set_xticks([1, 2, 3, 5, 10, 20]); a.set_xticklabels(["1", "2", "3", "5", "10", "20"])
a.set_xlabel("projected radius from the nucleus (arcmin)")
a.set_ylabel("surface density (objects per arcmin$^2$)")
a.set_title("a) radial surface density, G < 19.5", fontsize=10.5, loc="left")
a.legend(fontsize=8.6, loc="lower left", labelcolor=C_INK)
sec = a.secondary_xaxis("top", functions=(lambda v: v * KPC_PER_ARCMIN,
lambda v: v / KPC_PER_ARCMIN))
sec.set_xlabel("projected radius (kpc at 3.8 Mpc)", fontsize=9.5)
# panel b: background-subtracted, with a power law fit
b = ax[1]
excess = s_cc - bg
ee = np.sqrt(e_cc ** 2 + bg_e ** 2)
ok = np.isfinite(excess) & (excess > 0) & (rc < 20)
b.errorbar(rc[ok], excess[ok], yerr=ee[ok], color=C_CAND, lw=2.2, marker="o",
ms=8, capsize=3, label="candidate excess over the outer field")
slope = np.nan
if ok.sum() >= 3:
p = np.polyfit(np.log10(rc[ok]), np.log10(excess[ok]), 1)
slope = p[0]
xr = np.array([rc[ok].min(), rc[ok].max()])
b.plot(xr, 10 ** np.polyval(p, np.log10(xr)), color=C_INK, lw=1.4, ls="--",
label=r"power law, $\Sigma \propto R^{%.2f}$" % slope)
b.set_xscale("log"); b.set_yscale("log")
b.set_xlim(0.8, 30)
b.set_xticks([1, 2, 3, 5, 10, 20]); b.set_xticklabels(["1", "2", "3", "5", "10", "20"])
b.set_xlabel("projected radius from the nucleus (arcmin)")
b.set_ylabel(r"excess surface density (arcmin$^{-2}$)")
b.set_title("b) excess over the outer field", fontsize=10.5, loc="left")
b.legend(fontsize=9, labelcolor=C_INK)
fig.suptitle("Are the candidates concentrated on NGC 5128?", fontsize=13,
x=0.008, ha="left")
fig.tight_layout()
fig.savefig(layout.path("NGC5128-gc-radial-profile.png"), dpi=115,
bbox_inches="tight", facecolor=SURF)
plt.close(fig)
print("wrote NGC5128-gc-radial-profile.png")
print("\nRADIAL PROFILE TABLE (G < 19.5)")
print("%6s %6s %5s %8s %7s %10s %10s %10s"
% ("r_in", "r_out", "N", "area", "medC", "sig_raw", "sig_corr", "sig_ext"))
for i in range(len(rc)):
s = cand & (r >= rb[i]) & (r < rb[i + 1])
mc = np.nanmedian(C[s]) if s.sum() else np.nan
print("%6.1f %6.1f %5d %8.2f %7.2f %10.4f %10.4f %10.4f"
% (rb[i], rb[i + 1], n_c[i], area[i], mc, s_c[i], s_cc[i], s_e[i]))
print("outer-field level %.4f +/- %.4f arcmin^-2 ; power-law slope %.2f"
% (bg, bg_e, slope))
print("corrected density at 1.5-3' / outer-field level = %.1f"
% (s_cc[1] / bg if bg > 0 else np.nan))
ei = np.nansum(n_e[:4]) / np.nansum(area[:4])
eo = np.nansum(n_e[6:]) / np.nansum(area[6:])
si = np.nansum(n_s[:4]) / np.nansum(area[:4])
so = np.nansum(n_s[6:]) / np.nansum(area[6:])
print("extended sources: inner(<6') %.4f outer(>11') %.4f ratio %.2f"
% (ei, eo, ei / eo if eo else np.nan))
print("foreground stars: inner(<6') %.4f outer(>11') %.4f ratio %.2f"
% (si, so, si / so if so else np.nan))
fin = np.nansum(s_sc[1:4] * area[1:4]) / np.nansum(area[1:4])
fout = np.nansum(s_sc[6:] * area[6:]) / np.nansum(area[6:])
print("foreground stars CORRECTED: inner(1.5-6') %.3f outer(>11') %.3f ratio %.2f"
% (fin, fout, fin / fout if fout else np.nan))
np.savez(layout.path("_gc_profile.npz"), rb=rb, rc=rc, area=area,
n_c=n_c, s_c=s_c, e_c=e_c, corr=corr, s_cc=s_cc, e_cc=e_cc,
s_e=s_e, s_s=s_s, s_sc=s_sc, bg=bg, bg_e=bg_e, slope=slope, grid=grid)
if __name__ == "__main__":
d = load_cat()
cp = completeness_grid()
fig_background(d)
fig_finder(d)
fig_cmd(d)
if cp is not None:
fig_completeness(cp)
fig_radial(d, cp)