Calendario Lunar con Python
Jorge Martínez Garrido
May 7, 2022
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:
- Indicamos el año para el cual deseamos generar el calendario.
- 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:
- Obtención de la fase lunar para una fecha concreta del año.
- Obtención de la fecha (año-mes-día) para todos los días del año. Recordemos que Febrero no siempre tiene el mismo número de días, ya que depende de si el año es bisiesto o no.
- Generación de una plantilla HTML para renderizar el calendario.
- Extraer la imagen de la fase desde una matriz de imagenes para obtener la fase lunar deseada.
La imagen matriz que utilizaremos es la siguiente. Puedes descargarla haciendo “click” derecho y guardándola en tu máquina:

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á:
- Manejar de forma sencilla las efemérides (tablas de posiciones de los astros).
- Calcular la fase lunar para una fecha y efemérides deseadas.
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: