
Chisme del Lince AstroPi12: la checklist de vuelo de tu programa Astro Pi
El Lince de la Noche, Víctor Vílchez para su clase, lleva tres semanas con su programa y por fin está listo. Prepara el zip con el cariño de quien envía una carta al espacio: main.py, los datos, las fotos, un vídeo de despedida, un run.sh «por si acaso» y una copia de seguridad llamada «Mi foto final (copia).jpeg».
—¿Todo eso va a la ISS? —pregunta la Gata Montesa, sin levantar la vista de sus apuntes de astrofísica.
—Todo. Por si acaso.
—Descalificado. Cuatro veces, y sin salir de la Tierra.
El Lince se pone en pie con la mirada del que ha visto una solución espectacular: construirá un robot inspector con visión artificial que revise cada archivo.
—O un script que lea tu programa y te marque lo que incumple —responde la Gata—. Los pilotos no inspeccionan el avión con un robot: usan una lista.
Introducción
Hasta ahora hemos aprendido a construir el programa (AstroPi11), a medir con él y a probarlo en el Astro Pi Replay Tool. Hoy toca lo último antes de enviarlo: pasar una checklist Astro Pi, la lista de comprobación del reglamento de Mission Space Lab.
Vamos a repasar esta checklist Astro Pi con un simulador de 5 pasos y a construir un pequeño comprobador en Python, checklist.py, que lee tu main.py y marca cada norma con ✅, ⚠️ o ❌.
Por qué este chisme
Las directrices de 2026/27 lo dicen sin rodeos: los programas que incumplen la lista se descartan. No importa lo bueno que sea el experimento si el zip lleva un archivo de un tipo no admitido o el programa espera a que un astronauta pulse una tecla.
Además, la ISS no se puede arreglar a distancia. Todo lo que no compruebes antes del 22 de febrero de 2027 a las 12:00 (hora de Madrid, CET) ya no tendrá remedio. La checklist Astro Pi es barata y evita el peor error: perder el vuelo por un detalle.
Las normas de la checklist Astro Pi que sí se pueden revisar
Estas son las normas principales de las directrices de Mission Space Lab 2026/27. Compruébalas siempre en la página oficial, porque pueden cambiar:
- El zip pesa 3 MB como máximo y lleva un
main.pycomo punto de entrada. - Dentro del zip, además de
main.py, solo hay archivos.py .main .csv .txt .jpg .png .yuv .json .toml .yaml. Los nombres usan solo letras, números, punto, guion y guion bajo. - El programa está en Python 3.13, se ejecuta con
python3 main.pyy usa solo las librerías de la creator guide: Skyfield, astro_pi_orbit, picamzero, GPIO Zero, NumPy, SciPy, pandas, logzero, Matplotlib, Pillow, OpenCV, exif, scikit-learn, scikit-image, reverse-geocoder, sense_hat y ai_edge_rt. - Usa al menos un sensor de la Sense HAT o la cámara, y guarda datos o al menos una imagen. Apuntar solo la posición de la ISS con
skyfieldoastro_pi_orbitno cuenta: esa posición sale de una tabla de predicciones, no de un sensor. - No usa la matriz de LEDs de la Sense HAT (la creator guide lo prohíbe expresamente) y no necesita que nadie toque nada.
- Termina solo antes de 10 minutos, sin intervención de ningún astronauta.
- Al final de la ejecución quedan como mucho 42 imágenes (durante la ejecución pueden guardarse más) y no más de 250 MB de datos.
- Nada de red ni de procesos: no se lanzan programas ni comandos del terminal (por ejemplo,
vcgencmd). - Solo se guarda junto a
main.py, con__file__, sin crear carpetas ni usar rutas absolutas, y en archivos de salida permitidos. - No hay excepciones sin controlar y todo el envío está en inglés.
- El código se entiende, sin ocultar nada, y se atribuye lo que no es tuyo: las directrices aconsejan un archivo de licencia que cite el código reutilizado. Nosotros usamos
LICENSE.txt, porque.txtes una de las extensiones admitidas en el zip. - Si una IA te ayudó, lo explicas con comentarios junto a esas partes y en el formulario de envío.
- Buenas prácticas de la guía: crear
Camera()una sola vez (no dentro de un bucle), guardar cada dato en cuanto se mide y no al final, usar todo el tiempo disponible (no dejar el tiempo de prueba de 1 minuto) y dejar unsleepde al menos 1 segundo entre fotos si luego se calcula la velocidad de la ISS.
El simulador paso a paso
Antes de tocar el código, practica la checklist Astro Pi con el simulador del AstroPi12. Cada paso tiene un «Haz esto» y un «Fíjate»:
- El zip: marca qué archivos pueden ir dentro y cuáles no.
- Lo prohibido: caza ocho líneas de un programa que incumple el reglamento.
- El tiempo: mueve los ajustes y mira cuántas fotos salen.
- El comprobador: pasa
checklist.pypor un programa roto y por uno arreglado. - La prueba de vuelo: recorre los seis pasos del Replay Tool.

Diez minutos y 42 fotos
El paso 3 esconde una sorpresa. En el AstroPi11 sacábamos una foto cada 12 segundos. Si el programa dura 10 minutos con 10 segundos de margen, son 590 segundos útiles. Cada vuelta dura 12 s de pausa más unos 2 s de foto. Lo medimos en la Raspberry Pi con la cámara NoIR (picamzero, cinco fotos seguidas): entre 1,96 y 2,02 s cada una. Con otra cámara o con otra resolución el valor puede cambiar, así que mídelo en tu equipo.
Salen 590 / 14 = 42,1 vueltas, es decir, 43 fotos. Lo que nos salva es MAX_PHOTOS = 42. Es mejor que el cálculo salga bien por sí solo, por ejemplo con una foto cada 15 segundos.

Fíjate también en esta línea, que usamos en todos los programas de la serie:
sleep(max(0, min(PHOTO_EVERY, left))) # never sleep past the end
Sin el min, la última pausa podría empujar el programa más allá de los 10 minutos.
El comprobador: checklist.py
checklist.py lee tu programa con el módulo ast de Python: lo convierte en un árbol de instrucciones y busca llamadas, importaciones y cadenas de texto. No lo ejecuta, y por eso se puede pasar sin cámara ni Sense HAT, incluso en un ordenador cualquiera.
Comprueba, entre otras cosas, estas normas:
- el zip:
main.pyen la raíz, tamaño, tipos y nombres de archivo; - red, procesos y comandos del terminal;
input(), ventanas y joystick, que necesitan a un astronauta;- carpetas nuevas, rutas absolutas y uso de
__file__; - tipos de archivo de salida;
- control del tiempo, bucles sin fin y número de fotos;
try / except, comentarios en inglés y atribución del código reutilizado.
Guárdalo como checklist.py junto a tu main.py. Para ahorrarte trabajo, descarga el zip con el comprobador, los dos programas de ejemplo y la sonda.
# Chisme del Lince AstroPi12 - Flight checklist for a Mission Space Lab program
#
# What it does: it READS your program (it never runs it) and checks the rules of the
# Mission Space Lab 2026/27 guidelines that a computer can check.
# What it cannot do: tell you if the program really works. For that, use the Replay Tool.
#
# How to use it (in the same folder as your main.py, or giving a folder / zip):
# python3 checklist.py
# python3 checklist.py my_team_folder
# python3 checklist.py my_team.zip
# Add --ascii if your screen does not show the symbols.
#
# Rules taken from https://astro-pi.org/mission-space-lab/guidelines (2026/27).
# Everything marked "CHECK" is something that this script cannot be sure about.
import ast
import io
import re
import sys
import tokenize
import zipfile
from pathlib import Path
# ---------------------------------------------------------------------
# THE RULES (change them here if the guidelines change)
# ---------------------------------------------------------------------
MAX_ZIP_BYTES = 3_000_000 # "no more than 3MB": we use the safe value
MAX_IMAGES = 42 # images kept at the end of the run
MAX_MINUTES = 10 # the program must stop before this
ALLOWED_IN_ZIP = {'.py', '.main', '.csv', '.txt', '.jpg', '.png',
'.yuv', '.json', '.toml', '.yaml'}
ALLOWED_OUTPUT = {'.csv', '.txt', '.log', '.jpg', '.png', '.raw', '.yuv',
'.h264', '.json', '.toml', '.yaml'}
FILE_NAME = re.compile(r'^[A-Za-z0-9._-]+$')
# Modules that start processes or use the network: not allowed.
FORBIDDEN_MODULES = {
'subprocess', 'socket', 'ssl', 'http', 'urllib', 'ftplib', 'smtplib',
'poplib', 'imaplib', 'telnetlib', 'xmlrpc', 'socketserver', 'webbrowser',
'multiprocessing', 'pty', 'requests', 'urllib3', 'httpx', 'aiohttp',
'paramiko', 'pexpect', 'bluetooth', 'serial',
}
# Official libraries from the Mission Space Lab creator guide (2026/27):
# Skyfield, astro_pi_orbit, picamzero, GPIO Zero, NumPy, SciPy, pandas, logzero,
# Matplotlib, Pillow, OpenCV, exif, scikit-learn, scikit-image,
# reverse-geocoder, sense_hat, ai_edge_rt. ('orbit' kept for old examples.)
KNOWN_LIBRARIES = {'sense_hat', 'picamzero', 'astro_pi_orbit', 'orbit',
'numpy', 'cv2', 'logzero', 'exif', 'skyfield', 'gpiozero',
'scipy', 'pandas', 'matplotlib', 'PIL', 'sklearn', 'skimage',
'reverse_geocoder', 'ai_edge_rt'}
SENSOR_OR_CAMERA = {'sense_hat', 'picamzero'}
LED_MATRIX_CALLS = {'set_pixels', 'set_pixel', 'show_message', 'show_letter',
'load_image', 'flip_h', 'flip_v', 'set_rotation'}
PHOTO_CALLS = {'take_photo', 'capture_sequence', 'capture_file', 'capture',
'imwrite', 'start_and_capture_file'}
SPANISH_WORDS = {'el', 'la', 'los', 'las', 'para', 'foto', 'fotos', 'tiempo',
'datos', 'camara', 'guardar', 'hacer', 'archivo', 'fichero',
'porque', 'cuando', 'esto', 'aqui', 'bucle', 'resultado'}
OK, WARN, FAIL = 'ok', 'warn', 'fail'
results = []
def report(status, rule, message):
results.append((status, rule, message))
# ---------------------------------------------------------------------
# HELPERS
# ---------------------------------------------------------------------
def dotted_name(node):
"""'os.system' for an ast.Attribute chain, 'print' for a Name, else ''."""
parts = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return '.'.join(reversed(parts))
return ''
def string_constants(tree):
"""Every string in the program (including the pieces of f-strings), no docstrings."""
docstrings = set()
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.FunctionDef, ast.ClassDef)):
body = getattr(node, 'body', [])
if body and isinstance(body[0], ast.Expr) and \
isinstance(body[0].value, ast.Constant) and \
isinstance(body[0].value.value, str):
docstrings.add(id(body[0].value))
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str) \
and id(node) not in docstrings:
yield node.lineno, node.value
def comment_text(source):
"""All comments and docstrings, to look at the language."""
text = []
try:
for token in tokenize.generate_tokens(io.StringIO(source).readline):
if token.type == tokenize.COMMENT:
text.append(token.string.lstrip('# '))
except (tokenize.TokenError, IndentationError):
pass
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, (ast.Module, ast.FunctionDef, ast.ClassDef)):
doc = ast.get_docstring(node)
if doc:
text.append(doc)
return text
# ---------------------------------------------------------------------
# CHECKS ON THE FILES (the zip)
# ---------------------------------------------------------------------
def load_files(target):
"""Returns {relative_path: bytes} from a folder or a zip file."""
files = {}
if target.is_file() and target.suffix.lower() == '.zip':
with zipfile.ZipFile(target) as z:
for info in z.infolist():
if not info.is_dir():
files[info.filename] = z.read(info)
return files, target.stat().st_size
for path in sorted(target.rglob('*')):
if path.is_file() and path.name != 'checklist.py':
files[str(path.relative_to(target)).replace('\\', '/')] = path.read_bytes()
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as z:
for name, data in files.items():
z.writestr(name, data)
return files, len(buffer.getvalue())
def check_files(files, zip_size):
if 'main.py' in files:
report(OK, 'main.py', 'Hay un main.py en la raíz.')
else:
report(FAIL, 'main.py', 'No hay main.py en la raíz de la carpeta o del zip.')
mb = zip_size / 1_000_000
if zip_size <= MAX_ZIP_BYTES:
report(OK, 'Tamaño', f'El zip pesaría {mb:.2f} MB (máximo 3 MB).')
else:
report(FAIL, 'Tamaño', f'El zip pesa {mb:.2f} MB y el máximo es 3 MB.')
bad_type, bad_name = [], []
for name in files:
for part in name.split('/'):
if not FILE_NAME.match(part):
bad_name.append(name)
break
suffix = Path(name).suffix
if suffix not in ALLOWED_IN_ZIP:
bad_type.append(name)
if bad_type:
report(FAIL, 'Tipos de archivo',
'Tipos no permitidos en el zip: ' + ', '.join(bad_type[:6]) +
('...' if len(bad_type) > 6 else '') +
'. Solo: ' + ' '.join(sorted(ALLOWED_IN_ZIP)))
else:
report(OK, 'Tipos de archivo', 'Todos los archivos tienen un tipo permitido.')
if bad_name:
report(FAIL, 'Nombres de archivo',
'Solo letras, números, punto, guion y guion bajo. Revisa: ' +
', '.join(bad_name[:6]))
else:
report(OK, 'Nombres de archivo', 'Los nombres usan solo caracteres permitidos.')
images = [n for n in files if Path(n).suffix in ('.jpg', '.png')]
if len(images) > MAX_IMAGES:
report(WARN, 'Imágenes en el zip',
f'Hay {len(images)} imágenes dentro del zip. Lo que cuenta son las que '
f'quedan al final de la ejecución (máximo {MAX_IMAGES}), pero no hace falta '
'enviar tantas.')
if any(n.upper().startswith('LICENSE') for n in files):
if 'LICENSE' in files:
report(WARN, 'Licencia',
'LICENSE no tiene extensión y el zip solo admite algunas. '
'CHECK: usa LICENSE.txt, que sí es .txt.')
else:
report(OK, 'Licencia', 'Hay un archivo de licencia (.txt).')
# ---------------------------------------------------------------------
# CHECKS ON THE PROGRAM (main.py)
# ---------------------------------------------------------------------
def check_program(source):
try:
tree = ast.parse(source)
except SyntaxError as error:
report(FAIL, 'Sintaxis', f'main.py tiene un error de sintaxis en la línea {error.lineno}.')
return
report(OK, 'Sintaxis', 'main.py se puede leer como Python.')
# --- imports ----------------------------------------------------
modules = {}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
modules.setdefault(alias.name.split('.')[0], node.lineno)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
modules.setdefault(node.module.split('.')[0], node.lineno)
forbidden = sorted(m for m in modules if m in FORBIDDEN_MODULES)
if forbidden:
report(FAIL, 'Red y procesos',
'Módulos prohibidos (red o procesos): ' + ', '.join(forbidden))
unknown = sorted(m for m in modules
if m not in sys.stdlib_module_names
and m not in KNOWN_LIBRARIES and m not in FORBIDDEN_MODULES)
if unknown:
report(WARN, 'Librerías',
'No están en la lista oficial de la creator guide: ' + ', '.join(unknown))
elif modules:
report(OK, 'Librerías', 'Solo librerías estándar o de la lista oficial '
'de la creator guide.')
# --- calls ------------------------------------------------------
calls = [(dotted_name(n.func), n) for n in ast.walk(tree) if isinstance(n, ast.Call)]
names = {name for name, _ in calls}
attrs = {name.split('.')[-1] for name in names if name}
system = sorted(n for n in names if n.startswith(('os.system', 'os.popen', 'os.exec',
'os.spawn', 'os.fork', 'os.kill',
'subprocess.')))
if system:
report(FAIL, 'Terminal', 'Lanza procesos o comandos del sistema: ' + ', '.join(system))
elif not forbidden:
report(OK, 'Red y procesos', 'No veo red, procesos ni comandos del terminal.')
hidden = sorted(n for n in names if n in ('eval', 'exec', 'compile', '__import__'))
hidden += sorted(m for m in modules if m in ('base64', 'marshal', 'zlib', 'codecs'))
long_lines = [i + 1 for i, line in enumerate(source.splitlines()) if len(line) > 200]
if hidden or long_lines:
report(WARN, 'Código claro',
'El código debe ser fácil de entender y sin ocultar nada. Revisa: ' +
', '.join(hidden + [f'línea {n} muy larga' for n in long_lines[:3]]))
else:
report(OK, 'Código claro', 'No veo eval, exec ni código ofuscado.')
# --- astronaut interaction -------------------------------------
astronaut = sorted(n for n in names if n == 'input' or
n.endswith(('imshow', 'waitKey', 'plt.show', 'wait_for_event',
'get_events', 'show_and_wait')))
if astronaut:
report(FAIL, 'Sin astronauta',
'Necesita pantalla, teclado o joystick: ' + ', '.join(astronaut) +
'. En la ISS nadie puede tocar nada.')
else:
report(OK, 'Sin astronauta', 'No veo input(), ventanas ni joystick.')
# --- sensors / camera ------------------------------------------
if SENSOR_OR_CAMERA & set(modules):
report(OK, 'Sensor o cámara', 'Usa ' + ', '.join(sorted(SENSOR_OR_CAMERA & set(modules))) + '.')
else:
report(FAIL, 'Sensor o cámara',
'Debe usar un sensor de la Sense HAT o la cámara. Solo la posición de la ISS no cuenta.')
# --- files, folders, paths -------------------------------------
writes = (any(a in attrs for a in PHOTO_CALLS | {'writerow', 'writerows', 'write', 'dump'})
or any(n == 'open' for n in names))
if writes:
report(OK, 'Guarda datos', 'Parece que guarda datos o imágenes en un archivo.')
else:
report(FAIL, 'Guarda datos', 'No veo que guarde ningún dato ni imagen.')
makes_dirs = sorted(n for n in names if n.endswith(('mkdir', 'makedirs')))
if makes_dirs:
report(FAIL, 'Carpetas', 'No puede crear carpetas: ' + ', '.join(makes_dirs))
else:
report(OK, 'Carpetas', 'No crea carpetas.')
absolute = [(line, text) for line, text in string_constants(tree)
if re.match(r'^(/[A-Za-z0-9_.-]+|~/|[A-Za-z]:[\\/])', text) and ' ' not in text]
if absolute:
report(FAIL, 'Rutas',
f'Ruta absoluta en la línea {absolute[0][0]} ("{absolute[0][1][:30]}"). '
'Usa Path(__file__).parent.')
elif '__file__' in {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}:
report(OK, 'Rutas', 'Usa __file__ y no veo rutas absolutas.')
elif writes:
report(FAIL, 'Rutas', 'Guarda archivos pero no usa __file__: se perderían en otra carpeta.')
extensions = set()
for _, text in string_constants(tree):
if ' ' in text or len(text) > 80:
continue
match = re.search(r'(\.[A-Za-z][A-Za-z0-9]{1,4})$', text)
if match and match.group(1) != '.py':
extensions.add(match.group(1))
bad_ext = sorted(e for e in extensions if e not in ALLOWED_OUTPUT)
if bad_ext:
report(FAIL, 'Tipos de salida',
'Salida no permitida: ' + ', '.join(bad_ext) +
'. Solo: ' + ' '.join(sorted(ALLOWED_OUTPUT)))
elif extensions:
report(OK, 'Tipos de salida', 'Solo escribe archivos ' + ' '.join(sorted(extensions)) + '.')
# --- time ------------------------------------------------------
seconds_limit = None
for name, node in calls:
if name.endswith('timedelta'):
total = 0
for kw in node.keywords:
if isinstance(kw.value, ast.Constant) or (
isinstance(kw.value, ast.UnaryOp) and isinstance(kw.value.operand, ast.Constant)):
value = ast.literal_eval(kw.value)
total += value * {'minutes': 60, 'seconds': 1, 'hours': 3600}.get(kw.arg, 0)
if total:
seconds_limit = max(seconds_limit or 0, total)
uses_time = bool({'datetime', 'time'} & set(modules))
while_loops = [n for n in ast.walk(tree) if isinstance(n, ast.While)]
infinite = [w for w in while_loops
if isinstance(w.test, ast.Constant) and w.test.value is True
and not any(isinstance(x, ast.Break) for x in ast.walk(w))]
if infinite:
report(FAIL, 'Tiempo', f'El bucle while True de la línea {infinite[0].lineno} no termina nunca.')
elif not uses_time:
report(WARN, 'Tiempo', 'No veo control del tiempo (datetime o time). '
'El programa debe parar solo antes de 10 minutos.')
elif seconds_limit is not None and seconds_limit >= MAX_MINUTES * 60:
report(FAIL, 'Tiempo', f'Duración de {seconds_limit / 60:.1f} minutos: debe parar ANTES de 10.')
elif seconds_limit is not None and seconds_limit > MAX_MINUTES * 60 - 5:
report(WARN, 'Tiempo', 'Casi 10 minutos: deja un margen de seguridad (una foto tarda unos segundos).')
else:
report(OK, 'Tiempo', 'Controla el tiempo y para antes de 10 minutos (compruébalo en el Replay Tool).')
long_sleep = [n for name, n in calls if name.endswith('sleep') and n.args
and isinstance(n.args[0], ast.Constant) and isinstance(n.args[0].value, (int, float))
and n.args[0].value >= 60]
if long_sleep:
report(WARN, 'Pausas', f'Un sleep de {long_sleep[0].args[0].value} s en la línea '
f'{long_sleep[0].lineno}: ¿seguro?')
# --- images ----------------------------------------------------
big_sequence = [n for name, n in calls if name.endswith('capture_sequence') for kw in n.keywords
if kw.arg == 'num_images' and isinstance(kw.value, ast.Constant)
and kw.value.value > MAX_IMAGES]
takes_photos = bool(attrs & PHOTO_CALLS)
caps = [n.value for n in ast.walk(tree)
if isinstance(n, ast.Constant) and isinstance(n.value, int) and not isinstance(n.value, bool)
and 1 <= n.value <= MAX_IMAGES and n.value >= 20]
if big_sequence:
report(FAIL, 'Imágenes', f'Pide más de {MAX_IMAGES} imágenes en capture_sequence.')
elif takes_photos and MAX_IMAGES in caps:
report(OK, 'Imágenes', f'Hace fotos y veo un límite de {MAX_IMAGES}.')
elif takes_photos:
report(WARN, 'Imágenes', f'Hace fotos pero no veo un límite de {MAX_IMAGES}. '
'CHECK: cuenta cuántas quedan al final.')
# --- LED matrix ------------------------------------------------
# Creator guide, "Common mistakes": "Your program is not allowed to use the LED matrix."
led = sorted(a for a in attrs if a in LED_MATRIX_CALLS)
if led:
report(FAIL, 'Matriz LED',
'Usa la matriz LED (' + ', '.join(led) + '). La creator guide dice que '
'el programa no puede usarla.')
# --- Camera() created inside a loop ------------------------------
# Creator guide: several Camera objects in a loop can exhaust the memory.
for loop in ast.walk(tree):
if isinstance(loop, (ast.For, ast.While)):
bad = [n for n in ast.walk(loop) if isinstance(n, ast.Call) and
((isinstance(n.func, ast.Name) and n.func.id == 'Camera') or
(isinstance(n.func, ast.Attribute) and n.func.attr == 'Camera'))]
if bad:
report(WARN, 'Cámara', f'Crea Camera() dentro de un bucle (línea {bad[0].lineno}). '
'Créala una sola vez antes del bucle: si no, puede quedarse sin memoria.')
break
# --- errors ----------------------------------------------------
handlers = [n for n in ast.walk(tree) if isinstance(n, ast.ExceptHandler)]
silent = [h for h in handlers if h.type is None or
(len(h.body) == 1 and isinstance(h.body[0], ast.Pass))]
if not handlers:
report(WARN, 'Errores', 'No hay try/except: un fallo parará el programa. '
'No debe haber excepciones sin controlar.')
elif silent:
report(WARN, 'Errores', f'El except de la línea {silent[0].lineno} no hace nada o es '
'demasiado general: anota el error en el archivo.')
else:
report(OK, 'Errores', 'Controla errores con try/except.')
# --- threads ---------------------------------------------------
if 'threading' in modules:
report(WARN, 'Hilos', 'Usa threading: explícalo en comentarios y ciérralos limpiamente.')
# --- comments and language ------------------------------------
texts = comment_text(source)
code_lines = [l for l in source.splitlines() if l.strip() and not l.strip().startswith('#')]
comment_lines = [l for l in source.splitlines() if l.strip().startswith('#')]
words = set(w for t in texts for w in re.findall(r'[a-záéíóúñ]+', t.lower()))
spanish = sorted(words & SPANISH_WORDS) + (['acentos o ñ'] if re.search(r'[áéíóúñ¿¡]', ' '.join(texts).lower()) else [])
if spanish:
report(WARN, 'Inglés', 'Todo el envío debe estar en inglés. Veo español en los comentarios: ' +
', '.join(spanish[:6]))
else:
report(OK, 'Inglés', 'Los comentarios parecen estar en inglés.')
ratio = len(comment_lines) / max(1, len(code_lines))
if ratio < 0.08:
report(WARN, 'Comentarios', 'Casi no hay comentarios. El código debe estar bien documentado.')
else:
report(OK, 'Comentarios', f'Hay comentarios ({len(comment_lines)} líneas).')
if re.search(r'(based on|adapted from|copyright|licen[sc]e|CC BY)', source, re.I):
report(OK, 'Código reutilizado', 'Veo una mención a la fuente o licencia.')
else:
report(WARN, 'Código reutilizado',
'Si has usado código de una guía u otra persona, indícalo en un comentario y añade '
'LICENSE.txt. Si una IA te ayudó, explícalo con comentarios junto a esas partes '
'y en el formulario de envío (es obligatorio). Si todo es tuyo, ignora este aviso.')
# ---------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------
def main():
args = [a for a in sys.argv[1:] if not a.startswith('--')]
ascii_mode = '--ascii' in sys.argv
target = Path(args[0]) if args else Path(__file__).resolve().parent
if not target.exists():
print(f'No encuentro {target}')
sys.exit(2)
files, zip_size = load_files(target)
check_files(files, zip_size)
if 'main.py' in files:
check_program(files['main.py'].decode('utf-8', errors='replace'))
symbols = {OK: '[OK] ', WARN: '[AVISO]', FAIL: '[FALLO]'} if ascii_mode else \
{OK: '✅', WARN: '⚠️ ', FAIL: '❌'}
print(f'Lista de vuelo de: {target}')
print(f'(Python {sys.version_info.major}.{sys.version_info.minor} aquí; la ISS usa 3.13)\n')
for status, rule, message in results:
print(f'{symbols[status]} {rule}: {message}')
fails = sum(1 for s, _, _ in results if s == FAIL)
warns = sum(1 for s, _, _ in results if s == WARN)
print(f'\nResumen: {len(results) - fails - warns} bien, {warns} avisos, {fails} fallos.')
if fails:
print('Hay fallos: un programa que incumple la lista se descarta. Arréglalos antes de enviar.')
else:
print('Sin fallos. Ahora, a probarlo de verdad en el Astro Pi Replay Tool.')
print('Esta comprobación LEE el programa; no sustituye al Replay Tool ni a tu cabeza.')
sys.exit(1 if fails else 0)
main()
Cómo usarlo
- Copia
checklist.pya la carpeta de tu programa y ábrelo en Thonny. Pulsa «Run» y revisa el resultado. - También puedes usar el terminal:
python3 checklist.py(la carpeta actual),python3 checklist.py mi_equipoopython3 checklist.py mi_equipo.zip. - Si tu pantalla no muestra los símbolos, añade
--ascii. - Arregla los ❌, revisa los ⚠️ y vuelve a pasarlo hasta que no haya fallos.

Un programa que pasa la lista
Este es el programa arreglado del simulador, listo para la checklist Astro Pi. Hace una foto cada 20 segundos durante menos de 10 minutos, guarda una fila por foto en data.csv, controla los errores y no depende de nadie. Pásale checklist.py y obtendrás 20 ✅.
# Fixed example for the AstroPi12 flight checklist (Mission Space Lab).
# It takes a photo every 20 seconds for less than 10 minutes and writes one row
# per photo in data.csv.
# Based on the Mission Space Lab creator guide (Raspberry Pi Foundation, CC BY-SA 4.0).
import csv
from datetime import datetime, timedelta
from pathlib import Path
from time import sleep
from picamzero import Camera
RUN_MINUTES = 10
SAFETY_SECONDS = 10 # stop a little early: a photo needs about 2 seconds
PHOTO_EVERY = 20 # seconds between photos
MAX_PHOTOS = 42 # Mission Space Lab limit
folder = Path(__file__).parent # the folder where main.py is
def main():
end = datetime.now() + timedelta(minutes=RUN_MINUTES, seconds=-SAFETY_SECONDS)
camera = Camera() # create it ONCE
with open(folder / 'data.csv', 'w', newline='', buffering=1) as csv_file:
writer = csv.writer(csv_file)
writer.writerow(['time', 'photo', 'ok'])
number = 0
while datetime.now() < end and number < MAX_PHOTOS:
number += 1
name = f'image_{number:03d}.jpg'
try:
camera.take_photo(str(folder / name))
writer.writerow([datetime.now().isoformat(timespec='seconds'), name, 1])
except Exception as error:
# One bad photo must not stop the whole experiment
writer.writerow([datetime.now().isoformat(timespec='seconds'), name, 0])
print(f'{name}: ERROR ({error})')
left = (end - datetime.now()).total_seconds()
sleep(max(0, min(PHOTO_EVERY, left))) # never sleep past the end
main()
La prueba de vuelo: el Replay Tool
El comprobador solo lee. Para ver el programa funcionar usa el Astro Pi Replay Tool, como vimos en el AstroPi07:
- Pasa
checklist.pyy deja los fallos a cero. - Sube el zip con
main.pyen la raíz y elige el ajuste de imágenes (VIS o IR). - Comprueba que termina solo, antes de 10 minutos.
- Abre los resultados: tipos permitidos, 42 imágenes como máximo y sin errores en el registro.
- Si algo falla, arréglalo y repite desde el principio.
Según las directrices, los equipos con estado de vuelo se avisan a finales de abril de 2027, el programa se ejecuta en la ISS entre abril y mayo, y los resultados llegan por correo en junio.
Lo que no sabíamos: la sonda
En los chismes anteriores nos quedaron tres dudas sobre el Replay Tool. Para contestarlas escribimos una sonda: un programa de unos 12 segundos que apunta en probe.csv qué hay disponible.
- ¿Funciona OpenCV (
cv2) en el Replay Tool, incluyendo guardar y leer una foto? - ¿Se puede leer la posición de la ISS?
- ¿Cambia esa posición mientras corre el programa, es decir, el Replay reproduce también el tiempo?
La ejecutamos en el Replay Tool el 1 de noviembre de 2026 y contestó las tres:
- OpenCV: funciona (versión 4.9.0, NumPy 1.26.4) y guardar y volver a leer una foto con
cv2salió bien. - Posición de la ISS: se puede leer con
astro_pi_orbit. - ¿Cambia durante la ejecución? Sí: en 5 segundos pasó de 51,5403 N, 18,1457 E a 51,5050 N, 18,6403 E, unos 34 km, es decir, unos 7 km/s, coherente con la velocidad real de la ISS.
Un detalle que no esperábamos: el Replay Tool online usa Python 3.12.1 y el reglamento pide Python 3.13. Nuestra Raspberry Pi tiene Python 3.13.5, así que ahí sí se prueba con la versión de la ISS. Otro aviso de la guía: el Replay ignora en silencio muchos ajustes de picamzero (por ejemplo la resolución), que sí cambiarían en la ISS. La sonda no sirve como experimento (no usa ningún sensor ni la cámara, y el comprobador se lo dice con un ❌), pero sí para investigar.
# Chisme del Lince AstroPi12 - Replay Tool probe
#
# A tiny program that answers four questions about the Replay Tool:
# 1. Which version of Python and which libraries are there?
# 2. Does OpenCV (cv2) work, including saving and reading a photo?
# 3. Can we read the position of the ISS?
# 4. Does that position change while the program runs (is the time replayed)?
# It writes everything in probe.csv. It takes about 12 seconds.
import csv
import importlib
import sys
from datetime import datetime
from pathlib import Path
from time import sleep
folder = Path(__file__).parent # the folder where main.py is
csv_path = folder / 'probe.csv'
def read_iss_position():
"""Returns (latitude, longitude) in degrees. The ISS object is called in two ways
in different versions of the library, so we try both."""
try:
from astro_pi_orbit import ISS
except ImportError:
from orbit import ISS
iss = ISS
if not hasattr(iss, 'coordinates'):
iss = iss() # some versions need ISS()
point = iss.coordinates()
return point.latitude.degrees, point.longitude.degrees
with open(csv_path, 'w', newline='', buffering=1) as csv_file:
writer = csv.writer(csv_file)
writer.writerow(['time', 'item', 'value'])
def note(item, value):
writer.writerow([datetime.now().isoformat(timespec='seconds'), item, value])
print(item, '=', value)
# 1. Python and libraries
note('python', sys.version.split()[0])
for module_name in ('numpy', 'cv2', 'picamzero', 'sense_hat'):
try:
module = importlib.import_module(module_name)
note(module_name, getattr(module, '__version__', 'imported'))
except Exception as error:
note(module_name, f'FAILED: {error}')
# 2. OpenCV: save a small image and read it back
try:
import cv2
import numpy as np
image = np.zeros((20, 30, 3), dtype=np.uint8)
image[:, :, 2] = 200 # a red picture (OpenCV order is B, G, R)
photo_file = folder / 'probe.jpg'
cv2.imwrite(str(photo_file), image)
back = cv2.imread(str(photo_file))
note('cv2_roundtrip', 'ok' if back is not None and back.shape == image.shape
else 'FAILED: could not read the photo back')
except Exception as error:
note('cv2_roundtrip', f'FAILED: {error}')
# 3 and 4. ISS position twice, 5 seconds apart
try:
for attempt in (1, 2):
latitude, longitude = read_iss_position()
note(f'iss_position_{attempt}', f'{latitude:.4f},{longitude:.4f}')
if attempt == 1:
sleep(5)
except Exception as error:
note('iss_position', f'FAILED: {error}')
print('Done! Your file is here:', csv_path)
Qué mide realmente una checklist Astro Pi
El comprobador también es un instrumento, y como todos, puede equivocarse. Mide lo que se puede leer en el código, no lo que ocurrirá al ejecutarlo. Por eso puede dar un ✅ a un programa que no funciona, o un aviso a uno que está bien.
Un ejemplo: el aviso de «Inglés» busca palabras españolas en los comentarios con una lista corta. Si un comentario en inglés incluye «la», salta el aviso; si está en español pero con otras palabras, puede pasar sin avisar.
¿Cómo lo calibramos? Como en el AstroPi11, con controles. Un programa roto a propósito da 7 fallos y 4 avisos. El arreglado da 0 y 0. Si el comprobador fallara con alguno de los dos, sabríamos que no es de fiar.
Y recuerda la diferencia entre tres cosas: que el programa cumpla la norma, que funcione y que sea un buen experimento. El comprobador solo mira la primera; las otras dos se prueban con el Replay Tool, con datos reales y con la cabeza del equipo.
Para el aula
- Antes de ejecutar: cada equipo predice cuántos fallos tendrá su
main.py. ¿Acierta? - Rompe tu programa: introduce cinco fallos distintos a propósito. ¿Cuáles detecta el comprobador y cuáles no?
- Busca falsos positivos: ¿hay un programa correcto al que el comprobador le ponga un fallo o un aviso injusto?
- Revisión cruzada: cada equipo revisa el programa de otro con una checklist humana para lo que una máquina no mira: si los datos responden a la pregunta, si el inglés se entiende, si se cita lo reutilizado.
- Datos de la clase: ¿qué fallo es el más frecuente en los programas de todos los equipos? Hagan un diagrama de barras.
- Cuidado con la IA: si una IA os ayudó con el código, anotadlo en un comentario. La creator guide lo exige: el equipo debe explicar cómo ha usado la IA, con comentarios junto a las partes generadas y/o en el formulario de envío. El comprobador no puede saberlo, así que esto lo revisa una persona.
Retos
Reto 1
Pasa checklist.py por tu main.py y arregla todos los fallos.
Reto 2
Rompe tu programa de cinco maneras y apunta en una tabla cuáles detecta el comprobador.
Reto 3
Añade una regla nueva a checklist.py, por ejemplo avisar de un time.sleep largo o de un nombre de archivo con mayúsculas.
Reto 4
Haz que el comprobador cuente cuántos fallos y avisos tiene y los guarde en un CSV, para poder comparar versiones.
Reto 5
Pasa el comprobador por un zip real de tu equipo y compara el tamaño que calcula con el que ves en el explorador de archivos.
Reto 6
Ejecuta la sonda en el Replay Tool y escribe un informe corto con las tres respuestas.
Reto 7
Recoge los fallos de todos los equipos de la clase y represéntalos en una gráfica.
Qué hemos aprendido
- Un programa que incumple una sola norma se descarta: la checklist Astro Pi se pasa antes de enviar, y se repite cada vez que cambias el programa.
- El zip, el tiempo, las rutas y los tipos de archivo son los fallos más fáciles de cometer y de revisar.
- Con 12 s entre fotos, 10 minutos dan 43 fotos: el límite de 42 hay que calcularlo, no solo imponerlo.
- Un comprobador lee el programa; el Replay Tool lo ejecuta. Hacen falta los dos, y la cabeza del equipo.
- Un instrumento hay que calibrarlo con controles: un programa roto y uno arreglado.
Siguiente paso: con el programa revisado, toca decidir qué experimento llevamos de verdad a la ISS.
Para seguir investigando
- AstroPi11: del dato a la pregunta, diseña tu experimento Astro Pi
- AstroPi07: tu programa viaja a la ISS (casi)
- AstroPi06: el temporizador de tu programa
- Directrices de Mission Space Lab 2026/27
- Reglamento de Mission Space Lab
- Lista de verificación en Wikipedia
- El módulo ast de Python

Etiqueta:Astro Pi, checklist, Mission Space Lab, python, Raspberry Pi, Replay Tool



