Cuaderno/Proyectos/2 · Reporte de placa
Proyecto 2 de 10 · Bloque 1

Reporte de placa

De un export real de qPCR a un reporte que le ahorra una hora a alguien. El código nuevo no es el cálculo (eso ya lo sabes hacer en Excel): es aprender que los datos reales vienen sucios, y que el 80 % del código serio es validar en el borde y fallar con mensajes claros.

concepto · datos sucios y validación en el borde4 sesionespandas · openpyxl · matplotlib · typer · CI
ConstruyesCLI reporte: placa qPCR → ΔΔCt en md + xlsx + gráfico
AprendesTres capas load → transform → report, cada una testeable sola
CostoNinguno. Todo corre en tu laptop.
Terminas conAlguien de tu trabajo usándolo con su archivo

Objetivos

Al terminar este proyecto vas a haber hecho:

  • Leer un CSV/Excel real con pandas, validar su esquema y fallar nombrando la columna que falta.
  • Tratar los valores sucios de verdad: "Undetermined", espacios en los nombres, celdas vacías — sin inventar números.
  • Separar el código en tres capas (leer, calcular, escribir) donde el cálculo son funciones puras DataFrame → DataFrame.
  • Calcular ΔCt, ΔΔCt y expresión relativa 2−ΔΔCt con tests cuyos valores verificaste a mano.
  • Dejar GitHub Actions corriendo ruff + pytest en cada PR.
  • Generar el reporte desde Docker montando tu carpeta de datos.
Por qué una placa qPCR

Porque es un archivo que conoces: sabes qué es un Ct, qué es un gen de referencia y qué esperas ver. Cuando el reporte diga algo raro, vas a saber si el bug está en el código o en la placa. Si en tu trabajo usas otro export (curva de crecimiento, lectura de espectro), cámbialo: la estructura de tres capas es idéntica; lo que importa es que los datos sean tuyos.

Temas que vas a usar

Los de El libro de Python para la sintaxis; los de la guía para el criterio.

Si tu R interno protesta: tiene razón en que esto saldría igual en R (dplyr + testthat + renv). Seguimos en Python porque el resto del cuaderno construye sobre este stack; la estructura en capas es la misma en los dos lenguajes. Anótalo así en DECISIONES.md.

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.csv
Well, 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.py
from 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.py
import 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)
uv run pytest -q
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.py
from 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.yml
name: 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 ver
36 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 ver
36 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 ver
error: 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 inmutablecargar_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.

Con Claude

En P2 ya escribiste a mano tu primer módulo con pandas. A partir de aquí Claude puede escribir partes — con tus reglas y contigo leyendo cada diff. Actualiza el CLAUDE.md:

CLAUDE.md
# reporte-placa

Proyecto de aprendizaje (P2 del cuaderno). Estoy aprendiendo pandas y la
estructura en capas; el objetivo es que YO entienda cada línea.

## Reglas
- Propón cambios chicos, del tamaño de un commit, y explica cada línea nueva.
- La capa transform es pura: si un cambio tuyo le agrega I/O o print, está mal.
- Nunca modifiques los tests para que pasen. Nunca edites tests/data/ sin avisar.
- Los datos crudos son inmutables: ninguna función escribe sobre el archivo de entrada.
- No agregues dependencias sin preguntar y decir por qué.
- Antes de dar algo por listo: `uv run pytest` y `uv run ruff check .` en verde.
- Responde en español.

Pedidos que sí valen la pena en este proyecto

Aquí van 20 filas de mi export real [pegar]. ¿Qué es cada columna, en qué unidades está, y qué se ve raro? No escribas código todavía. Mi equipo exporta la columna Ct como "Cq" y agrega filas de comentarios al inicio. ¿Dónde de load.py adaptarías eso y por qué ahí? Propón el cambio con su test. Explícame qué hace exactamente prom.merge(referencia, on="Sample", how="left") y qué pasaría con how="inner" si una muestra no tuviera GAPDH. Escribe un test para el caso "todas las réplicas de una muestra son Undetermined". Antes de escribirlo, dime qué crees que hace mi código hoy en ese caso. Revisa transform.py como revisor de un journal: ¿qué criticaría un estadístico del uso de promedio de Ct antes de ΔCt?

El último pedido tiene trampa deliberada: hay debate real sobre promediar Ct vs promediar réplicas después del ΔCt. Escucha el argumento, decide, y documenta en DECISIONES.md. Decidir con argumentos es el músculo que estás entrenando.

Si algo falla

Antes de buscar aquí: lee el traceback de abajo hacia arriba y formula tu hipótesis.

ValueError: Unable to parse string "Undetermined" en la carga

Tu export trae un texto que no está en SIN_CT (o con otra grafía: "UNDETERMINED" pasa porque comparamos en minúsculas, pero "No Amp" no). Es el diseño funcionando: el programa se niega a adivinar. Agrega el caso a tests/data/placa_min.csv, haz fallar el test, agrega el texto a SIN_CT, verde. Ese es el ciclo completo de "apareció un dato nuevo".

EsquemaInvalido: faltan columnas ['Ct'] pero mi archivo sí tiene Ct

Mira la segunda parte del mensaje: el archivo tiene [...]. Casi siempre la columna real se llama distinto (Cq, CT Mean) o el CSV usa ; como separador (todo quedó en una sola columna). Para separador: pd.read_csv(ruta, sep=";") — mejor aún, detecta el separador y decide dónde vive esa lógica (pista: en load.py, con su test).

El gráfico sale vacío o el proceso muere en Docker con algo de display

Falta matplotlib.use("Agg") antes de importar pyplot, o lo pusiste después. El orden importa: por eso el # noqa: E402 en ese import — le decimos a ruff "sé que este import no está arriba; es a propósito". Un noqa siempre lleva una razón al lado.

test_ddct_y_fold_a_mano falla por decimales (23.999999 != 24.0)

Comparaste floats con == en algún test tuyo nuevo. Usa pytest.approx. Si el que falla es el de la guía, el bug está en el código: revisa que promedio_por_replica use mean y no otra agregación.

En CI falla ruff format --check pero local todo pasa

Local corriste ruff format (que arregla) y olvidaste commitear el resultado, o nunca lo corriste. CI usa --check: no arregla, solo verifica. Corre uv run ruff format ., mira el diff con git diff, commit. Nota el patrón: CI nunca modifica nada, solo juzga.

docker run dice no existe el archivo: /datos/placa.csv

El contenedor solo ve lo que montaste. Revisa: ¿el -v "$PWD:/datos" está antes del nombre de la imagen? ¿el archivo está en la carpeta desde donde corres el comando? ¿la ruta dentro del contenedor empieza con /datos/? Corre docker run --rm -v "$PWD:/datos" --entrypoint ls reporte-placa /datos para ver qué ve el contenedor.

El promedio da distinto que mi Excel

Hipótesis en orden: (1) Excel incluyó el pocillo Undetermined como 0 o 40 y tú como NaN — mira la columna n; (2) Excel promedió sobre otra selección de celdas; (3) redondeo de presentación (el xlsx guarda el valor completo, el md muestra 2 decimales). Comprueba con 3 pocillos a mano antes de sospechar de pandas.

Listo cuando

Las mismas casillas que en el índice; marcarlas aquí las marca allá.

La pregunta de Rodrigo en la sesión

"Llega un export con un pocillo que dice No Amp. Cuéntame, sin abrir el editor, qué hace tu programa hoy, qué debería hacer, y en qué archivo y con qué test lo cambias." Si la respuesta sale fluida, el concepto de validar en el borde ya es tuyo.

Siguiente

Tu reporte lee un archivo y muere. En el Proyecto 3 · Inventario en SQLite los datos empiezan a persistir: esquema, claves foráneas, transacciones y migraciones — todo lo que Excel nunca te dio. Es el arranque del sistema que vas a hacer crecer hasta el Proyecto 10.

Si te quedaste con ganas: agrega a load.py soporte para el formato ancho (una columna por réplica) que exportan algunos equipos, con su fixture y sus tests. Es el mismo músculo, una vuelta más.