codex/light-curve-aperture-calibration.py

light-curve-aperture-calibration.py
"""Synthetic light curve, not Kepler measurements.

Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
From _media: MPLCONFIGDIR=/tmp/codex-wiki-matplotlib python ../light-curve-aperture-calibration.py
Writes only light-curve-aperture-calibration.png to the current directory.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

DEPTH = 0.01
HALF_DURATION = 2.0  # hours
HALF_FLAT = 1.4  # hours
EXPOSURE = 0.5  # hours
REFERENCE_RATE = 100_000.0  # electrons/second
SKY_RATE = 4_000.0  # electrons/second
DRIFT = 0.0003  # per hour
SIGMA = 0.0006  # injected relative Gaussian scatter, not a detector noise model


def relative_flux(t):
    """A continuous trapezoid, deliberately not a physical transit profile."""
    t = np.asarray(t)
    fraction = np.clip((HALF_DURATION - np.abs(t)) / (HALF_DURATION - HALF_FLAT), 0, 1)
    return 1 - DEPTH * fraction


def exposure_mean(t, response_drift=0.0, duration=EXPOSURE):
    """Exact piecewise integral of linear response times piecewise-linear flux."""
    centres = np.asarray(t, dtype=float)
    result = np.empty_like(centres)
    for index in np.ndindex(centres.shape):
        left, right = centres[index] - duration / 2, centres[index] + duration / 2
        edges = [left] + [x for x in (-2.0, -1.4, 1.4, 2.0) if left < x < right] + [right]
        integral = 0.0
        for a, b in zip(edges[:-1], edges[1:]):
            # Simpson's rule is exact on each quadratic segment.
            points = np.array([a, (a + b) / 2, b])
            values = (1 + response_drift * points) * relative_flux(points)
            integral += (b - a) * (values[0] + 4 * values[1] + values[2]) / 6
        result[index] = integral / duration
    return result


def main():
    plt.rcParams.update({"font.size": 11, "axes.spines.top": False, "axes.spines.right": False})
    rng = np.random.default_rng(17091)
    samples = np.arange(-5.75, 6.0, EXPOSURE)
    dense = np.linspace(-6, 6, 1201)
    response = 1 + DRIFT * samples
    source_mean = exposure_mean(samples, DRIFT)
    injected_noise = rng.normal(0, SIGMA, samples.size)
    recorded = SKY_RATE + REFERENCE_RATE * (source_mean + response * injected_noise)
    corrected = (recorded - SKY_RATE) / (REFERENCE_RATE * response)
    expected = source_mean / response

    fig, axes = plt.subplots(2, 1, figsize=(8.6, 6.4), sharex=True)
    fig.patch.set_facecolor("white")
    fig.subplots_adjust(left=0.12, right=0.97, top=0.86, bottom=0.10, hspace=0.33)
    fig.suptitle("From detector counts to a light curve", fontsize=16, y=0.97)
    fig.text(0.5, 0.91, "Synthetic toy example • no observed source • 30-minute exposures", ha="center", color="#555555")
    ax = axes[0]
    ax.set_title("1. Recorded aperture rate includes sky and response drift", loc="left", fontsize=12)
    ax.plot(dense, (SKY_RATE + REFERENCE_RATE * (1 + DRIFT * dense)) / 1000,
            "--", color="#b36a12", lw=1.6, label="baseline without transit")
    ax.plot(dense, (SKY_RATE + REFERENCE_RATE * (1 + DRIFT * dense) * relative_flux(dense)) / 1000,
            color="#156c97", lw=2, label="instantaneous toy model")
    ax.errorbar(samples, recorded / 1000, yerr=REFERENCE_RATE * response * SIGMA / 1000,
                fmt="o", ms=3.5, color="#293b48", elinewidth=0.8, capsize=2, label="synthetic exposure samples")
    ax.set_ylabel("Aperture rate\n(thousand electrons/s)")
    ax.set_ylim(102.7, 104.35)
    ax.legend(fontsize=9, loc="lower right", framealpha=1)

    ax = axes[1]
    ax.set_title("2. Subtract known sky; divide by known response and reference", loc="left", fontsize=12)
    ax.axhline(1, color="#b36a12", ls="--", lw=1.4)
    ax.plot(dense, relative_flux(dense), color="#156c97", lw=2, label="instantaneous relative flux")
    ax.plot(samples, expected, "s", mfc="white", mec="#ba4c40", ms=6, label="exposure-averaged expectation")
    ax.errorbar(samples, corrected, yerr=SIGMA, fmt="o", ms=3.5, color="#293b48",
                elinewidth=0.8, capsize=2, label="corrected synthetic samples")
    ax.set_ylabel("Relative brightness")
    ax.set_xlabel("Time from mid-transit (hours)")
    ax.set_ylim(0.9875, 1.003)
    ax.set_xlim(-6, 6)
    ax.annotate("1% depth", xy=(0, 0.99), xytext=(0, 0.995), ha="center", fontsize=10,
                arrowprops={"arrowstyle": "->", "color": "#555555"})
    ax.legend(fontsize=9, loc="lower right", framealpha=1)
    for ax in axes:
        ax.set_facecolor("white")
        ax.grid(alpha=0.16)
    fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=135, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()