Una de las entradas que más gente trajo a este blog fue un tutorial de la función ÍNDICE de Excel; ya lo conté cuando lo reabrí. Lo escribí porque el problema era mío: la planilla de avance del residente por un lado, el metrado del expediente por el otro, y un jueves entero pasando valores de una hoja a la otra antes de la valorización.
Lo que no dije en ese post es que ÍNDICE resuelve un cruce. No resuelve el problema. El problema es que el cruce vuelve el mes siguiente, con las partidas escritas distinto, con espacios de más adelante del código, con partidas que aparecieron en obra y no están en el presupuesto. Eso no es una fórmula: es un procedimiento. Y los procedimientos se escriben una vez.
Cada bloque de código de abajo lo corrí antes de publicarlo, sobre un juego de datos que armé con los volúmenes de un expediente de obra escolar: 181 planos, 36 certificados de laboratorio, 69 filas de planilla. Nada más exótico que pandas, openpyxl, pypdf y la librería estándar.
1. Cruzar la planilla de campo con el metrado del expediente
import pandas as pd
exp = pd.read_excel("expediente/metrado_contractual.xlsx")
campo = pd.read_excel("campo/planilla_campo_agosto.xlsx")
for df in (exp, campo):
df["partida"] = df["partida"].astype(str).str.strip()
acum = campo.groupby("partida", as_index=False)["metrado"].sum()
c = exp.merge(acum, on="partida", how="outer", indicator=True)
c["avance_%"] = (100 * c["metrado"] / c["metrado_exp"]).round(1)
print(c.loc[c["_merge"] == "right_only", ["partida", "metrado"]]) # sin partida contractual
print(c.loc[c["avance_%"] > 100, ["partida", "avance_%"]]) # pasado del contractual
print(c.loc[c["_merge"] == "left_only", ["partida"]]) # sin avance reportado
La línea que parece de relleno, la del .str.strip(), es la que decide si esto sirve: en mi juego de datos la mitad de los códigos venían con un espacio adelante, como salen de verdad cuando alguien los pega desde otra hoja. Sin ella, el cruce no encuentra nada y uno concluye que Python no funciona.
Lo importante es el how="outer" con indicator=True. Un BUSCARV te dice qué encontró; esto te dice además qué no encontró, en las dos direcciones. Sobre mis 30 partidas salieron dos ejecutadas que no existen en el expediente, dos con metrado ejecutado por encima del contractual y dos del presupuesto sin ningún avance reportado. Las dos primeras son conversaciones incómodas con la supervisión; mejor tenerlas antes de firmar que después.
2. Renombrar y ordenar 181 planos sin abrir ninguno
import re, unicodedata
from pathlib import Path
CARPETA = {"A": "01_Arquitectura", "E": "02_Estructuras",
"IS": "03_Sanitarias", "IE": "04_Electricas"}
PATRON = re.compile(r"^(?:copia de )?(?:plano )?(IS|IE|A|E)[ _-]*(\d{1,3})(.*)$", re.I)
REV = re.compile(r"\brev\.?\s*\(?([A-Z])\)?", re.I)
def limpiar(t):
t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode()
t = REV.sub("", re.sub(r"\b(final|copia)\b", "", t, flags=re.I))
return re.sub(r"[\s_.()-]+", "-", t.strip()).strip("-").upper()
plan, raros = [], []
for f in sorted(Path("planos").glob("*.pdf")):
m = PATRON.match(f.stem.strip())
if not m:
raros.append(f.name)
continue
disc, num, resto = m.group(1).upper(), int(m.group(2)), m.group(3)
rev = REV.search(f.stem).group(1).upper() if REV.search(f.stem) else "0"
plan.append((f, Path("planos_ordenados") / CARPETA[disc] /
f"IE31501-{disc}-{num:03d}-{limpiar(resto)}-REV{rev}.pdf"))
destinos = [d for _, d in plan]
choques = {d for d in destinos if destinos.count(d) > 1}
print(f"{len(plan)} con patron, {len(choques)} choques, {len(raros)} sin patron: {raros}")
for origen, destino in plan:
if destino in choques:
print(f" CHOQUE {origen.name}")
continue
print(f" {origen.name} -> {destino}")
# destino.parent.mkdir(parents=True, exist_ok=True)
# origen.rename(destino) # descomentar recien cuando la lista se vea bien
Las dos últimas líneas comentadas son el punto de la sección. Un script que mueve archivos se escribe así: primero imprime lo que iba a hacer, uno lee la lista y recién después se le suelta la mano. Eso lo aprendí a la mala.
De los 181 archivos, 178 salieron ordenados solos. Uno quedó marcado como choque —el mismo plano guardado dos veces, uno de ellos con el prefijo "Copia de"— y tres no los tocó: un escaneado sin nombre, una memoria descriptiva y un sin nombre (2).pdf. Ese comportamiento es deliberado: lo que el patrón no entiende, no se renombra. Un script que adivina es peor que no tener script.
3. Sacar los datos de los certificados de rotura del laboratorio
import re
from pathlib import Path
import pandas as pd
from pypdf import PdfReader
CAMPOS = {"muestra": r"CODIGO DE MUESTRA:\s*([\w-]+)",
"probeta": r"CODIGO DE PROBETA:\s*([\w-]+)",
"elemento": r"ELEMENTO ESTRUCTURAL:\s*(.+)",
"vaciado": r"FECHA DE VACIADO:\s*([\d-]+)",
"fc_dis": r"f'c:\s*(\d+)",
"fc_obt": r"RESISTENCIA OBTENIDA:\s*([\d.]+)"}
filas, ilegibles = [], []
for pdf in sorted(Path("ensayos").glob("*.pdf")):
texto = "\n".join(p.extract_text() or "" for p in PdfReader(pdf).pages)
fila = {"archivo": pdf.name}
for campo, patron in CAMPOS.items():
m = re.search(patron, texto)
fila[campo] = m.group(1).strip() if m else None
(filas if all(fila[c] for c in CAMPOS) else ilegibles).append(fila)
df = pd.DataFrame(filas).astype({"fc_dis": float, "fc_obt": float})
df.to_excel("salida/ensayos_concreto.xlsx", index=False)
print(f"{len(df)} certificados leidos, {len(ilegibles)} para revisar a mano")
Treinta y seis certificados, cinco datos cada uno, una sola tabla. Con una condición dura: esto funciona si el PDF tiene texto. Si el laboratorio manda el certificado escaneado —una foto dentro de un PDF—, pypdf devuelve vacío y el archivo cae en la lista de ilegibles, que es exactamente lo que uno quiere: que avise en vez de inventar.
4. Armar el cuadro de control de concreto y hacer que se verifique solo
El cuadro repetitivo lo escribe openpyxl: trece hojas con el mismo encabezado, los mismos anchos de columna y el mismo formato, en medio segundo. Pero lo que de verdad paga es la hoja resumen, porque ahí se puede meter el criterio de aceptación de la Norma E.060 de Concreto Armado:
import pandas as pd
MPA = 10.1972 # 1 MPa en kg/cm2
TOL, FC_ALTO = 3.5 * MPA, 35 * MPA # E.060 5.6.3.3(b): la norma esta en MPa, no en kg/cm2
df = pd.read_excel("salida/ensayos_concreto.xlsx")
# un ENSAYO es el promedio de dos probetas de la misma muestra (E.060 5.6.3.2)
ens = (df.groupby(["muestra", "vaciado", "fc_dis"], as_index=False)
.agg(ensayo=("fc_obt", "mean")).sort_values("vaciado"))
ens["ensayo"] = ens.ensayo.round(1)
# (b) ningun ensayo por debajo de f'c menos la tolerancia
ens["tol"] = ens.fc_dis.apply(lambda f: 0.1 * f if f > FC_ALTO else TOL)
ens["cumple_b"] = ens.ensayo >= ens.fc_dis - ens.tol
# (a) el promedio de tres ensayos consecutivos, por clase de concreto
ens["prom_3"] = ens.groupby("fc_dis").ensayo.transform(
lambda s: s.rolling(3).mean().round(1))
ens["cumple_a"] = ens.prom_3.isna() | (ens.prom_3 >= ens.fc_dis)
print(ens.loc[~(ens.cumple_a & ens.cumple_b),
["muestra", "fc_dis", "ensayo", "prom_3"]].to_string(index=False))
Dos detalles del artículo 5.6.3 que vale la pena mirar de cerca. Para la E.060, "un ensayo de resistencia" es el promedio de dos probetas de la misma muestra, no el resultado de una. Y el texto legal está en megapascales: dice que ningún ensayo puede quedar por debajo de f'c en más de 3,5 MPa —unos 35,7 kg/cm²—, con otra regla, 0,1 f'c, cuando f'c pasa de 35 MPa. El "35 kg/cm²" que todos citamos, yo incluido, es una conversión redondeada.
En mis datos el resultado fue el que no se ve a ojo: tres ventanas de tres ensayos consecutivos quedaron por debajo de f'c —la peor en 201,7 contra 210— y sin embargo ningún ensayo individual incumplió. Una de esas muestras dio 216,4 kg/cm², por encima del diseño, y su ventana igual no cumple: las dos anteriores la hundieron. Un residente revisando una columna de porcentajes no encuentra eso nunca, porque la regla no vive en la fila sino en el grupo de tres.
5. Verificar la distorsión de entrepiso contra la E.030
import csv
# Los limites viven aca, con la version de la norma al lado, porque cambian.
LIMITE = {
"2026": {"concreto armado": 0.007, "acero": 0.010, "albanileria": 0.005,
"madera": 0.010, "ductilidad limitada": 0.004}, # Art. 51, Tabla 14
"2018": {"concreto armado": 0.007, "acero": 0.010, "albanileria": 0.005,
"madera": 0.010, "ductilidad limitada": 0.005}, # Art. 32, Tabla 11
}
NORMA, MATERIAL, R, REGULAR = "2026", "concreto armado", 8.0, True
FACTOR = (0.75 if REGULAR else 0.85) * R # E.030 2026, Art. 50.1 y 50.2
with open("expediente/desplazamientos_modal.csv", encoding="utf-8") as f:
pisos = [(r["piso"], float(r["h_m"]), float(r["ux_cm"]), float(r["uy_cm"]))
for r in csv.DictReader(f)]
lim = LIMITE[NORMA][MATERIAL]
for (piso, h, ux, uy), (_, _, ux0, uy0) in zip(pisos, pisos[1:]):
dx, dy = FACTOR * (ux - ux0) / (100 * h), FACTOR * (uy - uy0) / (100 * h)
peor = max(dx, dy)
estado = f"EXCEDE por {100 * peor / lim - 100:.1f} %" if peor > lim else "cumple"
print(f"{piso:8s} h={h:.2f} X={dx:.5f} Y={dy:.5f} {estado}")

Ese diccionario con dos versiones de la norma adentro no es pedantería. La NT E.030 vigente se aprobó por RM 183-2026-VIVIENDA el 3 de mayo de este año, y ahí el límite de los edificios de muros de ductilidad limitada bajó de 0,005 a 0,004. Los otros cuatro materiales quedaron igual. Si tu planilla de verificación tiene el 0,005 escrito a mano en una celda de 2019, hoy aprueba edificios que la norma vigente rechaza.
Se movieron dos cosas más. El requisito de los desplazamientos, que era el numeral 5.1 en 2016 y el artículo 31 en 2018, hoy es el artículo 50; los límites pasaron de la Tabla N° 11 a la N° 14. Y el factor para estructuras irregulares dejó de ser R: desde la RM 355-2018-VIVIENDA es 0,85R, y la 2026 lo mantiene. El PDF de la E.030 de 2016 todavía circula en carpetas de universidad con el factor viejo adentro.
Cuánto de esto es ahorro real

Los puntos azules son medición: mediana de siete corridas del script completo, arranque del intérprete incluido. Los grises son mi estimación, y publico la tasa que usé para que se pueda discutir: 40 minutos el cruce de la planilla, 12 segundos por plano, 90 segundos por certificado, 4 minutos por hoja de Excel, 15 minutos las derivas. Si te parecen generosos o mezquinos, cámbialos: el orden de magnitud no se mueve.
Las cinco juntas dan 3 h 17 min a mano contra 1,8 segundos de máquina, y 157 líneas de Python. Escribirlas y probarlas me tomó del orden de una mañana —eso no lo cronometré, así que tómalo como lo que es—, de modo que se pagan en la segunda valorización. Un script para una tarea que vas a hacer una sola vez es una pérdida de tiempo con buena prensa.
Lo que yo no automatizaría
Automatizar tiene un costo fijo que se recupera con la repetición. De ahí salen las excepciones.
- Lo que se hace una vez. El cuadro único, la tabla que nunca vuelve. A mano y a otra cosa.
- Lo que exige criterio. El script encuentra que la partida 02.04.01 se ejecutó sin estar en el expediente. Si eso es un adicional que procede, un error de digitación o un problema de verdad, lo decide un ingeniero. Detectar y decidir son cosas distintas, y la segunda no se delega.
- Lo que no puedas verificar por muestreo. Si no puedes tomar cinco resultados al azar y comprobarlos a mano, no tienes una herramienta: tienes un oráculo. A mis 178 planos renombrados les revisé una decena.
- La norma en transición. La misma RM 183-2026, y después la RM 217-2026-VIVIENDA, permiten que proyectos ya en marcha terminen con el texto anterior. Un script que solo conoce la tabla nueva le da el veredicto equivocado a un expediente que legalmente se rige por la vieja. Por eso el diccionario tiene las dos versiones y hay que elegir a mano cuál aplica.
- La firma. Ninguno de estos scripts asume responsabilidad profesional. La asume quien firma, y por eso tiene que entender qué hace cada línea.
Mi postura, después de años de hacer las dos cosas: Python no te hace mejor ingeniero, te devuelve las horas en las que podrías serlo. El jueves de la planilla no desaparece; se convierte en un jueves en el que miras las dos partidas raras en vez de teclear ciento cincuenta que estaban bien. Y aparece un efecto que no esperaba: cuando el cruce lo hace la máquina, uno empieza a hacerle preguntas que antes no valían el esfuerzo —cómo viene la resistencia vaciado por vaciado, qué frente atrasa siempre—, y eso ya es análisis, no oficina técnica.
¿Cuál es la tarea que tú repetiste esta semana y que una computadora debería estar haciendo? Cuéntamela en los comentarios: si es común, la próxima vez la resolvemos con código acá.
0 Comentarios