Spaces:
Runtime error
Runtime error
File size: 23,617 Bytes
7599506 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 | """
engine.py — Motor CPM estocástico V6 (núcleo de cálculo de CHRONOFLUX).
Es una copia FIEL de simular_cronograma() y sus auxiliares de
app_tesis_final_V3.py. La única diferencia: la detección de ciclos ya no llama a
st.error()/st.stop() (que dependen de Streamlit) sino que lanza CicloLogicoError.
Toda la aritmética —forward pass, backward pass, deuda de secado multi-día,
cuantización, holguras, ruta crítica— es byte a byte idéntica al original.
"""
import math
import re
from datetime import timedelta
import pandas as pd
import networkx as nx
from .climate import es_habil
from .models import calcular_ic_ia, calcular_tr_y_ic_dinamico
class CicloLogicoError(Exception):
"""Se lanza cuando la red de precedencias no es acíclica (requisito §7.4)."""
def __init__(self, detalle: str):
self.detalle = detalle
super().__init__(
f"Bucle lógico detectado en la red de precedencias (IDs: {detalle}). "
"La condición acíclica del DAG es un requisito del motor (Capítulo 7.4)."
)
def detalle_del_ciclo(G) -> str:
try:
ciclo = nx.find_cycle(G)
return " → ".join(str(a) for a, _ in ciclo) + f" → {ciclo[0][0]}"
except Exception:
return "no identificable"
# C-03: desplazamiento en días HÁBILES (no calendario)
def contar_dias_habiles_shift(desde, hasta, dias_idx, feriados):
"""Cuenta cuántos días hábiles hay entre 'desde' (exclusive) y 'hasta' (inclusive)."""
if desde is None or hasta is None or hasta <= desde:
return 0
c = desde
n = 0
while c < hasta:
c += timedelta(days=1)
if es_habil(c, dias_idx, feriados):
n += 1
return n
def avanzar_habiles(fecha, n, dias_idx, feriados):
"""Avanza 'n' días hábiles desde 'fecha' (inverso de contar_dias_habiles_shift)."""
d = fecha
if n <= 0:
while not es_habil(d, dias_idx, feriados):
d += timedelta(days=1)
return d
c = 0
while c < n:
d += timedelta(days=1)
if es_habil(d, dias_idx, feriados):
c += 1
return d
def pool_ventana_climatica(cursor, clima, W):
"""Agrupa los registros históricos de ±W días alrededor de una fecha-calendario."""
pool = []
for off in range(-W, W + 1):
kk = (cursor + timedelta(days=off)).strftime('%m-%d')
h = clima.get(kk)
if h:
v = h.get('valores_mm')
if v:
pool.extend(v)
return pool
def redondear_duracion(val):
return round(float(val), 2)
def simular_cronograma(df, clima, prob_min, mm_min, dias_idx, feriados, reparar,
umbral_horas, h_inicio, h_fin, use_nlp, use_ml,
temp_global, hum_global, usar_clima_real=False, ventana_dias=7):
G = nx.DiGraph()
for _, row in df.iterrows():
tid = row['ID']
G.add_node(tid, data=row.to_dict())
new_preds = str(row['OrigPreds']) if pd.notna(row['OrigPreds']) else ""
if reparar == "Automática" and "Falta Predecesora" in row['Errores']:
match = re.search(r'ID (\d+)', row['Errores'])
if match:
new_preds = match.group(1)
G.nodes[tid]['new_preds'] = new_preds
if new_preds.strip():
for p in new_preds.split(','):
p = p.strip()
if p.isdigit() and int(p) != tid:
G.add_edge(int(p), tid)
# [AUD-02] Blindaje acíclico (§7.4): un ciclo lógico no tiene solución topológica.
try:
orden = list(nx.topological_sort(G))
except nx.NetworkXUnfeasible:
raise CicloLogicoError(detalle_del_ciclo(G))
fecha_fin_calculada = {}
fecha_inicio_calculada = {}
res_temp = {}
for tid in orden:
row = G.nodes[tid]['data']
new_preds = G.nodes[tid]['new_preds']
note = "Corregido Auto" if (reparar == "Automática" and "Falta Predecesora" in row['Errores']) else row['Errores']
start_dt = pd.to_datetime(row['Start_XML']).date() if pd.notna(row['Start_XML']) else None
finish_dt = pd.to_datetime(row['Finish_XML']).date() if pd.notna(row['Finish_XML']) else None
base_dur_float = float(row['Duration_Days'])
# CPM TIPO-CONSCIENTE (Opción B): FS/FF/SF propagan el retraso del FIN del
# predecesor; SS propaga el del INICIO.
pred_links = row['PredLinks'] if isinstance(row.get('PredLinks'), list) else []
tipados = {pid for pid, _ in pred_links}
for p in [int(x.strip()) for x in new_preds.split(',') if x.strip().isdigit()]:
if p not in tipados:
pred_links = pred_links + [(p, 'FS')]
max_shift_dias = 0
if pred_links and start_dt:
for p, ltype in pred_links:
if not G.has_node(p):
continue
if ltype == 'SS':
ini_base_pred = G.nodes[p]['data']['Start_XML']
ini_base_pred = pd.to_datetime(ini_base_pred).date() if pd.notna(ini_base_pred) else None
ini_new_pred = fecha_inicio_calculada.get(p)
if ini_base_pred and ini_new_pred:
shift = contar_dias_habiles_shift(ini_base_pred, ini_new_pred, dias_idx, feriados)
if shift > max_shift_dias:
max_shift_dias = shift
else: # FS / FF / SF -> retraso del fin del predecesor
if fecha_fin_calculada.get(p) is not None:
fin_base_pred = G.nodes[p]['data']['Finish_XML']
fin_base_pred = pd.to_datetime(fin_base_pred).date() if pd.notna(fin_base_pred) else None
if fin_base_pred:
shift = contar_dias_habiles_shift(fin_base_pred, fecha_fin_calculada[p], dias_idx, feriados)
if shift > max_shift_dias:
max_shift_dias = shift
new_start = start_dt
if max_shift_dias > 0 and start_dt:
new_start = avanzar_habiles(start_dt, max_shift_dias, dias_idx, feriados)
if new_start:
fecha_inicio_calculada[tid] = new_start
new_finish = finish_dt
new_dur_float = base_dur_float
stats_prob = 0.0; prob_acumulada = 0.0; dias_evaluados = 0; prob_pico = 0.0
stats_mm = 0; rain_total = 0.0; retraso_teorico_dias = 0.0; last_rain_date = None
ic_base = calcular_ic_ia(row['Name'], use_nlp)
tr_horas_max = 0.0
ic_dinamico_max = ic_base
if not row['IsSummary'] and not row['IsMilestone'] and new_start:
work_needed = math.ceil(base_dur_float) if base_dur_float > 0 else 1
work_done = 0; cursor = new_start
# --- MOTOR ESTOCÁSTICO V6: DEUDA DE SECADO MULTI-DÍA (Ecs. 6.23–6.26) ---
lluvia_acumulada_terreno = 0.0 # humedad del suelo (mm equivalentes)
deuda_secado_horas = 0.0 # horas de inoperatividad pendientes
prob_vigente = 0.0 # P(d|Ur) del evento que originó la deuda activa
ic_vigente = 1.0 # Q(Ic) severidad vigente (NLP·ML) — Ec. 6.22
horas_jornada = float(h_fin - h_inicio) if h_fin > h_inicio else 8.0
while work_done < work_needed:
if es_habil(cursor, dias_idx, feriados):
k = cursor.strftime('%m-%d')
if k in clima:
h = clima[k]
# P(d) = n/N (Ec. 6.14b). Ur se aplica POR SEPARADO sobre la magnitud.
if ventana_dias and ventana_dias > 0:
muestra = pool_ventana_climatica(cursor, clima, ventana_dias)
else:
muestra = h.get('valores_mm', None)
RAIN_REF = 1.0 # mm: define "llovió ese día"
if muestra:
n = len(muestra)
dias_lluvia = [v for v in muestra if v >= RAIN_REF]
prob_dia = len(dias_lluvia) / n if n > 0 else 0.0
mm_evento = (sum(dias_lluvia) / len(dias_lluvia)) if dias_lluvia else 0.0
else:
prob_dia = h.get('probabilidad', 0.0)
mm_evento = h.get('mm_promedio', 0.0)
# AE-04: fuente termodinámica (ERA5 real por defecto / override).
if usar_clima_real:
temp_d = h.get('temp_dia', temp_global)
hum_d = h.get('hum_dia', hum_global)
else:
temp_d = temp_global
hum_d = hum_global
rain_total += h.get('mm_promedio', 0.0)
prob_acumulada += prob_dia
prob_pico = max(prob_pico, prob_dia)
dias_evaluados += 1
tasa_evaporacion = max(0.1, (temp_d / 10.0) * ((100.0 - hum_d) / 20.0))
# GATE 1 (Pr) + GATE 2 (Ur)
if prob_dia >= prob_min and mm_evento >= mm_min:
lluvia_acumulada_terreno = max(0.0, lluvia_acumulada_terreno + mm_evento - tasa_evaporacion)
stats_mm = max(stats_mm, mm_evento)
if h['ultima_fecha_lluvia']:
last_rain_date = h['ultima_fecha_lluvia'].date()
tr_horas, ic_dinamico = calcular_tr_y_ic_dinamico(lluvia_acumulada_terreno, temp_d, hum_d, ic_base, use_ml)
tr_horas_max = max(tr_horas_max, tr_horas)
ic_dinamico_max = max(ic_dinamico_max, ic_dinamico)
horas_lluvia = mm_evento / 5.0 # intensidad de referencia 5 mm/h
deuda_secado_horas = max(deuda_secado_horas, tr_horas) + horas_lluvia
prob_vigente = prob_dia
ic_vigente = ic_dinamico
else:
lluvia_acumulada_terreno = max(0.0, lluvia_acumulada_terreno - tasa_evaporacion)
# AE-01: PÉRDIDA FRACCIONAL CONTINUA DE LA JORNADA (Ec. 6.26)
if deuda_secado_horas > 1e-6:
horas_perdidas_hoy = min(horas_jornada, deuda_secado_horas)
ventana_util = max(0.5, horas_jornada - umbral_horas)
fraccion_perdida = min(1.0, horas_perdidas_hoy / ventana_util)
retraso_teorico_dias += prob_vigente * fraccion_perdida * ic_vigente
deuda_secado_horas = max(0.0, deuda_secado_horas - horas_jornada)
work_done += 1
cursor += timedelta(days=1)
stats_prob = prob_pico if dias_evaluados > 0 else 0
stats_prob_media = (prob_acumulada / dias_evaluados) if dias_evaluados > 0 else 0 # noqa: F841
nota_cuantizacion = ""
total_cuantizado = base_dur_float
if retraso_teorico_dias > 0:
total_cuantizado = base_dur_float + math.ceil(retraso_teorico_dias)
retraso_cuantizado = total_cuantizado - base_dur_float
if retraso_cuantizado != round(retraso_teorico_dias, 2):
nota_cuantizacion = f" (Q={round(retraso_cuantizado, 2)}d)"
else:
retraso_cuantizado = 0.0
if note == "OK" and retraso_cuantizado > 0:
note = f"Impacto Clima{nota_cuantizacion} [Ic={ic_dinamico_max}]"
elif note != "OK" and retraso_cuantizado > 0:
note += f" | Impacto Clima{nota_cuantizacion} [Ic={ic_dinamico_max}]"
dias_a_avanzar = math.ceil(total_cuantizado) if total_cuantizado > 0 else 1
cursor_fin = new_start; dias_avanzados = 1
while dias_avanzados < dias_a_avanzar:
cursor_fin += timedelta(days=1)
if es_habil(cursor_fin, dias_idx, feriados):
dias_avanzados += 1
new_finish = cursor_fin; new_dur_float = total_cuantizado
is_pushed_by_pred = (new_start > start_dt) if start_dt else False
if not is_pushed_by_pred and retraso_cuantizado == 0 and finish_dt:
new_finish = finish_dt; new_dur_float = base_dur_float
elif row['IsMilestone']:
new_dur_float = 0; stats_prob = 0
if new_start:
new_finish = new_start
fecha_fin_calculada[tid] = new_finish
G.nodes[tid]['ES'] = new_start; G.nodes[tid]['EF'] = new_finish; G.nodes[tid]['dur_ajustada'] = new_dur_float
res_temp[tid] = {
'ID': tid, 'WBS': row['WBS'], 'Actividad': row['Name'], 'IsSummary': row['IsSummary'], 'IsMilestone': row['IsMilestone'],
'Duración Base': redondear_duracion(base_dur_float), 'Inicio Base': start_dt, 'Fin Base': finish_dt,
'Duración Nueva': redondear_duracion(new_dur_float), 'Inicio Nuevo': new_start, 'Fin Nuevo': new_finish,
'Tr (Secado/Horas)': round(tr_horas_max, 1), 'Ic_Estimado': round(ic_dinamico_max, 2),
'Pred. Orig': row['OrigPreds'], 'Pred. Nueva': new_preds,
'Prob. Lluvia': f"{stats_prob:.0%}" if stats_prob > 0 else "-", 'mm Lluvia Max': round(stats_mm, 1) if stats_mm > 0 else "-",
'Lluvia Total Acum (mm)': round(rain_total, 1), 'Fecha Última Lluvia': last_rain_date if last_rain_date else "-",
'Días Impacto': redondear_duracion(new_dur_float) - redondear_duracion(base_dur_float), 'Estado': note,
'IsRain': ((redondear_duracion(new_dur_float) - redondear_duracion(base_dur_float)) > 0), 'IsLogic': (new_preds != row['OrigPreds'])
}
valid_efs = [data['EF'] for n, data in G.nodes(data=True) if data.get('EF') is not None]
max_project_ef = max(valid_efs) if valid_efs else None
for tid in reversed(orden):
node = G.nodes[tid]
if node.get('EF') is None:
continue
succs = list(G.successors(tid))
if not succs:
node['LF'] = max_project_ef
else:
valid_ls = [G.nodes[s].get('LS') for s in succs if G.nodes[s].get('LS') is not None]
if valid_ls:
min_succ_ls = min(valid_ls); cursor = min_succ_ls - timedelta(days=1)
while not es_habil(cursor, dias_idx, feriados):
cursor -= timedelta(days=1)
node['LF'] = cursor
else:
node['LF'] = max_project_ef
dur = math.ceil(node.get('dur_ajustada', 0)); cursor = node['LF']
if dur > 1:
days_stepped = 1
while days_stepped < dur:
cursor -= timedelta(days=1)
if es_habil(cursor, dias_idx, feriados):
days_stepped += 1
node['LS'] = cursor
ef = node['EF']; lf = node['LF']; tf_days = 0
if ef and lf and lf >= ef:
c = ef
while c < lf:
c += timedelta(days=1)
if es_habil(c, dias_idx, feriados):
tf_days += 1
elif ef and lf and lf < ef:
c = lf
while c < ef:
c += timedelta(days=1)
if es_habil(c, dias_idx, feriados):
tf_days -= 1
node['TF'] = tf_days; node['is_critical'] = (tf_days <= 0)
res_temp[tid]['Holgura (Días)'] = tf_days; res_temp[tid]['Ruta Crítica'] = "Sí" if tf_days <= 0 else "No"
impact = res_temp[tid]['Días Impacto']
inicio_nuevo = res_temp[tid].get('Inicio Nuevo')
inicio_base = res_temp[tid].get('Inicio Base')
# C-04: distinguir "Mutada por lluvia directa" vs "Empujada por cascada"
fue_empujada_sin_lluvia = (
tf_days <= 0 and
impact == 0 and
inicio_nuevo is not None and
inicio_base is not None and
inicio_nuevo > inicio_base
)
if fue_empujada_sin_lluvia:
res_temp[tid]['Nivel Riesgo'] = "Crítico (Empujada)"
elif tf_days <= 0 and impact > 0:
res_temp[tid]['Nivel Riesgo'] = "Crítico (Mutada)"
elif impact > 2:
res_temp[tid]['Nivel Riesgo'] = "Alto"
else:
res_temp[tid]['Nivel Riesgo'] = "Normal"
df_res = pd.DataFrame(list(res_temp.values())).sort_values('ID')
df_res['Holgura (Días)'] = df_res['Holgura (Días)'].astype(object)
df_res['Tr (Secado/Horas)'] = df_res['Tr (Secado/Horas)'].astype(object)
df_res['Duración Nueva'] = df_res['Duración Nueva'].astype(object)
df_res['Días Impacto'] = df_res['Días Impacto'].astype(object)
df_res['Nivel Riesgo'] = df_res['Nivel Riesgo'].astype(object)
for i in df_res[df_res['IsSummary'] == True].index:
wbs_val = str(df_res.at[i, 'WBS']); wbs_prefix = wbs_val + '.'
children = df_res[(df_res['WBS'].astype(str).str.startswith(wbs_prefix)) & (df_res['IsSummary'] == False)]
if children.empty and (df_res.at[i, 'ID'] == 0 or wbs_val == '0' or wbs_val == 'None'):
children = df_res[df_res['IsSummary'] == False]
if not children.empty:
min_start = children['Inicio Nuevo'].dropna().min()
max_finish = children['Fin Nuevo'].dropna().max()
if pd.notna(min_start):
df_res.at[i, 'Inicio Nuevo'] = min_start
if pd.notna(max_finish):
df_res.at[i, 'Fin Nuevo'] = max_finish
if pd.notna(min_start) and pd.notna(max_finish) and max_finish >= min_start:
c_dias = 0; cursor = min_start
while cursor <= max_finish:
if es_habil(cursor, dias_idx, feriados):
c_dias += 1
cursor += timedelta(days=1)
# Las tareas RESUMEN no llevan duración pegable (MS Project la deriva).
df_res.at[i, 'Duración Nueva'] = "-"
df_res.at[i, 'Días Impacto'] = "-"
df_res.at[i, 'Nivel Riesgo'] = "Resumen (auto)"
else:
df_res.at[i, 'Duración Nueva'] = "-"; df_res.at[i, 'Días Impacto'] = "-"; df_res.at[i, 'Nivel Riesgo'] = "Resumen (auto)"
df_res.at[i, 'Prob. Lluvia'] = "-"; df_res.at[i, 'mm Lluvia Max'] = "-"
df_res.at[i, 'Holgura (Días)'] = "-"; df_res.at[i, 'Ruta Crítica'] = "-"; df_res.at[i, 'Tr (Secado/Horas)'] = "-"
df_res['ID'] = pd.to_numeric(df_res['ID'], errors='coerce')
return df_res.sort_values('ID').reset_index(drop=True)
def agente_prescriptivo_mitigacion(df_tareas, evb_total):
"""Genera un mini-informe prescriptivo del estado analizado (siempre devuelve contenido)."""
reporte = []
act = df_tareas[(df_tareas['IsSummary'] == False) & (df_tareas['IsMilestone'] == False)].copy()
act['_imp'] = pd.to_numeric(act['Días Impacto'], errors='coerce').fillna(0)
act['_tr'] = pd.to_numeric(act['Tr (Secado/Horas)'], errors='coerce').fillna(0)
act['_ic'] = pd.to_numeric(act['Ic_Estimado'], errors='coerce').fillna(0)
n_total = len(act)
afectadas = act[act['_imp'] > 0]
n_afect = len(afectadas)
criticas = act[act['Ruta Crítica'].astype(str) == "Sí"]
n_crit = len(criticas)
pct = (n_afect / n_total * 100.0) if n_total > 0 else 0.0
if evb_total <= 0 and n_afect == 0:
nivel = "🟢 ESTABLE"
elif evb_total < 5:
nivel = "🟡 RIESGO MODERADO"
else:
nivel = "🔴 RIESGO ALTO"
reporte.append(
f"📋 **Informe de Estado — {nivel}**<br>"
f"El proyecto acumula un retraso climático estimado de **{int(round(evb_total))} días hábiles**. "
f"Se analizaron **{n_total} actividades**, de las cuales **{n_afect} ({pct:.0f}%)** presentan impacto pluviométrico "
f"y **{n_crit}** se encuentran sobre la Ruta Crítica estocástica."
)
if n_afect == 0:
reporte.append("✅ **Diagnóstico:** El riesgo climático actual es absorbido por las holguras del cronograma. No se requieren medidas de mitigación.")
return reporte
peor = afectadas.loc[afectadas['_imp'].idxmax()]
reporte.append(
f"🧠 **Cuello de botella principal:** **'{peor['Actividad']}'** concentra el mayor impacto "
f"({int(peor['_imp'])} días), con un tiempo de secado inferido de **{peor['_tr']:.0f} h** "
f"y coeficiente de vulnerabilidad Ic={peor['_ic']:.1f}."
)
top = afectadas.sort_values('_imp', ascending=False).head(3)
lineas = "<br>".join(
f" • **{t['Actividad']}** — {int(t['_imp'])} d "
f"({'Ruta Crítica' if str(t['Ruta Crítica']) == 'Sí' else 'con holgura'})"
for _, t in top.iterrows()
)
reporte.append(f"📌 **Actividades más afectadas:**<br>{lineas}")
# Recomendación logística con verificación de solapamiento (Ec. 6.10)
TAU_MAX_BLOQUEO = 24.0
tierras = afectadas[afectadas['_tr'] >= TAU_MAX_BLOQUEO]
refugios = act[act['_ic'] <= 1.0]
refugio_solapado = None
if not tierras.empty and not refugios.empty:
eb_ini = pd.to_datetime(peor.get('Inicio Nuevo'), errors='coerce')
eb_fin = pd.to_datetime(peor.get('Fin Nuevo'), errors='coerce')
if pd.notna(eb_ini) and pd.notna(eb_fin):
for _, ref in refugios.iterrows():
er_ini = pd.to_datetime(ref.get('Inicio Nuevo'), errors='coerce')
er_fin = pd.to_datetime(ref.get('Fin Nuevo'), errors='coerce')
if pd.notna(er_ini) and pd.notna(er_fin) and max(eb_ini, er_ini) <= min(eb_fin, er_fin):
refugio_solapado = ref
break
if refugio_solapado is not None:
reporte.append(
f"👉 **Estrategia recomendada (Ec. 6.10 — solapamiento confirmado):** Reasignar temporalmente la "
f"maquinaria del frente bloqueado hacia el nodo refugio estructural **'{refugio_solapado['Actividad']}'** "
f"(Ic={refugio_solapado['_ic']:.1f}), cuya ventana operativa se solapa con el período de parálisis, "
f"evitando que los recursos queden inactivos."
)
elif not tierras.empty:
reporte.append(
"⚠️ **Estrategia recomendada:** No hay frentes estructurales con ventana solapada al período de parálisis. "
"Se recomienda **reprogramar el inicio** del frente afectado hacia una ventana de menor probabilidad de lluvia "
"o evaluar la **movilización de recursos** a otro proyecto durante el secado."
)
else:
reporte.append(
"👉 **Estrategia recomendada:** Los impactos provienen de lluvias de baja persistencia. Se recomienda "
"**ajustar la secuencia de tareas** para ejecutar las partidas sensibles en las ventanas secas detectadas "
"y reforzar el drenaje superficial de los frentes activos."
)
if n_crit > 0:
reporte.append(
f"🚨 **Atención Ruta Crítica:** {n_crit} actividad(es) crítica(s) absorbieron el retraso y empujan la fecha "
f"de término del proyecto. Prioriza su mitigación: cualquier día ganado en ellas se traduce directamente en "
f"adelanto del hito final."
)
return reporte
|