TRAPPIST-1 b y c: rocas desnudas a 40 años-luz#

490 kelvin de día. Cero emisión de noche. Así se ve un planeta sin atmósfera.

Paper: Ducrot, E., Gillon, M., Demory, B.-O. et al. No thick atmosphere around TRAPPIST-1 b and c from JWST thermal phase curves. Nature Astronomy (2026). DOI: 10.1038/s41550-026-02806-9 Datos: MAST (JWST Programme 3077) + Supplementary Materials

Abrir en Colab

El sistema más estudiado fuera del nuestro#

TRAPPIST-1 está a ~40 años-luz. Una enana roja con siete planetas rocosos apretados en órbitas más pequeñas que la de Mercurio. Desde 2017, la gran pregunta es: ¿alguno tiene atmósfera?

El James Webb Space Telescope (JWST) observó los dos más cercanos a la estrella — TRAPPIST-1 b y c — durante más de 2 días continuos con su instrumento MIRI a 15 μm (infrarrojo medio). A esa longitud de onda, el telescopio detecta directamente el calor que irradian los planetas. Cuatro equipos analizaron los datos de forma independiente.

# ══════════════════════════════════════════════════════════════
# Configuración — modifica estos valores para explorar
# ══════════════════════════════════════════════════════════════
T_DAY_B = 490       # Temperatura diurna planeta b (K) — headline del paper
T_DAY_C = 369       # Temperatura diurna planeta c (K)
T_NIGHT_B_MAX = 243  # Estimación máxima nocturna b (K, ZH sinusoidal)
T_NIGHT_C = 266      # Nocturna c (K, ZH quasi-Lambert)
T_STAR = 2566        # Temperatura de TRAPPIST-1 (K)

COLOR_B = '#DC2626'     # Rojo — planeta b (más caliente, más cercano)
COLOR_C = '#2563EB'     # Azul CaM — planeta c
COLOR_MODELO = '#059669' # Emerald — modelos atmosféricos
COLOR_REF = '#D97706'    # Amber — referencia
COLOR_GRIS = '#BBBBBB'

FUENTE = 'Fuente: Ducrot et al. (2026), Nature Astronomy | Datos: JWST MIRI 15 μm'

# ══════════════════════════════════════════════════════════════
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os, urllib.request

# Estilo CaM
style_file = '../../cam.mplstyle'
if not os.path.exists(style_file):
    style_file = '/tmp/cam.mplstyle'
    if not os.path.exists(style_file):
        BASE = 'https://raw.githubusercontent.com/Ciencia-a-Mordiscos/lab/main'
        urllib.request.urlretrieve(f'{BASE}/cam.mplstyle', style_file)
plt.style.use(style_file)

# Cargar datos
obs = pd.read_csv('datos/curva_fase_obs.csv')
modelo = pd.read_csv('datos/curva_fase_modelo.csv')
mcmc_b = pd.read_csv('datos/mcmc_chunk0.csv')
mcmc_c = pd.read_csv('datos/mcmc_chunk3.csv')
clima = pd.read_csv('datos/modelos_climaticos.csv')
temps = pd.read_csv('datos/temperaturas_analisis.csv')

print(f"Observaciones GO 3077: {len(obs)} puntos (bins de 15 min)")
print(f"Modelo best-fit: {len(modelo)} puntos")
print(f"Modelos climáticos: {len(clima)} escenarios atmosféricos")
print(f"Duración observación: {obs['time_bjd'].max() - obs['time_bjd'].min():.2f} días (~{(obs['time_bjd'].max() - obs['time_bjd'].min())*24:.0f} horas)")
Observaciones GO 3077: 207 puntos (bins de 15 min)
Modelo best-fit: 4501 puntos
Modelos climáticos: 12 escenarios atmosféricos
Duración observación: 2.18 días (~52 horas)

La firma térmica de dos mundos#

Aquí está. Más de 52 horas de observación continua con el JWST.

fig, ax = plt.subplots(figsize=(13, 5.5))

# Observaciones binned (15 min)
ax.errorbar(obs['time_bjd'], obs['flux_ppm'], yerr=obs['error_ppm'],
            fmt='o', color='#555555', markersize=3.5, alpha=0.7,
            ecolor='#CCCCCC', elinewidth=0.8, capsize=0, zorder=3,
            label='Datos JWST (bins 15 min)')

# Modelo best-fit
modelo_sorted = modelo.sort_values('time_bjd')
ax.plot(modelo_sorted['time_bjd'], modelo_sorted['flux_ppm'],
        color=COLOR_B, linewidth=1.8, alpha=0.9, zorder=4,
        label='Modelo best-fit (b + c)')

# Anotar tránsitos de planeta b (dips profundos ~7000 ppm)
# Transit times from SM Table 3: phase curve transit at BJD 10271.254
t_transit_b = 10271.254
P_b = 1.51154
for i in range(2):
    t = t_transit_b + i * P_b
    if obs['time_bjd'].min() < t < obs['time_bjd'].max():
        ax.annotate('Tránsito b', xy=(t, -6000), xytext=(t, -5000),
                    fontsize=8.5, color=COLOR_B, ha='center', fontweight='bold',
                    arrowprops=dict(arrowstyle='->', color=COLOR_B, lw=1.2))

# Anotar tránsito de planeta c
t_transit_c = 10273.234
if obs['time_bjd'].min() < t_transit_c < obs['time_bjd'].max():
    ax.annotate('Tránsito c', xy=(t_transit_c, -6000), xytext=(t_transit_c, -5000),
                fontsize=8.5, color=COLOR_C, ha='center', fontweight='bold',
                arrowprops=dict(arrowstyle='->', color=COLOR_C, lw=1.2))

# Anotar fase diurna (máximo de flujo entre tránsitos)
ax.annotate('Cara diurna b →', xy=(10272.0, 800),
            fontsize=8.5, color=COLOR_B, ha='center', style='italic')

ax.axhline(y=0, color='#DDDDDD', linewidth=0.8, linestyle='--', zorder=1)

ax.set_xlabel('Tiempo (BJD$_{\mathrm{TDB}}$ − 2.450.000)', fontsize=11)
ax.set_ylabel('Flujo relativo (ppm)', fontsize=11)
ax.set_title('Curva de fase JWST: TRAPPIST-1 b y c a 15 μm',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.5, 1.02, '52 horas continuas con MIRI — los valles son tránsitos, las crestas son el calor planetario',
        transform=ax.transAxes, fontsize=10, color='#666666', ha='center')

ax.legend(fontsize=9, loc='upper right', framealpha=0.9)
fig.text(0.13, -0.03, FUENTE, fontsize=7.5, color='#999999', style='italic')
plt.savefig('figuras/curva_fase_jwst.png', dpi=200, bbox_inches='tight')
plt.show()
<>:39: SyntaxWarning: invalid escape sequence '\m'
<>:39: SyntaxWarning: invalid escape sequence '\m'
/tmp/ipykernel_566/3071807145.py:39: SyntaxWarning: invalid escape sequence '\m'
  ax.set_xlabel('Tiempo (BJD$_{\mathrm{TDB}}$ − 2.450.000)', fontsize=11)
../../_images/dd5ec0667d9b22f7a7a54fcd91aca8bee831e1b879bbb545fdbdf3535765ba4e.png

Los valles profundos (~7.000 ppm) son los tránsitos: cada planeta pasa frente a la estrella y bloquea luz. Pero lo que importa aquí es lo que pasa ENTRE los tránsitos.

Entre un tránsito y el siguiente, el flujo sube gradualmente. Eso es el calor del lado diurno del planeta irradiando hacia nosotros. Cuando el planeta muestra su «espalda» (lado nocturno), el flujo cae a cero. Esa diferencia entre cresta y valle nos dice si hay atmósfera redistribuyendo calor, o si el planeta es una roca desnuda.

Cuatro equipos independientes (MG, ED, TJB, ZH) ajustaron estos datos. Todos coinciden.

¿Cuánto calor? Día vs noche#

Con el flujo medido calculamos la temperatura de brillo: cuán caliente está la superficie. Si hubiera una atmósfera densa redistribuyendo calor, la noche sería más templada. Veamos qué predicen los modelos climáticos para diferentes composiciones.

fig, axes = plt.subplots(1, 2, figsize=(13, 6), sharey=True)

for idx, (planet, ax, color, t_day, t_day_err, t_night, t_night_err) in enumerate([
    ('b', axes[0], COLOR_B, T_DAY_B, 17, T_NIGHT_B_MAX, 40),
    ('c', axes[1], COLOR_C, T_DAY_C, 23, T_NIGHT_C, 36)
]):
    # Modelos climáticos para este planeta
    models = clima[clima['planet'] == planet]

    # Plot atmospheric model predictions
    y_pos = list(range(len(models)))
    for i, (_, row) in enumerate(models.iterrows()):
        label_text = f"{row['environment']} ({row['pressure_bar']} bar)"
        # Day temperature
        ax.barh(i, row['T_day'], height=0.35, left=0, color=COLOR_MODELO,
                alpha=0.5, edgecolor='white', linewidth=0.5)
        # Night temperature
        ax.barh(i, row['T_night'], height=0.35, left=0, color=COLOR_MODELO,
                alpha=0.25, edgecolor='white', linewidth=0.5)
        ax.text(5, i, label_text, fontsize=7.5, va='center', color='#444444')

    # Observed values as vertical bands
    ax.axvspan(t_day - t_day_err, t_day + t_day_err, alpha=0.15, color=color, zorder=0)
    ax.axvline(t_day, color=color, linewidth=2.5, linestyle='-', alpha=0.8, zorder=5,
               label=f'Día observado: {t_day} ± {t_day_err} K')
    ax.axvspan(max(0, t_night - t_night_err), t_night + t_night_err,
               alpha=0.10, color='#7C3AED', zorder=0)
    ax.axvline(t_night, color='#7C3AED', linewidth=2, linestyle='--', alpha=0.7, zorder=5,
               label=f'Noche: {t_night} ± {t_night_err} K')

    ax.set_yticks(y_pos)
    ax.set_yticklabels(['' for _ in y_pos])
    ax.set_xlabel('Temperatura superficial (K)', fontsize=10)
    ax.set_title(f'TRAPPIST-1 {planet}', fontsize=13, fontweight='bold', pad=15,
                 color=color)
    ax.legend(fontsize=8, loc='lower right', framealpha=0.9)
    ax.set_xlim(0, 600)
    ax.invert_yaxis()

axes[0].set_ylabel('Escenario atmosférico', fontsize=10)
fig.suptitle('¿Encaja alguna atmósfera con lo observado?',
             fontsize=14, fontweight='bold', y=1.03)
fig.text(0.5, 0.99, 'Barras verdes = modelos climáticos (día oscuro / noche claro). Bandas verticales = medición JWST',
         fontsize=10, color='#666666', ha='center')

plt.tight_layout()
fig.text(0.13, -0.03, FUENTE, fontsize=7.5, color='#999999', style='italic')
plt.savefig('figuras/temperaturas_vs_modelos.png', dpi=200, bbox_inches='tight')
plt.show()
../../_images/1a42ae07b1c900689fd63cf266f3f6da747d8a9313c19fc94f3b034b7c88620e.png

Para TRAPPIST-1 b, ningún modelo atmosférico explica simultáneamente el lado diurno tan caliente (490 K) y el nocturno tan frío. Eso es exactamente lo que esperamos de una roca desnuda: absorbe todo el calor de un lado y lo reirradia, sin viento ni convección que lo redistribuya.

TRAPPIST-1 c es más sutil. Los datos encajan tanto con una superficie reflectante sin aire como con una atmósfera tenue rica en oxígeno (O₂ a ~0,1 bar). Con estos datos, no hay forma de distinguirlos — toca esperar más observaciones.

¿Qué tan precisa es la medición? Cada equipo corrió cadenas MCMC con >100.000 muestras. Veamos la distribución del flujo diurno.

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

for ax, data, planet, color, paper_val in [
    (axes[0], mcmc_b, 'b', COLOR_B, 839.51),
    (axes[1], mcmc_c, 'c', COLOR_C, 392.19)
]:
    ax.bar(data['bin_center'], data['count'], width=np.diff(data['bin_center'].values[:2])[0] if len(data) > 1 else 10,
           color=color, alpha=0.5, edgecolor=color, linewidth=0.5)

    # Median and 68% interval
    # Reconstruct samples from histogram
    samples = np.repeat(data['bin_center'].values, data['count'].values.astype(int))
    med = np.median(samples)
    q16 = np.percentile(samples, 16)
    q84 = np.percentile(samples, 84)

    ax.axvline(med, color=color, linewidth=2, label=f'Mediana: {med:.0f} ppm')
    ax.axvspan(q16, q84, alpha=0.15, color=color,
               label=f'68%: [{q16:.0f}, {q84:.0f}] ppm')

    ax.set_xlabel(f'F$_{{p}}$/F$_{{*}}$ diurno (ppm)', fontsize=10)
    ax.set_ylabel('Frecuencia', fontsize=10)
    ax.set_title(f'TRAPPIST-1 {planet}', fontsize=13, fontweight='bold',
                 pad=15, color=color)
    ax.legend(fontsize=9, framealpha=0.9)
    ax.text(0.98, 0.95, f'MG Analysis #1\n120.000 muestras MCMC',
            transform=ax.transAxes, fontsize=8, color='#888888',
            ha='right', va='top')

fig.suptitle('Posterior MCMC del flujo diurno',
             fontsize=14, fontweight='bold', y=1.02)
fig.text(0.5, 0.98, 'Cuatro equipos analizaron los mismos datos — los resultados coinciden dentro de 1σ',
         fontsize=10, color='#666666', ha='center')

plt.tight_layout()
fig.text(0.13, -0.03, FUENTE, fontsize=7.5, color='#999999', style='italic')
plt.savefig('figuras/mcmc_posteriors.png', dpi=200, bbox_inches='tight')
plt.show()
../../_images/71ed10c5f41ba4fcb3417978b9694ef916e1e6832e4402799535d34c16df8b68.png

¿Qué tan caliente es «demasiado caliente para tener atmósfera»?#

Si hubiera atmósfera densa, las noches serían mucho más templadas. Veamos dónde caen las observaciones.

fig, ax = plt.subplots(figsize=(10, 5))

# Night temperatures from different atmospheric models (SM Table 6)
models_night_b = clima[clima['planet'] == 'b']['T_night'].values
models_night_c = clima[clima['planet'] == 'c']['T_night'].values
all_model_nights = np.concatenate([models_night_b, models_night_c])

# Histogram of model predictions
n, bins, patches = ax.hist(all_model_nights, bins=15, color=COLOR_MODELO, alpha=0.4,
                           edgecolor=COLOR_MODELO, linewidth=0.8,
                           label='Predicciones con atmósfera (12 modelos)')

y_max = n.max() * 1.3
ax.set_ylim(0, y_max)

# Bare rock prediction (T_night ≈ 0 for tidally locked)
ax.axvline(x=0, color='#444444', linewidth=2.5, linestyle='-',
           label='Roca desnuda (sin redistribución)')
ax.annotate('Roca\ndesnuda', xy=(0, y_max*0.85), xytext=(30, y_max*0.85),
            fontsize=10, fontweight='bold', color='#444444',
            arrowprops=dict(arrowstyle='->', color='#444444', lw=1.5))

# Observed night temperatures
ax.axvline(x=T_NIGHT_B_MAX, color=COLOR_B, linewidth=2.5, linestyle='--',
           label=f'Noche observada b: ~{T_NIGHT_B_MAX} K')
ax.axvline(x=T_NIGHT_C, color=COLOR_C, linewidth=2.5, linestyle='--',
           label=f'Noche observada c: ~{T_NIGHT_C} K')

# Arrow showing how far observed is from atmosphere predictions
median_model = np.median(all_model_nights)
ax.annotate('', xy=(T_NIGHT_B_MAX, y_max*0.55), xytext=(median_model, y_max*0.55),
            arrowprops=dict(arrowstyle='<->', color='#666666', lw=1.5))
ax.text((T_NIGHT_B_MAX + median_model)/2, y_max*0.60,
        f'Gap: {median_model - T_NIGHT_B_MAX:.0f} K',
        fontsize=10, color='#666666', ha='center', fontweight='bold')

ax.set_xlabel('Temperatura nocturna (K)', fontsize=11)
ax.set_ylabel('N.° de modelos', fontsize=11)
ax.set_title('¿Dónde cae la noche observada?',
             fontsize=14, fontweight='bold', pad=20)
ax.text(0.5, 1.02, 'Si hubiera atmósfera densa, la noche sería más templada',
        transform=ax.transAxes, fontsize=10, color='#666666', ha='center')
ax.legend(fontsize=9, loc='upper right', framealpha=0.9)

fig.text(0.13, -0.03, FUENTE, fontsize=7.5, color='#999999', style='italic')
plt.savefig('figuras/anomalia_nocturna.png', dpi=200, bbox_inches='tight')
plt.show()
../../_images/5d49d66876b8bf57088a1978d1df37619af5b75a33ca07201c84d09026790da3.png

Lo que los datos soportan#

Afirmación

¿Soportada?

Detalle

TRAPPIST-1 b tiene T diurna de ~490 K

490 ± 17 K (abstract). Rango entre análisis: 472–508 K. Todos consistentes dentro de 2σ

TRAPPIST-1 b no tiene emisión nocturna significativa

ZH: T_night = 220–243 K con error grande (±35–40 K). ED: T_night ≤ 217 K (uno consistente con 0 K)

TRAPPIST-1 c tiene T diurna de ~369 K

369 ± 23 K (abstract). Rango entre análisis: 349–388 K

Presiones >1 bar descartadas para ambos

Modelos con 10 bar predicen T_night = 300–340 K. Observado: ≤243 K (b), ~266 K (c). Gap de ~34 K (c) a ~97 K (b)

TRAPPIST-1 c podría tener atmósfera tenue de O₂

⚠️

«Consistent with either a tenuous, oxygen-rich atmosphere or an equally airless surface». No distinguible con estos datos

Los planetas perdieron sus atmósferas por la radiación estelar

⚠️

El paper dice «suggest divergent evolutionary pathways» — es hipótesis, no medición directa. Los datos muestran ausencia de atmósfera, no la causa

Limitaciones: (1) Los análisis asumen rotación síncrona (siempre la misma cara hacia la estrella). (2) La conversión flujo → temperatura depende de la emissividad asumida. (3) Una atmósfera extremadamente tenue (<0,1 bar) no es descartable para ninguno de los dos planetas. (4) Los datos son de un solo filtro (15 μm) — más longitudes de onda refinarían las restricciones.

Ahora tú#

Tres ideas para explorar:

  1. ¿Qué pasa si cambias la emissividad? La temperatura de brillo depende de cuánto emite la superficie. Prueba recalcular con emissividad 0.9 vs 0.5. Pista: T_bright = T_observado * emissividad^(-0.25).

  2. ¿Cuánta atmósfera toleran los datos? El modelo con O₂ a 0,1 bar tiene σ = 1,4 para el planeta b. ¿A qué presión el σ sube a 3 (descartado a 3σ)?

  3. ¿Cómo se compara con Mercurio? Mercurio también carece de atmósfera y tiene T_day ≈ 700 K, T_night ≈ 100 K. ¿Es proporcional al que recibiría si orbitara TRAPPIST-1 en vez del Sol?

# --- EXPERIMENTA AQUÍ ---
# ¿Cuánto redistribuiría el calor una atmósfera?
# El factor de redistribución f va de 1/4 (redistribución total) a 2/3 (cero redistribución)

import numpy as np

T_star = T_STAR  # K
a_b = 0.01153    # semi-eje mayor planeta b (AU)
a_c = 0.01580    # semi-eje mayor planeta c (AU)
R_star = 0.1192  # Radio estelar (R_sol)

AU_to_Rsol = 215.03  # 1 AU en radios solares

print("Temperatura de equilibrio según redistribución de calor:")
print(f"{'f':>6} {'T_eq b (K)':>12} {'T_eq c (K)':>12} {'Interpretación'}")
print("-" * 65)
for f, desc in [(2/3, 'Sin redistribución (roca)'),
                (1/2, 'Solo hemisferio día'),
                (1/4, 'Redistribución total (atmósfera)')]:
    R_star_AU = R_star / AU_to_Rsol  # Convertir R_star a AU
    T_eq_b = T_star * np.sqrt(R_star_AU / (2 * a_b)) * f**0.25
    T_eq_c = T_star * np.sqrt(R_star_AU / (2 * a_c)) * f**0.25
    print(f"{f:>6.3f} {T_eq_b:>12.0f} {T_eq_c:>12.0f} {desc}")

print(f"\nObservado: T_day b = {T_DAY_B} K, T_day c = {T_DAY_C} K")
print("→ Compara con la fila f = 2/3 (sin redistribución): más cercano = más parecido a roca")
Temperatura de equilibrio según redistribución de calor:
     f   T_eq b (K)   T_eq c (K) Interpretación
-----------------------------------------------------------------
 0.667          359          307 Sin redistribución (roca)
 0.500          335          286 Solo hemisferio día
 0.250          281          240 Redistribución total (atmósfera)

Observado: T_day b = 490 K, T_day c = 369 K
→ Compara con la fila f = 2/3 (sin redistribución): más cercano = más parecido a roca

Datos: JWST MIRI F1500W, programas GO 3077, GO 1177, GO 2304. Disponibles en MAST. Source Data y Supplementary Materials de Nature Astronomy.

Paper: Ducrot, E., Gillon, M., Demory, B.-O. et al. No thick atmosphere around TRAPPIST-1 b and c from JWST thermal phase curves. Nature Astronomy (2026). DOI: 10.1038/s41550-026-02806-9

Licencia datos: Los datos supplementary de Nature están bajo los términos de acceso de Springer Nature.

Notebook: Ciencia a Mordiscos — El Lab | GitHub