Spaces:
Runtime error
Runtime error
| """ | |
| 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 | |