Apuntes DAM
Volver al inicio

Planificar un proyecto con PERT y el camino crítico, con su diagrama de Gantt

Ejercicio de PythonDifícilUnos 80 minutos

Planifica el desarrollo de una aplicación: duraciones PERT a partir de tres estimaciones, orden de las tareas con sus dependencias (y detección de ciclos), fechas más tempranas y más tardías, holguras, camino crítico, probabilidad de cumplir el plazo y un diagrama de Gantt en texto.

  • Planificación de proyectos de software
  • PERT: estimación de tres valores
  • CPM: camino crítico y holguras
  • Orden topológico y ciclos
  • Distribución normal (math.erf)
  • Diagrama de Gantt

Enunciado

Antes de empezar un proyecto hay que responder a dos preguntas: cuánto va a durar y qué tareas no pueden retrasarse ni un día. Como nadie sabe cuánto dura una tarea, PERT pide tres estimaciones (optimista O, más probable M y pesimista P) y usa la media (O + 4M + P) / 6, con una desviación (P − O) / 6. Con las dependencias entre tareas, el método del camino crítico (CPM) calcula cuándo puede empezar cada una como pronto y como tarde.

La diferencia entre las dos fechas es la holgura: lo que una tarea puede retrasarse sin retrasar el proyecto. Las de holgura 0 forman el camino crítico. Cada línea de la entrada es una tarea: ID O M P dependencias nombre (las dependencias separadas por comas, o - si no tiene), y puede haber una línea plazo N con los días disponibles.

Qué tiene que hacer el programa

  1. Una línea con menos de seis datos, estimaciones que no son números (admiten coma decimal) o un ID repetido: Línea N: no se entiende «línea»; estimaciones que no cumplen 0 ≤ O ≤ M ≤ P: Línea N: la tarea X tiene estimaciones incoherentes (deben cumplir 0 ≤ O ≤ M ≤ P). Después, cada dependencia que no existe: La tarea X depende de Y, que no existe. Si hubo algún error de estos: No se puede planificar y nada más. Sin tareas: No hay tareas.
  2. Si las dependencias forman un ciclo: Hay un ciclo de dependencias: A → B → C → A (el primero que se encuentra recorriendo las tareas en el orden de entrada y sus dependencias en su orden, con un recorrido en profundidad) y nada más.
  3. Duración de cada tarea: (O + 4M + P) / 6. Inicio temprano: el mayor fin de sus dependencias (0 si no tiene); fin: inicio + duración. La duración del proyecto es el mayor fin. Hacia atrás, el fin tardío de una tarea es el menor inicio tardío de las que dependen de ella (o la duración del proyecto si no hay ninguna); inicio tardío = fin tardío − duración; holgura = inicio tardío − inicio. Son críticas las de holgura 0. La tabla (ya escrita en tabla) va en el orden de entrada.
  4. Duración prevista: T días (σ = S), donde S es la raíz de la suma de las varianzas ((P − O) / 6)² de las tareas del camino crítico; Camino crítico: A → B → …, que se reconstruye hacia atrás desde la primera tarea crítica (en orden topológico) que acaba al final, eligiendo cada vez la primera dependencia crítica que acaba justo cuando empieza la tarea. Con plazo: Probabilidad de acabar en N días: X % con la normal 50 · (1 + erf((N − T) / (S · √2))) (si S es 0: 100 o 0). Todos los números con un decimal y coma.
  5. Al final, Gantt (# crítica, = no crítica, . holgura): y una fila por tarea en el orden de entrada: el ID en 6 columnas y un carácter por día (de 0 hasta la duración del proyecto redondeada hacia arriba): # (crítica) o = si el día [d, d + 1) se solapa con la tarea, . si se solapa con su holgura (entre el fin y el fin tardío), y espacio si no; sin espacios al final.

Entrada

Una tarea por línea: ID O M P dependencias nombre (- si no tiene dependencias).

Opcional: plazo N.

Datos de referencia

Las tareas del ejemplo
IDOMPDepende deTarea
A234-Requisitos
B359ADiseño de la base de datos
C246ADiseño de la interfaz
D5814BBackend y API
E468CFrontend
F235D,EIntegración y pruebas
G112CManual de usuario

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Una aplicación web

Entrada

A 2 3 4 - Requisitos
B 3 5 9 A Diseño de la base de datos
C 2 4 6 A Diseño de la interfaz
D 5 8 14 B Backend y API
E 4 6 8 C Frontend
F 2 3 5 D,E Integración y pruebas
G 1 1 2 C Manual de usuario
plazo 22

Salida por consola

Tarea  Duración  Inicio     Fin  Holgura
A           3,0     0,0     3,0      0,0  *
B           5,3     3,0     8,3      0,0  *
C           4,0     3,0     7,0      3,8
D           8,5     8,3    16,8      0,0  *
E           6,0     7,0    13,0      3,8
F           3,2    16,8    20,0      0,0  *
G           1,2     7,0     8,2     11,8
Duración prevista: 20,0 días (σ = 1,9)
Camino crítico: A → B → D → F
Probabilidad de acabar en 22 días: 85,4 %
Gantt (# crítica, = no crítica, . holgura):
A     ###
B        ######
C        ====....
D             #########
E            ======....
F                     ####
G            ==...........

Errores en las tareas

Entrada

A 1 2 3 - Uno
B 2 1 3 A Dos
C 1 2 X A Tres
D 1 2 3 Z Cuatro
A 1 1 1 - Repetida
=====
X 1 2 3 Z Equis

Salida por consola

Línea 2: la tarea B tiene estimaciones incoherentes (deben cumplir 0 ≤ O ≤ M ≤ P)
Línea 3: no se entiende «C 1 2 X A Tres»
Línea 5: no se entiende «A 1 1 1 - Repetida»
Línea 6: no se entiende «=====»
La tarea D depende de Z, que no existe
La tarea X depende de Z, que no existe
No se puede planificar

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Primero, un orden válido

Un recorrido en profundidad que añade cada tarea después de sus dependencias da un orden topológico. Si al bajar encuentras una tarea que está «en curso», hay un ciclo: el camino desde ella hasta aquí.

python
def visitar(t, camino):
    if estado.get(t) == "en curso":
        return camino[camino.index(t):] + [t]
    ...
2. Dos pasadas

Hacia delante, en orden topológico, las fechas más tempranas; hacia atrás, en el orden inverso, las más tardías. Cada pasada solo mira a las tareas vecinas.

3. El camino y su incertidumbre

Las varianzas de tareas seguidas se suman (la desviación no): σ es la raíz de la suma. Con math.erf se calcula la probabilidad de la normal sin ninguna librería.

4. El Gantt

Cada día es una columna; compara el intervalo del día con el de la tarea y con el de su holgura. Con fracciones de día, un día cuenta si la tarea ocupa cualquier parte de él.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 3 casos ocultos que buscan los errores típicos.

🐍PythonPlanificar un proyecto con PERT y el camino crítico, con su diagrama de GanttDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
A 2 3 4 - Requisitos
B 3 5 9 A Diseño de la base de datos
C 2 4 6 A Diseño de la interfaz
D 5 8 14 B Backend y API
E 4 6 8 C Frontend
F 2 3 5 D,E Integración y pruebas
G 1 1 2 C Manual de usuario
plazo 22
Salida esperada
Tarea  Duración  Inicio     Fin  Holgura
A           3,0     0,0     3,0      0,0  *
B           5,3     3,0     8,3      0,0  *
C           4,0     3,0     7,0      3,8
D           8,5     8,3    16,8      0,0  *
E           6,0     7,0    13,0      3,8
F           3,2    16,8    20,0      0,0  *
G           1,2     7,0     8,2     11,8
Duración prevista: 20,0 días (σ = 1,9)
Camino crítico: A → B → D → F
Probabilidad de acabar en 22 días: 85,4 %
Gantt (# crítica, = no crítica, . holgura):
A     ###
B        ######
C        ====....
D             #########
E            ======....
F                     ####
G            ==...........
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
python
1import math
2import sys
3
4
5def num(x):
6    """Un número con un decimal y coma: 4,2 (los restos de redondeo como -0,0000001 son 0)"""
7    if abs(x) < 1e-9:
8        x = 0.0
9    return f"{x:.1f}".replace(".", ",")
10
11
12def tabla(tareas, orden, plan):
13    print("Tarea  Duración  Inicio     Fin  Holgura")
14    for t in orden:
15        p = plan[t]
16        print(f"{t:<6}{num(p['dur']):>9}{num(p['ini']):>8}{num(p['fin']):>8}{num(p['holgura']):>9}{'  *' if p['critica'] else ''}")
17
18def leer(lineas):
19    """{id: (o, m, p, [dependencias], nombre)} en el orden de entrada, o None si hay errores."""
20    tareas, plazo, ok = {}, None, True
21    for n, linea in enumerate(lineas, 1):
22        p = linea.split()
23        if not p:
24            continue
25        if p[0] == "plazo" and len(p) == 2 and p[1].isdigit():
26            plazo = int(p[1])
27            continue
28        try:
29            o, m, pe = (float(x.replace(",", ".")) for x in p[1:4])
30            assert len(p) >= 6 and p[0] not in tareas
31        except (ValueError, AssertionError):
32            print(f"Línea {n}: no se entiende «{linea.strip()}»")
33            ok = False
34            continue
35        if not 0 <= o <= m <= pe:
36            print(f"Línea {n}: la tarea {p[0]} tiene estimaciones incoherentes (deben cumplir 0 ≤ O ≤ M ≤ P)")
37            ok = False
38            continue
39        deps = [] if p[4] == "-" else p[4].split(",")
40        tareas[p[0]] = (o, m, pe, deps, " ".join(p[5:]))
41    for t, (_, _, _, deps, _) in tareas.items():
42        for d in deps:
43            if d not in tareas:
44                print(f"La tarea {t} depende de {d}, que no existe")
45                ok = False
46    return (tareas, plazo) if ok else (None, None)
47
48
49def orden_topologico(tareas):
50    """Las tareas en un orden en que cada una va después de sus dependencias, o el ciclo si lo hay."""
51    estado, orden = {}, []
52
53    def visitar(t, camino):
54        if estado.get(t) == "hecha":
55            return None
56        if estado.get(t) == "en curso":
57            return camino[camino.index(t):] + [t]
58        estado[t] = "en curso"
59        for d in tareas[t][3]:
60            ciclo = visitar(d, camino + [t])
61            if ciclo:
62                return ciclo
63        estado[t] = "hecha"
64        orden.append(t)
65        return None
66
67    for t in tareas:
68        ciclo = visitar(t, [])
69        if ciclo:
70            return None, ciclo
71    return orden, None
72
73
74def calcular(tareas, orden):
75    plan = {}
76    for t in orden:                                   # pasada hacia delante: lo antes posible
77        o, m, p, deps, _ = tareas[t]
78        dur = (o + 4 * m + p) / 6
79        ini = max((plan[d]["fin"] for d in deps), default=0)
80        plan[t] = {"dur": dur, "var": ((p - o) / 6) ** 2, "ini": ini, "fin": ini + dur}
81    total = max(p["fin"] for p in plan.values())
82    sucesores = {t: [s for s in orden if t in tareas[s][3]] for t in orden}
83    for t in reversed(orden):                         # pasada hacia atrás: lo más tarde posible
84        fin_tarde = min((plan[s]["ini_tarde"] for s in sucesores[t]), default=total)
85        plan[t]["ini_tarde"] = fin_tarde - plan[t]["dur"]
86        plan[t]["holgura"] = plan[t]["ini_tarde"] - plan[t]["ini"]
87        plan[t]["critica"] = abs(plan[t]["holgura"]) < 1e-9
88        plan[t]["fin_tarde"] = fin_tarde
89    return plan, total
90
91
92def camino_critico(tareas, orden, plan, total):
93    """Desde la primera tarea crítica que acaba al final, hacia atrás por predecesoras críticas encadenadas."""
94    actual = next(t for t in orden if plan[t]["critica"] and abs(plan[t]["fin"] - total) < 1e-9)
95    camino = [actual]
96    while True:
97        previa = next((d for d in tareas[actual][3] if plan[d]["critica"] and abs(plan[d]["fin"] - plan[actual]["ini"]) < 1e-9), None)
98        if previa is None:
99            return list(reversed(camino))
100        camino.append(previa)
101        actual = previa
102
103
104def fila_gantt(p, dias):
105    """# o = los días de la tarea, . los de holgura: un día d cuenta si [d, d+1) se solapa con el intervalo."""
106    fila = ""
107    for d in range(dias):
108        if d < p["fin"] - 1e-9 and d + 1 > p["ini"] + 1e-9:
109            fila += "#" if p["critica"] else "="
110        elif d < p["fin_tarde"] - 1e-9 and d + 1 > p["fin"] + 1e-9:
111            fila += "."
112        else:
113            fila += " "
114    return fila.rstrip()
115
116
117def main():
118    tareas, plazo = leer(sys.stdin.read().splitlines())
119    if tareas is None:
120        print("No se puede planificar")
121        return
122    if not tareas:
123        print("No hay tareas")
124        return
125    orden, ciclo = orden_topologico(tareas)
126    if ciclo:
127        print("Hay un ciclo de dependencias: " + " → ".join(ciclo))
128        return
129    plan, total = calcular(tareas, orden)
130    tabla(tareas, list(tareas), plan)
131    camino = camino_critico(tareas, orden, plan, total)
132    sigma = math.sqrt(sum(plan[t]["var"] for t in camino))
133    print(f"Duración prevista: {num(total)} días (σ = {num(sigma)})")
134    print("Camino crítico: " + " → ".join(camino))
135    if plazo is not None:
136        prob = 100.0 if sigma == 0 and plazo >= total else 0.0 if sigma == 0 else 50 * (1 + math.erf((plazo - total) / (sigma * math.sqrt(2))))
137        print(f"Probabilidad de acabar en {plazo} días: {num(prob)} %")
138    print("Gantt (# crítica, = no crítica, . holgura):")
139    dias = math.ceil(total - 1e-9)
140    for t in tareas:
141        print(f"{t:<6}{fila_gantt(plan[t], dias)}".rstrip())
142
143
144main()

La media de PERT da más peso al valor más probable, pero tiene en cuenta los extremos: una tarea con un pesimista muy alto alarga la estimación aunque lo normal sea rápido. Es la forma de convertir la incertidumbre en números.

El camino crítico dice dónde poner la atención: retrasar una tarea crítica retrasa el proyecto; una con holgura puede esperar. Añadir gente a una tarea que no es crítica no acorta nada.

La probabilidad de cumplir el plazo suele sorprender: un proyecto planificado justo en su duración prevista solo tiene un 50 % de acabar a tiempo. Por eso se añaden márgenes.

Las metodologías ágiles planifican por sprints y no por tareas con fechas, pero el camino crítico sigue apareciendo en las dependencias entre equipos y en la implantación de sistemas como un ERP.

Para ir más allá

  • Añade recursos: cada tarea necesita una persona y solo hay dos; retrasa lo necesario (nivelación).
  • Calcula el camino crítico más probable con una simulación de Monte Carlo (duraciones aleatorias entre O y P).
  • Exporta el plan a un CSV que se pueda abrir como diagrama de Gantt en una hoja de cálculo.

Dónde se explica