Antes de empezar
Necesitas el Proyecto 0 y el Proyecto 1 terminados: aquí se asume que sabes crear el proyecto con uv, escribir tests antes que el código, y el ciclo rama → PR → revisión. Nada de eso se vuelve a explicar.
Consigue además un export real de tu trabajo (anonimizado si hace falta). La guía usa una placa qPCR con columnas Well, Sample, Target, Ct; si tu equipo exporta otras columnas, adapta los nombres cuando llegues al paso 3 — es parte del ejercicio.
Checklist antes de seguir
P0 y P1 mergeados · un CSV o xlsx real a mano · uv --version y docker run hello-world siguen en verde.
Construcción paso a paso
Igual que siempre: test primero donde se pueda, commit por paso, y cada salida comparada con "deberías ver". Trabaja en ramas y abre un PR por bloque de pasos (1–2, 3–5, 6–9 es un buen corte).
Paso 1 Proyecto, dependencias y la fixture mínima
mkdir reporte-placa
cd reporte-placa
git init
uv python pin 3.12
mkdir -p src/reporte tests/data notebooks
pyproject.toml[project]
name = "reporte"
version = "0.1.0"
description = "Proyecto 2: reporte de placa qPCR a partir del export crudo"
requires-python = ">=3.12"
dependencies = [
"pandas>=2.2",
"openpyxl>=3.1",
"matplotlib>=3.9",
"typer>=0.12",
]
[project.scripts]
reporte = "reporte.cli:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/reporte"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
Novedades respecto a P1: cuatro dependencias de verdad (pandas para tablas, openpyxl para Excel, matplotlib para el gráfico, typer para la CLI) y la sección [project.scripts], que convierte reporte.cli:app en el comando reporte.
touch src/reporte/__init__.py
uv add --dev pytest ruff
El .gitignore de P0 más dos líneas: salida/ (lo generado no se versiona) y .ipynb_checkpoints/.
Ahora la fixture: un archivo de prueba mínimo que concentra los casos raros que has visto en exports reales. No es la placa completa — es el archivo que hace fallar al código ingenuo:
tests/data/placa_min.csvWell, Sample ,Target,Ct
A1,Control,GAPDH,18.02
A2,Control,GAPDH,18.10
A3,Control,IL6,24.51
A4,Control,IL6,24.47
B1,Tratada ,GAPDH,18.20
B2,Tratada,GAPDH,18.12
B3,Tratada,IL6,22.03
B4,Tratada,IL6,Undetermined
Fíjate en las trampas que trae a propósito: la columna se llama " Sample " con espacios, una muestra es "Tratada " con espacio al final, y un pocillo dice Undetermined en una columna que debería ser numérica. Todas salen de exports reales.
git add .
git commit -m "Estructura del proyecto y fixture con casos sucios reales"
La fixture es un contrato. Cada caso raro que descubras en producción se agrega aquí con su test. En seis meses este archivo es la memoria de todo lo que ya te pasó.
Paso 2 Los errores propios
Antes de leer nada, define cómo va a fallar. Un error con nombre propio se atrapa con precisión y se lee sin abrir el código:
src/reporte/errors.py"""Errores propios del reporte. Un nombre por causa: el mensaje dice qué arreglar."""
class ReporteError(Exception):
"""Base de todos los errores del reporte."""
class EsquemaInvalido(ReporteError):
"""El archivo no tiene las columnas que el reporte necesita."""
class ReferenciaNoEncontrada(ReporteError):
"""El gen de referencia no aparece en la placa (o falta en alguna muestra)."""
class ControlNoEncontrado(ReporteError):
"""La muestra control no aparece en la placa."""
Por qué una base común. La CLI atrapará ReporteError (una sola vez) para mostrar el mensaje en rojo y salir con código 1. Los tests, en cambio, atrapan el error específico. Herencia usada para algo concreto, no por costumbre.
Paso 3 load: aquí termina la desconfianza
Tests primero. Describen todo lo que el archivo sucio puede traer:
tests/test_load.pyfrom pathlib import Path
import pytest
from reporte.errors import EsquemaInvalido
from reporte.load import cargar_placa, resumen_carga
DATA = Path(__file__).parent / "data" / "placa_min.csv"
def test_carga_limpia_espacios_y_tipos():
df = cargar_placa(DATA)
assert list(df.columns) == ["Well", "Sample", "Target", "Ct"]
assert set(df["Sample"]) == {"Control", "Tratada"} # " Tratada " quedó limpio
assert df["Ct"].dtype == "float64"
def test_undetermined_queda_como_nan_no_como_cero():
df = cargar_placa(DATA)
fila = df[(df["Sample"] == "Tratada") & (df["Well"] == "B4")]
assert fila["Ct"].isna().all()
def test_columna_faltante_nombra_la_columna(tmp_path):
malo = tmp_path / "malo.csv"
malo.write_text("Well,Sample,Ct\nA1,Control,18.0\n")
with pytest.raises(EsquemaInvalido, match="Target"):
cargar_placa(malo)
def test_archivo_inexistente():
with pytest.raises(FileNotFoundError):
cargar_placa(Path("no-existe.csv"))
def test_resumen_carga():
r = resumen_carga(cargar_placa(DATA))
assert r == {"pocillos": 8, "sin_ct": 1, "muestras": 2, "targets": 2}
def test_no_modifica_el_crudo(tmp_path):
copia = tmp_path / "placa.csv"
copia.write_bytes(DATA.read_bytes())
antes = copia.read_bytes()
cargar_placa(copia)
assert copia.read_bytes() == antes
Dos cosas nuevas de pytest: tmp_path es una carpeta temporal que pytest crea y borra por ti (para fabricar archivos malos sin ensuciar el repo), y match="Target" verifica que el mensaje del error nombra la columna — el mensaje también es parte del contrato.
Corre uv run pytest: rojo (ModuleNotFoundError, como siempre). Ahora la implementación:
src/reporte/load.py"""Capa 1: leer el archivo crudo y validarlo. Aquí termina la desconfianza.
Todo lo que sale de `cargar_placa` cumple el esquema; las otras capas ya no validan.
"""
from pathlib import Path
import pandas as pd
from reporte.errors import EsquemaInvalido
COLUMNAS = ["Well", "Sample", "Target", "Ct"]
SIN_CT = {"undetermined", "undet", "n/a", "na", ""}
def cargar_placa(ruta: Path) -> pd.DataFrame:
"""Lee un export de placa qPCR (CSV o Excel) y devuelve un DataFrame limpio.
- Columnas exactas: Well, Sample, Target, Ct (ignora mayúsculas y espacios en el nombre).
- Sample y Target sin espacios sobrantes.
- Ct numérico; "Undetermined" y vacíos quedan como NaN (no se inventan valores).
Lanza EsquemaInvalido si falta una columna, nombrándola.
"""
ruta = Path(ruta)
if not ruta.exists():
raise FileNotFoundError(f"no existe el archivo: {ruta}")
if ruta.suffix.lower() in {".xlsx", ".xls"}:
crudo = pd.read_excel(ruta)
else:
crudo = pd.read_csv(ruta)
# Normaliza nombres de columna: " sample " -> "Sample"
crudo = crudo.rename(columns={c: str(c).strip().title() for c in crudo.columns})
faltan = [c for c in COLUMNAS if c not in crudo.columns]
if faltan:
raise EsquemaInvalido(f"faltan columnas {faltan}; el archivo tiene {list(crudo.columns)}")
df = crudo[COLUMNAS].copy() # copia: el crudo no se toca
df["Sample"] = df["Sample"].astype(str).str.strip()
df["Target"] = df["Target"].astype(str).str.strip()
ct = df["Ct"].astype(str).str.strip()
ct = ct.where(~ct.str.lower().isin(SIN_CT), other=None)
df["Ct"] = pd.to_numeric(ct, errors="raise")
return df
def resumen_carga(df: pd.DataFrame) -> dict[str, int]:
"""Números que conviene mirar antes de calcular nada."""
return {
"pocillos": int(len(df)),
"sin_ct": int(df["Ct"].isna().sum()),
"muestras": int(df["Sample"].nunique()),
"targets": int(df["Target"].nunique()),
}
uv run pytest tests/test_load.py -q
Deberías ver...... [100%]
6 passed in 0.4s
La decisión importante está en SIN_CT. Un Undetermined podría convertirse en 0, en 40 (costumbre de algunos labs) o en NaN. Elegimos NaN porque inventar un número sesga el promedio en silencio; el reporte mostrará n (cuántas réplicas sí entraron) para que la pérdida quede a la vista. Esa decisión va en DECISIONES.md — es exactamente el tipo de cosa que un revisor pregunta.
Paso 4 transform: cálculo puro, verificado a mano
La capa de cálculo no conoce archivos: DataFrame entra, DataFrame sale. Los tests usan una placa inventada con números redondos que puedes verificar con lápiz:
tests/test_transform.pyimport pandas as pd
import pytest
from reporte.errors import ControlNoEncontrado, ReferenciaNoEncontrada
from reporte.transform import analizar, delta_ct, promedio_por_replica
def placa() -> pd.DataFrame:
# 2 muestras x 2 targets x 2 réplicas, números fáciles de verificar a mano
return pd.DataFrame(
{
"Well": ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"],
"Sample": ["Control"] * 4 + ["Tratada"] * 4,
"Target": ["GAPDH", "GAPDH", "IL6", "IL6"] * 2,
"Ct": [18.0, 18.0, 24.0, 24.0, 18.0, 18.0, 22.0, 22.0],
}
)
def test_promedio_ignora_nan_y_cuenta_n():
df = placa()
df.loc[7, "Ct"] = None
prom = promedio_por_replica(df)
fila = prom[(prom["Sample"] == "Tratada") & (prom["Target"] == "IL6")].iloc[0]
assert fila["ct_mean"] == 22.0
assert fila["n"] == 1
def test_delta_ct_calculado_a_mano():
dct = delta_ct(promedio_por_replica(placa()), ref="GAPDH")
control = dct[dct["Sample"] == "Control"].iloc[0]
assert control["dct"] == pytest.approx(6.0) # 24 - 18
def test_ddct_y_fold_a_mano():
res = analizar(placa(), ref="GAPDH", control="Control")
tratada = res[res["Sample"] == "Tratada"].iloc[0]
assert tratada["ddct"] == pytest.approx(-2.0) # (22-18) - (24-18)
assert tratada["fold"] == pytest.approx(4.0) # 2^2
def test_referencia_ausente_lanza_error_claro():
with pytest.raises(ReferenciaNoEncontrada, match="ACTB"):
delta_ct(promedio_por_replica(placa()), ref="ACTB")
def test_control_ausente_lanza_error_claro():
with pytest.raises(ControlNoEncontrado, match="Mock"):
analizar(placa(), ref="GAPDH", control="Mock")
Haz la cuenta antes de correr nada: la muestra Tratada tiene Ct de IL6 = 22 y GAPDH = 18, así que ΔCt = 4; el control tiene ΔCt = 6; ΔΔCt = 4 − 6 = −2 y la expresión relativa 2−(−2) = 4. Si el test pasa, el código calcula lo mismo que tú.
src/reporte/transform.py"""Capa 2: cálculo. Funciones puras: entra un DataFrame, sale otro. Nada de archivos."""
import pandas as pd
from reporte.errors import ControlNoEncontrado, ReferenciaNoEncontrada
def promedio_por_replica(df: pd.DataFrame) -> pd.DataFrame:
"""Promedio y desviación estándar de Ct por (Sample, Target).
Los pocillos sin Ct (NaN) no entran al promedio; `n` dice cuántos sí entraron.
"""
agrupado = df.groupby(["Sample", "Target"], sort=True)["Ct"]
return agrupado.agg(ct_mean="mean", ct_sd="std", n="count").reset_index()
def delta_ct(prom: pd.DataFrame, ref: str = "GAPDH") -> pd.DataFrame:
"""ΔCt = Ct(target) - Ct(ref) dentro de cada muestra. Excluye el propio gen de referencia."""
if ref not in set(prom["Target"]):
raise ReferenciaNoEncontrada(
f"el gen de referencia {ref!r} no está en la placa; "
f"targets: {sorted(prom['Target'].unique())}"
)
referencia = prom.loc[prom["Target"] == ref, ["Sample", "ct_mean"]].rename(
columns={"ct_mean": "ct_ref"}
)
sin_ref = sorted(set(prom["Sample"]) - set(referencia["Sample"]))
if sin_ref:
raise ReferenciaNoEncontrada(f"muestras sin {ref!r}: {sin_ref}")
otros = prom[prom["Target"] != ref]
res = otros.merge(referencia, on="Sample", how="left")
res["dct"] = res["ct_mean"] - res["ct_ref"]
return res
def delta_delta_ct(dct: pd.DataFrame, control: str = "Control") -> pd.DataFrame:
"""ΔΔCt = ΔCt(muestra) - ΔCt(control) por target, y fold = 2^-ΔΔCt."""
if control not in set(dct["Sample"]):
raise ControlNoEncontrado(
f"la muestra control {control!r} no está en la placa; "
f"muestras: {sorted(dct['Sample'].unique())}"
)
base = dct.loc[dct["Sample"] == control, ["Target", "dct"]].rename(
columns={"dct": "dct_control"}
)
res = dct.merge(base, on="Target", how="left")
res["ddct"] = res["dct"] - res["dct_control"]
res["fold"] = 2.0 ** (-res["ddct"])
return res
def analizar(df: pd.DataFrame, ref: str = "GAPDH", control: str = "Control") -> pd.DataFrame:
"""Las tres etapas en orden. Es la única función que la CLI necesita llamar."""
return delta_delta_ct(delta_ct(promedio_por_replica(df), ref=ref), control=control)
Deberías ver........... [100%]
11 passed in 0.5s
pytest.approx. Los floats no se comparan con ==: 0.1 + 0.2 no es exactamente 0.3 en binario. approx compara con una tolerancia razonable. Regla: enteros y strings con ==, floats calculados con approx.
Paso 5 report: escribir md, xlsx y el gráfico
src/reporte/report.py"""Capa 3: escribir resultados. Recibe DataFrames ya calculados; no calcula nada."""
from pathlib import Path
import matplotlib
import pandas as pd
matplotlib.use("Agg") # sin ventana: solo archivos
import matplotlib.pyplot as plt # noqa: E402
COLUMNAS_SALIDA = ["Sample", "Target", "n", "ct_mean", "ct_sd", "dct", "ddct", "fold"]
def _fmt(x: float) -> str:
return "—" if pd.isna(x) else f"{x:.2f}"
def escribir_markdown(res: pd.DataFrame, ruta: Path, resumen: dict[str, int]) -> Path:
lineas = ["# Reporte de placa qPCR", ""]
lineas.append(
f"Pocillos: {resumen['pocillos']} · sin Ct: {resumen['sin_ct']} · "
f"muestras: {resumen['muestras']} · targets: {resumen['targets']}"
)
lineas += [
"",
"| Muestra | Target | n | Ct medio | SD | ΔCt | ΔΔCt | Fold (2^-ΔΔCt) |",
"|---|---|---|---|---|---|---|---|",
]
for _, r in res.sort_values(["Target", "Sample"]).iterrows():
lineas.append(
f"| {r.Sample} | {r.Target} | {int(r.n)} | {_fmt(r.ct_mean)} | {_fmt(r.ct_sd)} | "
f"{_fmt(r.dct)} | {_fmt(r.ddct)} | {_fmt(r.fold)} |"
)
ruta.write_text("\n".join(lineas) + "\n", encoding="utf-8")
return ruta
def escribir_xlsx(res: pd.DataFrame, ruta: Path) -> Path:
res[COLUMNAS_SALIDA].to_excel(ruta, index=False, sheet_name="resultados")
return ruta
def graficar_fold(res: pd.DataFrame, ruta: Path) -> Path:
tabla = res.pivot(index="Sample", columns="Target", values="fold")
ax = tabla.plot(kind="bar", figsize=(8, 4.5), width=0.8)
ax.axhline(1.0, color="gray", linewidth=0.8, linestyle="--")
ax.set_ylabel("Expresión relativa (2^-ΔΔCt)")
ax.set_xlabel("")
ax.set_title("Expresión relativa por muestra y target")
plt.xticks(rotation=0)
plt.tight_layout()
plt.savefig(ruta, dpi=150)
plt.close()
return ruta
def generar_reporte(res: pd.DataFrame, resumen: dict[str, int], carpeta: Path) -> list[Path]:
carpeta = Path(carpeta)
carpeta.mkdir(parents=True, exist_ok=True)
return [
escribir_markdown(res, carpeta / "reporte.md", resumen),
escribir_xlsx(res, carpeta / "resultados.xlsx"),
graficar_fold(res, carpeta / "fold.png"),
]
matplotlib.use("Agg") le dice a matplotlib que no intente abrir ventanas: solo escribe archivos. Sin esa línea, el reporte funciona en tu laptop y muere en Docker o en CI, donde no hay pantalla. Es el primer "funciona en mi máquina" sutil del cuaderno.
Paso 6 La CLI que une las tres capas
src/reporte/cli.py"""Entrada por línea de comandos. Delgada: parsea argumentos, llama a las capas, informa."""
from pathlib import Path
from typing import Annotated
import typer
from reporte import load, report, transform
from reporte.errors import ReporteError
app = typer.Typer(help="Reporte ΔΔCt a partir del export de una placa qPCR.", no_args_is_help=True)
@app.command()
def generar(
archivo: Annotated[Path, typer.Argument(help="CSV o xlsx exportado del equipo")],
ref: Annotated[str, typer.Option(help="Gen de referencia")] = "GAPDH",
control: Annotated[str, typer.Option(help="Muestra control (calibrador)")] = "Control",
out: Annotated[Path, typer.Option(help="Carpeta de salida")] = Path("salida"),
) -> None:
"""Lee la placa, calcula ΔCt/ΔΔCt y escribe reporte.md, resultados.xlsx y fold.png."""
try:
df = load.cargar_placa(archivo)
resumen = load.resumen_carga(df)
res = transform.analizar(df, ref=ref, control=control)
rutas = report.generar_reporte(res, resumen, out)
except (ReporteError, FileNotFoundError) as e:
typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1) from e
typer.echo(
f"{resumen['pocillos']} pocillos, {resumen['sin_ct']} sin Ct, "
f"{resumen['muestras']} muestras, {resumen['targets']} targets"
)
for r in rutas:
typer.echo(f" escrito {r}")
if __name__ == "__main__":
app()
Y sus tests, usando el runner de typer (no hace falta un subproceso):
tests/test_cli.pyfrom pathlib import Path
from typer.testing import CliRunner
from reporte.cli import app
DATA = Path(__file__).parent / "data" / "placa_min.csv"
runner = CliRunner()
def test_generar_escribe_los_tres_archivos(tmp_path):
out = tmp_path / "rep"
r = runner.invoke(app, [str(DATA), "--ref", "GAPDH", "--control", "Control", "--out", str(out)])
assert r.exit_code == 0, r.output
assert (out / "reporte.md").exists()
assert (out / "resultados.xlsx").exists()
assert (out / "fold.png").exists()
def test_referencia_mala_sale_con_codigo_1(tmp_path):
r = runner.invoke(app, [str(DATA), "--ref", "ACTB", "--out", str(tmp_path)])
assert r.exit_code == 1
assert "ACTB" in r.output
uv run pytest -q
uv run ruff check . && uv run ruff format .
Deberías ver............. [100%]
13 passed in 0.6s
All checks passed!
Commit y PR de este bloque (pasos 3–6): tres capas, trece tests.
Paso 7 CI: los tests corren solos en cada PR
Hasta ahora los tests corren porque tú te acuerdas. GitHub Actions los corre en cada push y cada PR, en una máquina limpia — la versión automática del "funciona en la máquina de otro":
.github/workflows/ci.ymlname: ci
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
- run: uv sync
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pytest
git add .github
git commit -m "CI: ruff y pytest en cada PR"
git push
Abre el repo en GitHub → pestaña Actions: deberías ver el workflow correr y quedar en verde. Desde ahora, un PR con la ✗ roja no se mergea, sin discusión.
Paso 8 Docker: el reporte sobre una carpeta montada
Diferencia con P0: el contenedor necesita ver tus datos, que viven fuera de la imagen. Eso se resuelve montando una carpeta con -v:
Dockerfile# Imagen base: Python 3.12 mínimo, sobre Debian
FROM python:3.12-slim
# Copiamos el binario de uv desde su imagen oficial
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# Primero las dependencias (cambian poco → la capa se cachea)
COPY pyproject.toml uv.lock ./
# Luego el código y los tests
COPY src ./src
COPY tests ./tests
# Instala exactamente lo que dice uv.lock, nada más
RUN uv sync --frozen
# El reporte se genera sobre /datos, que se monta al correr
ENTRYPOINT ["uv", "run", "reporte"]
CMD ["--help"]
En .dockerignore, agrega salida/ y notebooks/ a lo que ya tenías.
docker build -t reporte-placa .
docker run --rm -v "$PWD:/datos" reporte-placa /datos/placa_2026-08-14.csv --out /datos/salida-docker
Deberías ver36 pocillos, 1 sin Ct, 4 muestras, 3 targets
escrito /datos/salida-docker/reporte.md
escrito /datos/salida-docker/resultados.xlsx
escrito /datos/salida-docker/fold.png
ENTRYPOINT vs CMD. ENTRYPOINT es el programa fijo del contenedor (uv run reporte); CMD son sus argumentos por defecto (--help). Lo que escribes después del nombre de la imagen reemplaza al CMD. Y -v "$PWD:/datos" monta tu carpeta actual como /datos dentro del contenedor: por eso las rutas del comando empiezan con /datos/ — el contenedor no ve tu disco, solo lo que montaste.
Paso 9 Probarlo con datos reales (y con una persona real)
Genera el reporte con tu placa real:
uv run reporte placa_2026-08-14.csv --ref GAPDH --control Control --out salida
Deberías ver36 pocillos, 1 sin Ct, 4 muestras, 3 targets
escrito salida/reporte.md
escrito salida/resultados.xlsx
escrito salida/fold.png
Abre salida/reporte.md. Con la placa de ejemplo de la guía se ve así (tus números serán otros):
salida/reporte.md (fragmento real)# Reporte de placa qPCR
Pocillos: 36 · sin Ct: 1 · muestras: 4 · targets: 3
| Muestra | Target | n | Ct medio | SD | ΔCt | ΔΔCt | Fold (2^-ΔΔCt) |
|---|---|---|---|---|---|---|---|
| Control | IL6 | 3 | 24.24 | 0.04 | 6.14 | 0.00 | 1.00 |
| Dosis alta | IL6 | 3 | 21.89 | 0.07 | 3.83 | -2.31 | 4.95 |
| Dosis baja | IL6 | 3 | 23.09 | 0.16 | 4.95 | -1.19 | 2.28 |
| Vehiculo | IL6 | 3 | 24.33 | 0.03 | 6.09 | -0.05 | 1.03 |
Fíjate en la fila con n = 2 (donde cayó el Undetermined): la decisión del paso 3 quedó visible en el reporte. Prueba también el camino del error:
uv run reporte placa_2026-08-14.csv --ref ACTB
echo $?
Deberías vererror: el gen de referencia 'ACTB' no está en la placa; targets: ['GAPDH', 'IL6', 'TNF']
1
Último objetivo, el difícil: dale el comando (o el reporte) a alguien de tu trabajo que hoy hace esto a mano. Su primer comentario ("me sirve, pero...") vale más que cualquier test. Anótalo como issue en el repo.
Si exploras los datos antes de decidir qué calcular, hazlo en un notebook en notebooks/ — pero la regla es dura: ninguna lógica vive solo ahí. Lo que descubras en el notebook se convierte en función con test en src/, o no existe.
Entiende el código
Estructura
reporte-placa/
├── pyproject.toml + [project.scripts]: el comando `reporte`
├── src/reporte/
│ ├── errors.py errores con nombre propio
│ ├── load.py capa 1: leer y validar (la desconfianza termina aquí)
│ ├── transform.py capa 2: cálculo puro DataFrame → DataFrame
│ ├── report.py capa 3: escribir md / xlsx / png
│ └── cli.py entrada: parsea, llama, informa
├── tests/
│ ├── data/placa_min.csv fixture con los casos raros
│ ├── test_load.py la suciedad
│ ├── test_transform.py la aritmética, verificada a mano
│ └── test_cli.py el programa completo
├── notebooks/ exploración (sin lógica exclusiva)
├── .github/workflows/ci.yml ruff + pytest en cada PR
└── Dockerfile ENTRYPOINT reporte, datos por -v
El flujo de un dato
cargar_placa es una frontera: antes de ella, cualquier cosa (espacios, texto en columnas numéricas, columnas faltantes); después de ella, un DataFrame con cuatro columnas de tipos conocidos. Por eso transform.py no tiene ni un if defensivo sobre el formato — si validara de nuevo, la validación estaría repartida y nunca sabrías dónde falta. Validar en el borde, confiar adentro.
Decisiones que debes poder defender
- NaN, no 0 ni 40, para
Undetermined — inventar un número sesga en silencio; n en el reporte hace visible la pérdida.
- El crudo es inmutable —
cargar_placa trabaja sobre una copia y hay un test (test_no_modifica_el_crudo) que lo garantiza. Toda corrección es código, nunca editar el CSV.
errors="raise" en pd.to_numeric — si aparece un texto que no está en SIN_CT (por ejemplo "No Amp" de otro equipo), el programa explota en la carga con un mensaje claro, en vez de colar un NaN silencioso. Cuando pase, agregas el caso a la fixture y a SIN_CT: así crece el software.
groupby(...).agg(n="count") — count en pandas cuenta solo los no-NaN. Es exactamente lo que queremos para n, pero tienes que saberlo: con size habría contado los pocillos vacíos también.
Complejidad, de paso
Todo el análisis es O(n) sobre los pocillos (un groupby y dos merge sobre tablas diminutas). Una placa tiene 96 filas; podrías procesar mil placas por segundo. La lección aquí es la inversa a la del P1: no todo necesita pensarse para escalar — este código es claro primero, y da igual que no sea óptimo, porque n = 96.