Jorge Martinez

Aerospace Engineer and Senior Software Developer


Calendario Lunar con Python

Jorge Martínez Garrido

May 7, 2022

python skyfield jinja2


En el presente post vamos a aprender como generar un calendario lunar. Un calendario lunar nos muestra las fases de la Luna a lo largo del año. El Calendario funciona de la siguiente forma:

  1. Indicamos el año para el cual deseamos generar el calendario.
  2. El programa utiliza una plantilla HTML y rellena cada día de cada mes del año con una imagen de la fase lunar correspondiente.

Atacando el problema

Para resolver el problema, lo mejor es dividirlo en otros problemas más pequeños. Así pues, podemos identificar los sguientes sub-problemas:

La imagen matriz que utilizaremos es la siguiente. Puedes descargarla haciendo “click” derecho y guardándola en tu máquina:

Imagen matriz de fases lunares

Calculando la fase lunar y la iluminación del disco

Vamos a resolver el primero de los problemas: ¿cuál es la fase lunar para una fecha deseada? Las fases lunares se producen por la sombra que genera nuestro planeta Tierra sobre su satélite natural, la Luna, la cual se encuentra iluminada por el Sol.

La fase lunar depende de la posición de los tres cuerpos anteriores, es decir, necesitamos conocer la geometría del sistema Sol-Luna-Tierra para resolver la fase lunar. El problema es complejo pero por suerte, ¡Python al rescate!

Vamos a utilizar la biblioteca skyfield, cuyo autor es Brandon Rhodes. Esta librería nos permitirá:

Para simplificar los cálculos, vamos a utilizar las efemérides de420.bsp. Bastará con indicar su nombre en skyfield para que esta biblioteca descargue el archivo oficial:

from skyfield import almanac, api
from skyfield.api import utc


def calcular_fase_lunar(fecha):
    """Calcula la fase lunar para una fecha deseada.
    
    Parameters
    ----------
    fecha : ~datetime.datetime
        Fecha como objeto `datetime`.
        
    Returns
    -------
    fase_lunar : float
        Fase lunar en radianes.
        
    Notas
    -----
    La fase lunar es un ángulo, siendo su valor 0 [rad] 
    para la Luna nueva y pi [rad] para Luna llena.
        
    """
    # Cargamos la escala temporal y la tabla de efemérides
    tabla_tiempos = api.load.timescale()
    tabla_efemerides = api.load("de421.bsp")

    # Convertimos la fecha datetime a un datetime que entiende skyfield
    fecha_skyfield = tabla_tiempos.from_datetime(fecha.replace(tzinfo=utc))

    # Calculamos la fase lunar en radianes. Nos aseguramos de que sea un `float`
    fase_lunar = float(almanac.moon_phase(tabla_efemerides, fecha_skyfield).radians)
    return fase_lunar

Para calcular la iluminación del disco, es decir, el porcentaje de la superficie iluminada de la Luna aplicaremos la fórmula 5.2.3 del popular Fundamentals of Astrodynamics, 4th Edition by David A. Vallado:

import numpy as np


def calcular_iluminacion_disco(fecha):
    """Calcula el porcentaje de iluminación del disco lunar para una fecha.
    
    Parameters
    ----------
    fecha : ~datetime.datetime
        Fecha como objecto `datetime`.
        
    Returns
    -------
    iluminacion_disco : float
        Porcenatje de iluminación del disco lunar.
        
    Notas
    -----
    Para la Luna nueva, el valor de iluminación es de 0 unidades mientras
    que para Luna llena es de 100 unidades.
        
    """
    # Calculamos la fase lunar
    fase_lunar = calcular_fase_lunar(fecha)

    # Aplicamos la fórmula tomada del libro de Vallado
    iluminacion_disco = 100 * (1 - np.cos(fase_lunar)) / 2
    return iluminacion_disco

Vamos a comprobar si las rutinas anteriores funcionan correctamente. Para ello, veamos que datos arrojan para la fecha del 16 de Abril del 2022, cuando hubo Luna llena:

from datetime import datetime


# Declaramos la fecha y calculamos la fase e iluminación
fecha = datetime(2022, 4, 16)
fase_lunar, iluminacion_disco = calcular_fase_lunar(fecha), calcular_iluminacion_disco(fecha)

# Mostramos los resultados
print(f"Para el {fecha}, las propiedades lunares fueron:")
print(f"Fase Lunar = {np.rad2deg(fase_lunar):.2f} [deg]\nIlum. Disco = {iluminacion_disco:.2f} %")
Para el 2022-04-16 00:00:00, las propiedades lunares fueron:
Fase Lunar = 169.66 [deg]
Ilum. Disco = 99.19 %

Calculando los días de un mes para un año determinado

Para calcular el número de días que tiene un mes para una año determinado, podemos emplear el módulo calendar. En concreto, vamos a utilizar la función monthrange, que devuelve el número de días de un mes y el día de la semana con el que empieza el mes.

Por ejemplo, si le pedimos que resuelva cuantos días tiene Mayo de 2022, nos devolverá (7, 31), ya que este mes tiene 31 días y en el caso de 2022 el día 1 del mes resulta ser un domingo (séptimo día de la semana).

from calendar import monthrange


def calcular_dias_en_mes(mes, año):
    """Calcula el número de un mes en un año determinado.

    Parameters
    ----------
    mes : int
        Número del mes.
    año : int
        Número del año.
        
    Returns
    -------
    dias : int
        Número de días en el mes.

    """
    _, dias = monthrange(año, mes)
    return dias

Volvemos a comprobar el código; lo haremos con un año bisiesto como 2024 y para el mes de Febrero:

# Calculamos el número de días de Febrero, 2024
dias_en_febrero_2024 = calcular_dias_en_mes(2, 2024)
print(f"Febrero del año 2024 tiene {dias_en_febrero_2024} días.")
Febrero del año 2024 tiene 29 días.

Calculando las propiedades lunares para todo un año

A continuación, vamos a calcular la fase lunar y la iluminación para cada día de un año. Para ello, podemos generar la siguiente función:

def calcular_propiedades_lunares(año):
    """Calcula la fase e iluminación para cada día de un año deseado.
    
    Parameters
    ----------
    año : int
        Año deseado.
        
    Yields
    ------
    propiedades : list
        Fecha completa, fase e iluminación.
    
    """
    # Todos los años tienen 12 meses
    for mes in range(1, 12+1):
        for dia in range(1, calcular_dias_en_mes(mes, año)+1):
            # Ensamblamos la fecha y calculamos las propiedades
            fecha = datetime(año, mes, dia)
            fase_lunar, iluminacion_disco = calcular_fase_lunar(fecha), calcular_iluminacion_disco(fecha)
            yield fecha, fase_lunar, iluminacion_disco

Podemos comprobar el funcionamiento del código anterior aplicándolo al presente año. Mostramos solo las tres primeras fechas para no llenar la pantalla de datos.

# Guardamos el generador y consumimos los primeros tres elementos para no alargar el post
propiedades_lunares_2022 = calcular_propiedades_lunares(2022)
for _ in range(3):
    fecha, fase_lunar, iluminacion_disco = next(propiedades_lunares_2022)
    print(
        f"Fecha = {fecha:%Y-%m-%d}  Fase Lunar = {np.rad2deg(fase_lunar):6.2f} [deg]  Ilum.Disco = {iluminacion_disco:.2f} %"
    )
Fecha = 2022-01-01  Fase Lunar = 334.95 [deg]  Ilum.Disco = 4.70 %
Fecha = 2022-01-02  Fase Lunar = 349.06 [deg]  Ilum.Disco = 0.91 %
Fecha = 2022-01-03  Fase Lunar =   3.20 [deg]  Ilum.Disco = 0.08 %

Calculando la imagen de la fase lunar

Para obtener la imagen de la fase lunar vamos a utilizar la matriz de fases lunares. Puedes encontrarla aquí o bien al principio del post.

La idea es conseguir romper la imagen anterior en pequeñas porciones que podamos manejar luego. Para poder trabajar con imágenes en Python, lo más común es utilizar la biblioteca Pillow, la cual nos proveé de un objecto llamado Image con el que podremos manejar las imágenes.

Sabiendo que el ciclo lunar dura un total de 28 días, podemos asociar cada fase lunar con una de las 28 sub-imágenes de la matriz.

from PIL import Image


def generar_imagenes_fases_lunares():
    """Genera todas las imágenes para cada fase lunar a partir del imagen matriz."""
    
    with Image.open("img/moon_phases.png") as imagen_matriz:
        
        # Calculamos las dimensiones de cada sub-imagen
        ancho, alto = imagen_matriz.size
        delta_ancho, delta_alto = ancho / 7, alto / 4

        # Iteramos para cada fase lunar de la matriz 
        dia = 0
        for n_fila in range(4):
            for n_columna in range(7):
                
                # Buscamos la esquina superior izquierda de cada sub-imagen
                x0, y0 = delta_ancho * n_columna, delta_alto * n_fila
                
                # La posición final del recorte es el inicio de la siguiente sub-imagen
                ancho_recorte = delta_ancho * (n_columna + 1)
                alto_recorte = delta_alto * (n_fila + 1)
                
                # Recortamos la región rectangular deseada y la guardamos como una imagen nueva.
                # Es importante guardar el dia de vida en el nombre del archivo.
                dimension_recorte = (x0, y0, ancho_recorte, alto_recorte)
                imagen_recorte = imagen_matriz.crop(dimension_recorte)
                imagen_recorte.save(f"img/luna_{dia}.png")
                
                # Incrementamos el contador de dias de vida de la Luna
                dia += 1
                
def calcular_imagen_fase_lunar(fase_lunar):
    """Devuelva la ruta de la imagen para la fase lunar deseada.
    
    Parameters
    ----------
    fase_lunar : float
        Fase lunar en radianes.
        
    Returns
    -------
    ruta_imagen_fase_lunar : str
        Ruta a la imagen de la fase lunar.
    
    """

    # Dado que la imagen matriz de fases lunares no comienza con una Luna nueva
    # aplicamos una corrección para acomodar la fase lunar con la sub-imagen que
    # le corresponde.
    dias_vida_luna = int(np.round(fase_lunar / (2 * np.pi) * 27 + 14, 0)) % 27
    ruta_imagen_fase_lunar = f"img/luna_{dias_vida_luna}.png"
    return ruta_imagen_fase_lunar

Al igual que hemos hecho antes, vamos a comprobar si obtenemos la imagen correcta. Utilizaremos la misma fecha para cuando hubo Luna llena en Abril de 2022:

# Declaramos la fecha y calculamos la fase e iluminación
fecha = datetime(2022, 4, 16)
fase_lunar = calcular_fase_lunar(fecha)

# Generamos todas las sub-imágenes y resolvemos la ruta de la imagen
generar_imagenes_fases_lunares()
ruta_imagen_fase_lunar = calcular_imagen_fase_lunar(fase_lunar)
print(ruta_imagen_fase_lunar)

# Mostramos la imagen con Pillow
pil_im = Image.open(ruta_imagen_fase_lunar, 'r')
pil_im
img/luna_0.png

Efectivamente, vemos que para el 2022-04-16 hubo Luna llena.

Creando la plantilla HTML del calendario

Ahora que ya tenemos implementada toda la lógica, es hora de pasar a trabajar con la plantilla HTML. Para ello, vamos a utilizar Jinja2. La syntaxis puedes repasarla en la página web. Para no alargar el post, te dejo la plantilla a continuación:

<!DOCTYPE html>
<html>
  <head>
    <body>
      <h1 align="center">Calendario Lunar {{ anyo }}</h1>
      <div align="center">
        <table style="text-align: center;">

        <!-- Creamos la cabecera de la tabla -->
        <tr>
          <!-- Cabecera de los días -->
        <th>Day</th>
          <!-- Cabecera del mes -->
          {% for nombre_mes in lista_meses %}
            <th style="font-weight: bold; padding: 30px;">{{ nombre_mes[:3] }}</th>
          {% endfor %}
        </tr>

        <!-- Resolvemos la fase lunar y la imagen para cada fecha del año -->
        {% for dia in range(1, 32) %}
          <tr>
            <!-- Escribimos el numero del día en la columna de los días -->
            <td style="margin-right: 10px;">{{ dia }}</td>

            <!-- Calculamos la fase lunar para cada mes ese día -->
            {% for mes in range(1, 13) %}
                {% set dias_en_mes = calcular_dias_en_mes(mes, anyo) %}
                <!-- Rellenamos solo si el día existe para ese mes -->
                {% if dia < dias_en_mes + 1 %}
                  {% set fecha = datetime(anyo, mes, dia) %}
                  {% set fase_lunar = calcular_fase_lunar(fecha) %}
                  {% set imagen_fase_lunar = calcular_imagen_fase_lunar(fase_lunar) %}
                  <td style="padding: 5px;">
                        <img src="{{ imagen_fase_lunar }}">
                  </td>
                {% else %}
                  <!-- Si el mes tiene menos días no rellenamos nada -->
                  <td></td>
                {% endif %}

             {% endfor %}
           </tr>
         {% endfor %}

      </table>
    </div>
  </body>
</html>

Renderizando la plantilla

Ya casi tenemos listo nuestro calendario. Solo nos queda generar el código para renderizar la plantilla con las variables deseadas:

import calendar
from datetime import datetime
import os
from pathlib import Path

from jinja2 import Template


MESES_DEL_ANYO = calendar.month_name[1:]
"""Lista con los nombres de todos los meses del año."""


def renderizar_calendario(anyo):
    """Renderiza la plantilla del calendario para el año deseado.
    
    Parameters
    ----------
    anyo : int
        Año deseado del calendario lunar.

    """

    # Make sure the year is integer and positive
    if anyo < 0:
        raise ValueError("Al año debe de ser positivo!")

    # Collect all variables
    variables = dict(
        anyo=anyo,
        lista_meses=MESES_DEL_ANYO,
        calcular_dias_en_mes=calcular_dias_en_mes,
        calcular_fase_lunar=calcular_fase_lunar,
        calcular_imagen_fase_lunar=calcular_imagen_fase_lunar,
        datetime=datetime,
    )

    # Open the template
    with open("res/plantilla_calendario.html", "r") as archivo_plantilla_calendario:

        # Render the template
        plantilla_calendario = archivo_plantilla_calendario.read()
        calendario_renderizado = Template(plantilla_calendario).render(variables)

        # Write the baked template to a new file
        with open(f"calendario_{anyo}.html", "w") as archivo_calendario:
            for linea in calendario_renderizado:
                archivo_calendario.write(linea)

Finalmente, renderizamos el calendario para el presente año. Este proceso tarda un poco…

# Calculamos el año actual y generamos el calendario
anyo = datetime.now().year
renderizar_calendario(anyo)

Resultado

Puedes ver el calendario generado en este enlace: