""" 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}**
" 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 = "
".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:**
{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