Chandrayaan-2 XSM · level 2

How the XSM energy bands are madeCómo se arman las bandas de energía de XSM

A short Python script turns the archived XSM files into a 1 s light curve and count rates in five energy bands. On the left is the exact code; on the right, what each block does, why, and where it comes from.

Un script corto de Python convierte los archivos de XSM en una curva de luz de 1 s y tasas de cuentas en cinco bandas de energía. A la izquierda va el código exacto; a la derecha, qué hace cada bloque, por qué y de dónde sale.

Read this firstLee esto primero The bands are counts, not photons.Las bandas son cuentas, no fotones. Where this stopsHasta dónde llega ↓
out.pngas written by the script, shown unalteredtal como lo escribe el script, sin retocar
XSM light curve and five energy-band count rates on 2024-10-03, with two flares shaded grey where the Be attenuator was in

What it producesLo que produce

2024-10-03, a day with two large flares. Top: all energies, one point per second. Bottom: the five bands, one point per minute. Grey marks the minutes with the Be attenuator in, where counts drop because of the filter.

2024-10-03, un día con dos fulguraciones grandes. Arriba: todas las energías, un punto por segundo. Abajo: las cinco bandas, un punto por minuto. El gris marca los minutos con el atenuador de Be puesto, donde las cuentas caen por el filtro.

1–2 keV2–33–55–88–15Be

Before reading the plotAntes de leer el gráfico

  1. Before June 2020, counts below 1.3 keV are lower than the response predicts, because of the detector's low-energy threshold. The guide recommends ignoring them for spectral fitting (§8, printed p. 27).
  2. The Sun angle changes with a period of about 120 minutes (half the orbit). The software corrects the effective area, but residuals may remain (§8, printed p. 27). Do not read a 120 min pattern as solar.
  3. In noon-midnight seasons the data are sparse and the calibration uncertainty is larger, about 10–15 % (§8, printed p. 27).
  4. FILT_STATUS values other than 0 and 2 occur (10 on 2024-10-03). The script does not shade them; treat them as unknown.
  1. Antes de junio de 2020, las cuentas por debajo de 1,3 keV son menores que lo que predice la respuesta, por el umbral de baja energía del detector. La guía recomienda ignorarlas al ajustar espectros (§8, p. 27 impresa).
  2. El ángulo del Sol cambia con un periodo de unos 120 minutos (media órbita). El software corrige el área efectiva, pero pueden quedar residuos (§8, p. 27 impresa). Un patrón de 120 min no debe leerse como solar.
  3. En las temporadas de mediodía-medianoche los datos son escasos y la incertidumbre de calibración es mayor, cerca de 10–15 % (§8, p. 27 impresa).
  4. Aparecen valores de FILT_STATUS distintos de 0 y 2 (10 el 2024-10-03). El script no los sombrea; hay que tratarlos como desconocidos.

The script, block by blockEl script, bloque a bloque

blocksbloques · 131 lineslíneas
Long linesLíneas largas
CodeCódigo
What it does and whyQué hace y por qué
"""Chandrayaan-2 XSM: count-rate light curves in energy bands, from level-2 data.

What this plots
---------------
Top panel:    the level-2 light curve (.lc), native 1 s cadence, all energies.
Bottom panel: count rates in five energy bands, built from the level-2 spectra
              (.pha, one 512-channel spectrum per minute as archived).

How the bands are built
-----------------------
Each PI channel is assigned to a band by the centre of its nominal energy range,
taken from the official CALDB EBOUNDS table, so every channel lands in exactly
one band. Band rate = sum of COUNTS over the band's channels / EXPOSURE of that
spectrum. Spectra with non-positive or non-finite exposure are left empty.

What this is NOT
----------------
These are COUNT rates in nominal channel energy, as in the PRL quick look.
They are not photon fluxes: no response (RMF/ARF) is applied, so detector
redistribution and efficiency are not removed. For photon spectra, temperature
or emission measure, forward-fold a model through the response (e.g. XSPEC).
The Be attenuator (FILT_STATUS == 2) is shaded, not corrected: rates under Be
are not comparable with open-filter rates, especially below ~2 keV.

Usage
-----
python xsm_count_bands.py <ch2_xsm_YYYYMMDD_v1.zip> <ch2_xsm_caldb_*.zip> <out.png>

Requires numpy, matplotlib, astropy. Reads the zips in memory; writes only the PNG.
"""

Reading guide

The docstring states what the figure is, how the bands are made, and what the figure is not. If you read only one part of the script, read this one.

The key phrase is count rates in nominal channel energy. The detector records the energy of each photon, but imperfectly: some photons land in neighbouring channels, and the detector does not see all energies equally well. Turning counts back into photons needs the instrument response. This script does not apply it.

Guía de lectura

El docstring dice qué es la figura, cómo se arman las bandas y qué no es. Si lees una sola parte del script, que sea esta.

La frase clave es tasas de cuentas en la energía nominal del canal. El detector registra la energía de cada fotón, pero no perfecta: algunos fotones caen en canales vecinos y el detector no ve todas las energías igual de bien. Volver de cuentas a fotones requiere la respuesta del instrumento. Este script no la aplica.

import argparse
import datetime as dt
import io
import re
import zipfile

import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
from astropy.io import fits

Dependencies

Only three scientific packages: numpy for arrays, astropy to read FITS, matplotlib to draw. Agg renders straight to a PNG file, with no window.

Dependencias

Solo tres paquetes científicos: numpy para los arreglos, astropy para leer FITS y matplotlib para dibujar. Agg dibuja directo a un PNG, sin abrir ventana.

BANDS_KEV = [(1, 2), (2, 3), (3, 5), (5, 8), (8, 15)]
COLORS = ["#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#66a61e"]
MET_EPOCH = dt.datetime(2017, 1, 1)  # XSM MET: UTC seconds since 2017-01-01 (MJDREF 57754)

Bands and the clock

Five bands from 1 to 15 keV. The upper limit is not arbitrary: above 15 keV the last ADC channel also collects upper-level-discriminator events, so the guide restricts solar light curves to below 15 keV (PRL XSM Data Analysis Guide v1.5, §5.3, printed p. 17).

XSM time is MET: UTC seconds since 2017-01-01 00:00 UTC, which is MJD 57754 (PRL XSM Data Analysis Guide v1.5, §4.5.1, printed p. 16). Adding seconds to that date gives UTC directly.

Bandas y el reloj

Cinco bandas de 1 a 15 keV. El límite superior no es arbitrario: por encima de 15 keV el último canal del ADC también junta eventos del discriminador de nivel alto, así que la guía limita las curvas solares a menos de 15 keV (PRL XSM Data Analysis Guide v1.5, §5.3, p. 17 impresa).

El tiempo de XSM es MET: segundos UTC desde el 2017-01-01 00:00 UTC, que es el MJD 57754 (PRL XSM Data Analysis Guide v1.5, §4.5.1, p. 16 impresa). Sumar esos segundos a esa fecha da UTC directamente.

def read_member(archive, suffix):
    names = [n for n in archive.namelist() if n.endswith(suffix) and "/calibrated/" in n]
    if len(names) != 1:
        raise SystemExit(f"expected one calibrated *{suffix} in the zip, found {len(names)}")
    return fits.open(io.BytesIO(archive.read(names[0])))[1]

Reading inside the zip

Each PRADAN day is one zip with raw (level 1) and calibrated (level 2) files. This function opens a calibrated file in memory, without extracting anything to disk, and stops if it finds zero or several matches instead of guessing.

Leer dentro del zip

Cada día de PRADAN es un zip con archivos crudos (nivel 1) y calibrados (nivel 2). Esta función abre un archivo calibrado en memoria, sin extraer nada al disco, y se detiene si encuentra cero o varios en vez de adivinar.

def met_to_datetime(seconds):
    return np.array([MET_EPOCH + dt.timedelta(seconds=float(s)) if np.isfinite(s) else None
                     for s in seconds])

MET to UTC

Converts each MET value into a UTC date and time for the plot axis. Missing values stay empty.

MET a UTC

Convierte cada valor MET en fecha y hora UTC para el eje del gráfico. Los valores faltantes quedan vacíos.

def break_gaps(t, y, max_step):
    """Insert NaN where consecutive samples are further apart than max_step, so lines break."""
    cut = np.where(np.diff(t) > max_step)[0] + 1
    return np.insert(t, cut, np.nan), np.insert(y.astype(float), cut, np.nan)

Gaps stay gaps

XSM does not see the Sun all the time: the Moon blocks it and some data are flagged out. Drawing a line across those gaps would invent data. This function inserts an empty value wherever two samples are further apart than expected (1.5 s for the 1 s curve, 90 s for the 1 min spectra), so the line breaks there.

Los huecos quedan como huecos

XSM no ve el Sol todo el tiempo: la Luna lo tapa y algunos datos se descartan. Unir esos huecos con una línea inventaría datos. Esta función inserta un valor vacío donde dos muestras están más separadas de lo esperado (1,5 s en la curva de 1 s, 90 s en los espectros de 1 min) y la línea se corta ahí.

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("day_zip")
    ap.add_argument("caldb_zip")
    ap.add_argument("out_png")
    args = ap.parse_args()

    caldb = zipfile.ZipFile(args.caldb_zip)
    eb_name = [n for n in caldb.namelist() if re.search(r"ebounds.*\.fits$", n, re.I)][0]
    eb = fits.open(io.BytesIO(caldb.read(eb_name)))[1].data
    e_centre = (np.asarray(eb["E_MIN"], float) + np.asarray(eb["E_MAX"], float)) / 2

Which energy each channel means

The spectra have 512 PI channels. PI (pulse invariant) means the pipeline has already corrected the detector gain, so a given channel means the same energy on every day (PRL XSM Data Analysis Guide v1.5, xsmgenspec parameters, printed p. 13).

The energy of each channel comes from the official EBOUNDS table in the CALDB: one row per channel with E_MIN and E_MAX (OGIP CAL/GEN/92-002, §3.2). For XSM each channel is about 0.033 keV wide.

A channel goes to the band that contains its centre. That way every channel lands in exactly one band, including the ones that straddle a band edge.

BandPI channelsChannels
1–2 keV15–4430
2–3 keV45–7531
3–5 keV76–13560
5–8 keV136–22691
8–15 keV227–438212

Qué energía significa cada canal

Los espectros tienen 512 canales PI. PI (pulse invariant) quiere decir que el pipeline ya corrigió la ganancia del detector, así que un canal significa la misma energía todos los días (PRL XSM Data Analysis Guide v1.5, parámetros de xsmgenspec, p. 13 impresa).

La energía de cada canal sale de la tabla oficial EBOUNDS de la CALDB: una fila por canal con E_MIN y E_MAX (OGIP CAL/GEN/92-002, §3.2). En XSM cada canal mide unos 0,033 keV.

Cada canal va a la banda que contiene su centro. Así cada canal cae en exactamente una banda, incluidos los que quedan sobre un borde.

BandaCanales PICanales
1–2 keV15–4430
2–3 keV45–7531
3–5 keV76–13560
5–8 keV136–22691
8–15 keV227–438212
    day = zipfile.ZipFile(args.day_zip)
    lc = read_member(day, ".lc")
    pha = read_member(day, ".pha")
    for hdu in (lc, pha):
        if hdu.header.get("TIMESYS", "UTC") != "UTC" or float(hdu.header.get("MJDREF", 57754)) != 57754:
            raise SystemExit("unexpected time system; this script assumes UTC with MJDREF 57754")

Two level-2 products

.lc is the light curve: one rate per second, all energies together. .pha holds the spectra: one 512-channel spectrum per minute, as archived by PRL.

The script refuses to run if the files do not declare UTC with MJDREF 57754, because every time on the plot depends on that.

Dos productos de nivel 2

.lc es la curva de luz: una tasa por segundo, todas las energías juntas. .pha guarda los espectros: un espectro de 512 canales por minuto, tal como lo archiva PRL.

El script se niega a correr si los archivos no declaran UTC con MJDREF 57754, porque todas las horas del gráfico dependen de eso.

    t_lc = np.asarray(lc.data["TIME"], float) + float(lc.header.get("TIMEZERO", 0.0))
    rate_lc = np.asarray(lc.data["RATE"], float)

The 1 s curve, untouched

RATE is already counts per second, so it is not divided by anything again. TIMEZERO is a header offset that must be added to every time; it is usually zero, but the script reads it anyway.

La curva de 1 s, sin tocar

RATE ya está en cuentas por segundo, así que no se divide otra vez. TIMEZERO es un desplazamiento del encabezado que se suma a todos los tiempos; casi siempre es cero, pero el script lo lee igual.

    tz = float(pha.header.get("TIMEZERO", 0.0))
    t_mid = (np.asarray(pha.data["TSTART"], float) + np.asarray(pha.data["TSTOP"], float)) / 2 + tz
    counts = np.asarray(pha.data["COUNTS"], float)
    exposure = np.asarray(pha.data["EXPOSURE"], float)
    filt = np.asarray(pha.data["FILT_STATUS"], int)
    valid = np.isfinite(exposure) & (exposure > 0)

The spectra

For each one-minute spectrum the script keeps four things: its central time, the COUNTS in each of the 512 channels, the EXPOSURE (the real observing time inside that minute) and FILT_STATUS (0 open, 2 Be attenuator in).

Level-2 COUNTS are not whole numbers; they are used exactly as archived. Spectra without a positive exposure are marked invalid and stay empty instead of producing a division by zero.

Los espectros

De cada espectro de un minuto el script guarda cuatro cosas: su tiempo central, las COUNTS de cada uno de los 512 canales, la EXPOSURE (el tiempo real de observación dentro de ese minuto) y FILT_STATUS (0 abierto, 2 con el atenuador de Be).

Las COUNTS de nivel 2 no son números enteros; se usan tal como vienen. Los espectros sin exposición positiva se marcan como inválidos y quedan vacíos en vez de dividir por cero.

    fig, (ax_lc, ax_b) = plt.subplots(2, 1, figsize=(10, 7.5), sharex=True,
                                      gridspec_kw={"height_ratios": [1, 1.6]})
    t, y = break_gaps(t_lc, rate_lc, 1.5)
    ax_lc.plot(met_to_datetime(t), y, lw=0.8, color="#1f4e79")
    ax_lc.set_yscale("log")
    ax_lc.set_ylabel("All energies\n(counts/s, 1 s)")

Top panel

The 1 s light curve on a log axis, with lines broken at gaps. Nothing is averaged or smoothed.

Panel de arriba

La curva de 1 s en eje logarítmico, con la línea cortada en los huecos. No se promedia ni se suaviza nada.

    for (lo, hi), color in zip(BANDS_KEV, COLORS):
        channels = (e_centre >= lo) & (e_centre < hi)
        rate = np.full(len(exposure), np.nan)
        rate[valid] = counts[valid][:, channels].sum(axis=1) / exposure[valid]
        t, y = break_gaps(t_mid, rate, 90)
        ax_b.plot(met_to_datetime(t), y, lw=1.2, color=color, label=f"{lo}-{hi} keV")
        print(f"{lo}-{hi} keV: {int(channels.sum())} channels, max {np.nanmax(rate):.3g} counts/s")

The bands: the core of the method

For each minute and each band: add the counts of the band's channels, then divide by that minute's exposure. The result is counts per second in that band, one point per minute. Grouping channels this way is binning in energy; time is not regrouped, it keeps the one-minute cadence of the archived spectra.

Why one minute and not one second? The 1 s curve has all energies mixed into one number, so it cannot be split. The official route to band light curves at a chosen cadence is xsmgenlc enelow=1 enehigh=2 tbinsizesec=10 run on level-1 data with PRL's XSMDAS software (PRL XSM Data Analysis Guide v1.5, §5.3, printed p. 17).

What it is not. These are counts, not photons. Recovering photon spectra, temperature or emission measure means fitting a model folded through the response (RMF and ARF), for example in XSPEC.

Las bandas: el corazón del método

Para cada minuto y cada banda: sumar las cuentas de los canales de la banda y dividir por la exposición de ese minuto. El resultado son cuentas por segundo en esa banda, un punto por minuto. Agrupar canales así es binning en energía; el tiempo no se reagrupa, conserva la cadencia de un minuto de los espectros archivados.

¿Por qué un minuto y no un segundo? La curva de 1 s trae todas las energías mezcladas en un solo número, así que no se puede partir. La vía oficial para curvas por banda con la cadencia que uno elija es xsmgenlc enelow=1 enehigh=2 tbinsizesec=10, sobre datos de nivel 1 con el software XSMDAS de PRL (PRL XSM Data Analysis Guide v1.5, §5.3, p. 17 impresa).

Lo que no es. Son cuentas, no fotones. Recuperar espectros de fotones, temperatura o medida de emisión requiere ajustar un modelo pasado por la respuesta (RMF y ARF), por ejemplo en XSPEC.

    for s in t_mid[filt == 2]:
        for ax in (ax_lc, ax_b):
            ax.axvspan(MET_EPOCH + dt.timedelta(seconds=s - 30), MET_EPOCH + dt.timedelta(seconds=s + 30),
                       color="0.85", lw=0)

The Be attenuator

During strong flares XSM moves a 250 µm beryllium attenuator in front of the detector (Mithun et al. 2026, §3.3). It absorbs low-energy X-rays, so counts drop suddenly, most of all below about 2 keV. That drop is the filter, not the Sun.

The script does not correct it. It shades those minutes grey in both panels so the jump is never read as solar variation.

El atenuador de Be

En fulguraciones intensas XSM pone un atenuador de berilio de 250 µm delante del detector (Mithun et al. 2026, §3.3). Absorbe los rayos X de baja energía, así que las cuentas caen de golpe, sobre todo por debajo de unos 2 keV. Esa caída es el filtro, no el Sol.

El script no la corrige. Pinta esos minutos de gris en los dos paneles para que el salto nunca se lea como variación solar.

    ax_b.set_yscale("log")
    ax_b.set_ylabel("Energy bands\n(counts/s, 1 min spectra)")
    ax_b.legend(ncol=5, fontsize=9, loc="upper left")
    ax_b.set_xlabel("UTC")
    ax_b.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
    for ax in (ax_lc, ax_b):
        ax.grid(alpha=0.25)
    date = (MET_EPOCH + dt.timedelta(seconds=float(np.nanmedian(t_lc)))).date()
    ax_lc.set_title(f"Chandrayaan-2 XSM {date}, level 2 count rates (no response applied; grey = Be attenuator)")
    fig.tight_layout()
    fig.savefig(args.out_png, dpi=130)
    print(f"{len(exposure)} spectra, {int(valid.sum())} with valid exposure, {int((filt == 2).sum())} under Be")

Labels and output

Axis labels state the cadence of each panel. The title repeats that no response is applied. The script prints the channels per band and how many spectra were valid or under Be, so a run can be checked without opening the PNG.

Etiquetas y salida

Las etiquetas de los ejes dicen la cadencia de cada panel. El título repite que no se aplicó la respuesta. El script imprime los canales por banda y cuántos espectros fueron válidos o con Be, para revisar una corrida sin abrir el PNG.

if __name__ == "__main__":
    main()

Entry point

Runs main() only when the file is executed as a script, not when it is imported.

Punto de entrada

Ejecuta main() solo cuando el archivo se corre como script, no cuando se importa.

Where this stopsHasta dónde llega

  • No response is applied, so no photon flux, no W/m², no GOES class.
  • No temperature, emission measure or abundances. Those come from spectral fitting with the response, such as the XSPEC workflow with PRL's solar models.
  • No level-1 reprocessing. Band curves faster than one minute need xsmgenlc from XSMDAS; 100 ms curves exist in three fixed bands (§5.4).
  • No se aplica la respuesta: nada de flujo de fotones, W/m² ni clase GOES.
  • Ni temperatura, ni medida de emisión, ni abundancias. Eso sale de ajustar el espectro con la respuesta, como el flujo de XSPEC con los modelos solares de PRL.
  • No se reprocesa el nivel 1. Curvas por banda más rápidas que un minuto requieren xsmgenlc de XSMDAS; existen curvas de 100 ms en tres bandas fijas (§5.4).

ReferencesReferencias

  1. PRL, XSM Data Analysis Guide, v1.5, November 2025. prl.res.in/ch2xsm/data_analysis. Sections cited: 4.5.1, 5.3, 5.4, 8, and the xsmgenspec parameters.
  2. George, Arnaud, Pence, Ruamsuwan & Corcoran, The Calibration Requirements for Spectral Analysis (Definition of RMF and ARF file formats), OGIP CAL/GEN/92-002, §3.1–3.2. HEASARC.
  3. Arnaud, George & Tennant, The OGIP Spectral File Format, OGIP/92-007 (PHA files, Type II). HEASARC.
  4. Mithun et al. 2020, Solar X-ray Monitor On Board the Chandrayaan-2 Orbiter: In-flight Performance and Science Prospects, Solar Physics 295, 139. doi:10.1007/s11207-020-01712-1.
  5. Mithun et al. 2021, Data processing software for Chandrayaan-2 Solar X-ray Monitor, Astronomy and Computing. §4 and Fig. 5 show the quick look as a count curve plus a count spectrum. doi:10.1016/j.ascom.2021.100449.
  6. Mithun et al. 2026, Cross-Calibration of Chandrayaan-2 XSM with INSPIRESat-1 DAXSS and GOES-16 XRS, §3.3 (Be attenuator). arXiv:2608.01411.