chronoflux / core /engine.py
xcenic's picture
Upload 19 files
7599506 verified
Raw
History Blame Contribute Delete
23.6 kB
"""
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"&nbsp;&nbsp;• **{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