ccastro344's picture
Upload app.py
8c403bc verified
# Cell 0:
#%pip install xlsxwriter
# Cell 1:
#!python -m pip install gradio
# Cell 2:
# ============================================================================
# SISTEMA COMPLETO DE OPTIMIZACIÓN DE PLANIFICACIÓN DE LABORATORIO
# Versión 1.0 - Implementación completa con Algoritmo Genético
# ============================================================================
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Tuple, Set, Any
from enum import Enum
from collections import defaultdict, Counter
import random
import pandas as pd
import numpy as np
import unicodedata
import re
import os
from zoneinfo import ZoneInfo
# import gradio as gr
import copy # Para clonar objetos Paso y evitar referencias compartidas
import sys
# Configurar codificación UTF-8 para salida estándar en Windows
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# ============================================================================
# CONFIGURACIÓN DE DEBUG
# ============================================================================
# Cambiar a True para ver todos los mensajes de debug detallados
# Cambiar a False para ver solo generaciones y resultados del algoritmo genético
DEBUG_VERBOSE = False # Apagado para reducir ruido
# ============================================================================
# DEBUG ESPECÍFICO PARA MODO 4
# ============================================================================
# Variables globales para rastrear específicamente problemas con modo 4
DEBUG_MODE_4_OVERLAPS = False # ✅ DESACTIVADO para corrida final (ya confirmamos el bug)
DEBUG_MODE_4 = {
'registrations': [], # Todos los registros de modo 4
'overlaps': [], # Solapamientos detectados en modo 4
'other_mode_registrations': 0, # Contador de otros modos
'verification_logs': [], # Logs de verificaciones
}
# ============================================================================
# ENUMERACIONES
# ============================================================================
class ModoEquipo(Enum):
"""Modos de operación de equipos"""
BATCH = "Batch"
ROLLING = "Rolling"
BATCH_SCALING = "Batch Scaling"
class TipoEquipo(Enum):
"""Tipos de equipos disponibles en el laboratorio"""
ESPECTROFOTOMETRO = "Espectrofotómetro"
CROMATOGRAFO = "Cromatógrafo"
CENTRIFUGA = "Centrífuga"
HPLC = "HPLC"
GC_MS = "GC-MS"
BALANZA = "Balanza"
REACTOR = "Reactor"
OTRO = "Otro"
class NivelUrgencia(Enum):
"""Niveles de urgencia para esquemas"""
BAJA = 1
MEDIA = 2
ALTA = 3
CRITICA = 4
class EstadoPaso(Enum):
"""Estados posibles de un paso"""
PENDIENTE = "Pendiente"
EN_PROCESO = "En Proceso"
COMPLETADO = "Completado"
BLOQUEADO = "Bloqueado"
# ============================================================================
# CLASES DE RECURSOS
# ============================================================================
@dataclass
class Turno:
"""Representa un turno de trabajo"""
dia_semana: int # 0=Lunes, 6=Domingo
hora_inicio: str # Formato "HH:MM"
hora_fin: str # Formato "HH:MM"
def __post_init__(self):
"""Validación básica"""
if not 0 <= self.dia_semana <= 6:
raise ValueError("dia_semana debe estar entre 0 (Lunes) y 6 (Domingo)")
@dataclass
class Analista:
"""Representa un analista del laboratorio"""
id: str
nombre: str
habilidades: List[str] = field(default_factory=list)
turnos: List[Turno] = field(default_factory=list)
activo: bool = True
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class Equipo:
"""Representa un equipo de laboratorio"""
id: str
tipo: str # ✅ CAMBIAR: de TipoEquipo a str
modo: ModoEquipo
capacidad: int
activo: bool = True
tiempo_setup: float = 0.0
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if self.capacidad <= 0:
raise ValueError("capacidad debe ser mayor a 0")
# ============================================================================
# CLASES DE PROCESO
# ============================================================================
@dataclass
class Paso:
"""Representa un paso dentro de un esquema analítico"""
id: str
esquema_id: str
secuencia: int
nombre: str
duracion: float
tipo_equipo: str # ✅ CAMBIAR: de TipoEquipo a str
equipos_posibles: List[str] = field(default_factory=list)
analista_preferido: Optional[str] = None
modo_interaccion: str = "manual"
tiempo_interaccion: float = 0.0
frecuencia_interaccion: int = 1
uniformidad: bool = True
pasos_predecesores: List[str] = field(default_factory=list)
estado: EstadoPaso = EstadoPaso.PENDIENTE
inicio_programado: Optional[datetime] = None
fin_programado: Optional[datetime] = None
analista_asignado: Optional[str] = None
equipo_asignado: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if self.duracion <= 0:
raise ValueError("duracion debe ser mayor a 0")
if self.tiempo_interaccion < 0:
raise ValueError("tiempo_interaccion no puede ser negativo")
if self.frecuencia_interaccion < 0:
raise ValueError("frecuencia_interaccion no puede ser negativa")
@dataclass
class Esquema:
"""Representa un esquema analítico (conjunto de pasos)"""
id: str
orden_id: str
nombre: str
urgencia: NivelUrgencia = NivelUrgencia.MEDIA
cantidad_muestras: int = 1
pasos: List[Paso] = field(default_factory=list)
tat_especifico: Optional[timedelta] = None
fecha_recepcion: Optional[datetime] = None # Fecha de recepción del esquema (Entregado x PMO)
inicio_programado: Optional[datetime] = None
fin_programado: Optional[datetime] = None
esquema_tipo: Optional[str] = None # Tipo de esquema (POCALI, etc) para match con pasos
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if self.cantidad_muestras <= 0:
raise ValueError("cantidad_muestras debe ser mayor a 0")
def agregar_paso(self, paso: Paso):
if paso.esquema_id != (self.esquema_tipo if self.esquema_tipo else self.id):
raise ValueError(f"Paso {paso.id} no coincide: paso.esquema_id={paso.esquema_id} vs esquema.esquema_tipo={self.esquema_tipo if self.esquema_tipo else self.id}")
self.pasos.append(paso)
def ordenar_pasos(self):
self.pasos.sort(key=lambda p: p.secuencia)
@dataclass
class Orden:
"""Representa una orden de trabajo del laboratorio"""
id: str
fecha_recepcion: datetime
tat_prometido: timedelta
esquemas: List[Esquema] = field(default_factory=list)
cliente: Optional[str] = None
prioridad_global: int = 1
fecha_compromiso: Optional[datetime] = None
fecha_inicio_programado: Optional[datetime] = None
fecha_fin_programado: Optional[datetime] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if self.fecha_compromiso is None and self.fecha_recepcion and self.tat_prometido:
self.fecha_compromiso = self.fecha_recepcion + self.tat_prometido
def agregar_esquema(self, esquema: Esquema):
if esquema.orden_id != self.id:
raise ValueError(f"El esquema pertenece a otra orden: {esquema.orden_id}")
self.esquemas.append(esquema)
def calcular_tardanza(self, fecha_actual: datetime) -> float:
if self.fecha_fin_programado and self.fecha_compromiso:
delta = self.fecha_fin_programado - self.fecha_compromiso
return max(0, delta.total_seconds() / 3600)
return 0.0
@dataclass
class SistemaPlanificacion:
"""Contenedor principal del sistema de planificación"""
ordenes: List[Orden] = field(default_factory=list)
analistas: List[Analista] = field(default_factory=list)
equipos: List[Equipo] = field(default_factory=list)
fecha_inicio_planificacion: Optional[datetime] = None # ✅ CAMBIO AQUÍ
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self): # ✅ AGREGAR ESTO
"""Asignar fecha actual si no se proporciona"""
if self.fecha_inicio_planificacion is None:
self.fecha_inicio_planificacion = datetime.now()
# ... resto de métodos sin cambios ...
def agregar_orden(self, orden: Orden):
self.ordenes.append(orden)
def agregar_analista(self, analista: Analista):
self.analistas.append(analista)
def agregar_equipo(self, equipo: Equipo):
self.equipos.append(equipo)
def obtener_orden(self, orden_id: str) -> Optional[Orden]:
for orden in self.ordenes:
if orden.id == orden_id:
return orden
return None
def obtener_analista(self, analista_id: str) -> Optional[Analista]:
for analista in self.analistas:
if analista.id == analista_id:
return analista
return None
def obtener_equipo(self, equipo_id: str) -> Optional[Equipo]:
for equipo in self.equipos:
if equipo.id == equipo_id:
return equipo
return None
# Cell 3:
# -*- coding: utf-8 -*-
# ETL para inputs del algoritmo: ORDENES, ESQUEMAS, PASOS, ANALISTAS, EQUIPOS
# VERSIÓN CORREGIDA: Con equipo NONE automático
import pandas as pd
import numpy as np
import re
from datetime import datetime
import logging
from datetime import datetime as dt_now
import unicodedata
# =========================
# Helpers
# =========================
def to_datetime_safe(s):
if pd.isna(s):
return pd.NaT
try:
return pd.to_datetime(s, dayfirst=False, errors="coerce")
except Exception:
return pd.NaT
def to_float_safe(x):
try:
if pd.isna(x) or str(x).strip()=="":
return np.nan
return float(x)
except Exception:
return np.nan
def list_from_comma(s):
if s is None or (isinstance(s, float) and np.isnan(s)) or str(s).strip()=="":
return []
return [t.strip() for t in str(s).split(",") if str(t).strip()!=""]
def norm_upper(s):
return None if pd.isna(s) else str(s).strip().upper()
def gen_slug(s, maxlen=12):
if s is None:
return ""
slug = re.sub(r"[^A-Za-z0-9]+", "", str(s))
return slug[:maxlen] if slug else ""
# =========================
# Turnos
# =========================
def build_turnos_string(tipo_turno: str, turnos_config: dict = None) -> str:
"""
Expande palabras clave (mañana, tarde, noche) a formato completo de turnos.
Args:
tipo_turno: Palabra clave ("mañana", "tarde", "noche") o formato completo
turnos_config: Dict con horarios personalizados desde la interfaz
Ej: {'manana_ini': '07:00', 'manana_fin': '14:00', ...}
"""
# Valores por defecto
if turnos_config is None:
turnos_config = {}
TURNO_MANANA_INI = turnos_config.get('manana_ini', "07:00")
TURNO_MANANA_FIN = turnos_config.get('manana_fin', "14:00")
TURNO_TARDE_INI = turnos_config.get('tarde_ini', "14:00")
TURNO_TARDE_FIN = turnos_config.get('tarde_fin', "22:00")
TURNO_NOCHE_INI = turnos_config.get('noche_ini', "22:00")
TURNO_NOCHE_FIN = turnos_config.get('noche_fin', "07:00")
t = (tipo_turno or "").casefold()
if "mañana" in t or "manana" in t:
rango = f"{TURNO_MANANA_INI}-{TURNO_MANANA_FIN}"
elif "tarde" in t:
rango = f"{TURNO_TARDE_INI}-{TURNO_TARDE_FIN}"
elif "noche" in t:
rango = f"{TURNO_NOCHE_INI}-{TURNO_NOCHE_FIN}"
else:
rango = f"{TURNO_MANANA_INI}-{TURNO_MANANA_FIN}"
return ", ".join([f"{d}:{rango}" for d in range(1,8)])
# ============================================================================
# FUNCIÓN PRINCIPAL DE TRANSFORMACIÓN Y CARGA
# ============================================================================
def transformar_y_cargar_datos(
raw_ord: pd.DataFrame,
df_equipos_raw: pd.DataFrame,
df_pasos_raw: pd.DataFrame,
df_ana_raw: pd.DataFrame,
out_xlsx: str = "datos_laboratorio.xlsx",
turnos_config: dict = None
):
"""
# Configurar logging
log_file = f"etl_log_{dt_now.now().strftime("%Y%m%d_%H%M%S")}.txt"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[logging.FileHandler(log_file, encoding="utf-8"), logging.StreamHandler()],
force=True
)
logging.info("Iniciando ETL")
Procesa los DataFrames crudos de órdenes, equipos, pasos y analistas
y los exporta a un archivo Excel.
Args:
raw_ord (pd.DataFrame): DataFrame crudo de órdenes.
df_equipos_raw (pd.DataFrame): DataFrame crudo de equipos.
df_pasos_raw (pd.DataFrame): DataFrame crudo de pasos.
df_ana_raw (pd.DataFrame): DataFrame crudo de analistas.
out_xlsx (str): Ruta del archivo Excel de salida.
"""
print("\n" + "="*80)
print("📊 PROCESANDO DATOS PARA OPTIMIZACIÓN")
print("="*80)
# =========================
# VALIDACION DE COLUMNAS
print("\nValidando columnas de archivos...")
# Validar Ordenes
required_ordenes = ["Orden", "Esquema", "Cant.", "TAT", "URGENCIA_ESQUEMA", "PRIORIDAD_ORDEN", "Entregado x PMO"]
missing_ord = [col for col in required_ordenes if col not in raw_ord.columns]
if missing_ord:
raise ValueError(f"Faltan columnas en Ordenes.xlsx: {missing_ord}. Columnas actuales: {list(raw_ord.columns)}")
# Validar Pasos
required_pasos = ["ESQUEMA_ID", "SECUENCIA", "NOMBRE", "TIPO_EQUIPO", "UNIFORMIDAD", "DURACION"]
missing_pasos = [col for col in required_pasos if col not in df_pasos_raw.columns]
if missing_pasos:
raise ValueError(f"Faltan columnas en Pasos.xlsx: {missing_pasos}. Columnas actuales: {list(df_pasos_raw.columns)}")
print(" OK - Todas las columnas presentes")
# 1) ORDENES & ESQUEMAS
# =========================
print("\n🔄 Procesando ORDENES y ESQUEMAS...")
df_esquemas = pd.DataFrame({
"URGENCIA": raw_ord["URGENCIA_ESQUEMA"],
"TAT_ESPECIFICO": raw_ord["TAT"].apply(to_float_safe).fillna(0)*24.0,
"ESQUEMA_ID": raw_ord["Esquema"].astype(str),
"ORDEN_ID": raw_ord["Orden"].astype(str),
"NOMBRE": raw_ord["Determinacion"].astype(str),
"CANTIDAD_MUESTRAS": raw_ord["Cant."].astype(int),
"FECHA_RECEPCION": raw_ord["Entregado x PMO"].apply(to_datetime_safe)
})
# FIX: Crear ID unico combinando ORDEN_ID + ESQUEMA_ID
# FIX: Crear ID unico combinando ORDEN_ID + ESQUEMA_ID
df_esquemas["ID_UNICO"] = (df_esquemas["ORDEN_ID"].astype(str) +
"-" +
df_esquemas["ESQUEMA_ID"].astype(str))
df_esquemas["ID"] = df_esquemas["ID_UNICO"] # CORRECCION: Usar ID unico
tmp = raw_ord.copy()
tmp["TAT_horas"] = tmp["TAT"].apply(to_float_safe).fillna(0)*24.0
tmp["Entregado_dt"] = tmp["Entregado x PMO"].apply(to_datetime_safe)
agg_ord = (tmp
.groupby("Orden", as_index=False)
.agg({
"TAT_horas":"max",
"Entregado_dt":"min"
})
.rename(columns={
"Orden":"ORDEN_ID",
"TAT_horas":"TAT_PROMETIDO",
"Entregado_dt":"FECHA_RECEPCION"
})
)
prio = (raw_ord.groupby("Orden", as_index=False)["PRIORIDAD_ORDEN"]
.max()
.rename(columns={"Orden":"ORDEN_ID", "PRIORIDAD_ORDEN":"PRIORIDAD"}))
df_ordenes = agg_ord.merge(prio, on="ORDEN_ID", how="left")
df_ordenes = df_ordenes[["ORDEN_ID","FECHA_RECEPCION","TAT_PROMETIDO","PRIORIDAD"]].copy()
print(f" ✅ {len(df_ordenes)} órdenes y {len(df_esquemas)} esquemas procesados")
# =========================
# 2) EQUIPOS
# =========================
print("\n🔄 Procesando EQUIPOS...")
id_col = None
for c in df_equipos_raw.columns:
if c.strip().upper() in ("ID","EQUIPO_ID"):
id_col = c
break
if id_col is None:
df_equipos_proc = df_equipos_raw.copy()
df_equipos_proc["EQUIPO_ID"] = ["EQ" + str(i+1).zfill(3) for i in range(len(df_equipos_proc))]
id_col = "EQUIPO_ID"
else:
df_equipos_proc = df_equipos_raw.rename(columns={id_col: "EQUIPO_ID"})
if "TIPO" not in df_equipos_proc.columns:
raise ValueError("En el DataFrame de equipos debe existir la columna 'TIPO'.")
df_equipos_proc["TIPO_NORM"] = df_equipos_proc["TIPO"].astype(str).str.upper().str.replace("-", "_")
df_equipos = df_equipos_proc.copy()
print(f" ✅ {len(df_equipos)} equipos procesados")
# =========================
# 3) PASOS - FILTRAR POR ESQUEMAS ACTIVOS
# =========================
print("\n🔄 Procesando PASOS y filtrando por esquemas activos...")
req_pasos = ["ESQUEMA_ID","SECUENCIA","NOMBRE","TIPO_EQUIPO","UNIFORMIDAD","DURACION",
"TIEMPO_MIN","TIEMPO_MAX","MODO_INTERACCION","TIEMPO_INTERACCION","FRECUENCIA_INTERACCION"]
for c in req_pasos:
if c not in df_pasos_raw.columns:
raise ValueError(f"Falta columna en el DataFrame de pasos: {c}")
df_pasos = df_pasos_raw.copy()
# FIX: Eliminar espacios en blanco antes de comparar
esquemas_activos = set(df_esquemas['ESQUEMA_ID'].astype(str).str.strip().str.upper().unique())
print(f" 🔍 Esquemas en órdenes: {len(esquemas_activos)}")
df_pasos['ESQUEMA_ID_UPPER'] = df_pasos['ESQUEMA_ID'].astype(str).str.strip().str.upper()
# DEBUG: Mostrar qué esquemas están en cada archivo
print(f" 📋 Esquemas en Ordenes: {sorted(list(esquemas_activos)[:5])}{'...' if len(esquemas_activos) > 5 else ''}")
esquemas_en_pasos = set(df_pasos['ESQUEMA_ID_UPPER'].unique())
print(f" 📋 Esquemas en Pasos: {sorted(list(esquemas_en_pasos)[:5])}{'...' if len(esquemas_en_pasos) > 5 else ''}")
faltantes = esquemas_activos - esquemas_en_pasos
if faltantes:
print(f" ERROR: Los siguientes esquemas no tienen pasos definidos:")
for esq in sorted(faltantes):
print(f" - {esq}")
raise ValueError(
f"Faltan {len(faltantes)} esquemas en Pasos.xlsx. "
f"Esquemas faltantes: {sorted(faltantes)}. "
"Agregue estos esquemas a Pasos.xlsx y vuelva a ejecutar."
)
total_pasos_catalogo = len(df_pasos)
df_pasos = df_pasos[df_pasos['ESQUEMA_ID_UPPER'].isin(esquemas_activos)].copy()
df_pasos = df_pasos.drop(columns=['ESQUEMA_ID_UPPER'])
print(f" ✅ Pasos filtrados: {len(df_pasos)} de {total_pasos_catalogo} del catálogo")
# Crear identificadores únicos para los pasos. Si el catálogo no trae
# columnas "ID" o "PASO_ID", se construyen a partir de ESQUEMA_ID y SECUENCIA.
if "ID" not in df_pasos.columns and "PASO_ID" not in df_pasos.columns:
# Construir un identificador base por esquema y secuencia
df_pasos["ID"] = df_pasos.apply(
lambda r: f"{str(r['ESQUEMA_ID'])}-P{int(r['SECUENCIA'])}", axis=1
)
else:
if "PASO_ID" in df_pasos.columns and "ID" not in df_pasos.columns:
df_pasos = df_pasos.rename(columns={"PASO_ID": "ID"})
# 👉 Asegurar que los identificadores de pasos sean únicos. En casos donde el catálogo de
# pasos tenga varias filas con el mismo ESQUEMA_ID y SECUENCIA (por ejemplo,
# pasos alternativos o manuales), se enumeran los duplicados añadiendo un sufijo -1, -2, etc.
if "ID" in df_pasos.columns:
dup_mask = df_pasos.duplicated(subset=["ID"], keep=False)
if dup_mask.any():
counts = {}
new_ids = []
for base_id in df_pasos["ID"]:
if base_id not in counts:
counts[base_id] = 0
new_ids.append(base_id)
else:
counts[base_id] += 1
new_ids.append(f"{base_id}-{counts[base_id]}")
df_pasos["ID"] = new_ids
def equipos_posibles_por_tipo(tipo_equipo_norm: str) -> str:
if not tipo_equipo_norm:
return ""
mask = df_equipos["TIPO_NORM"] == tipo_equipo_norm
ids = df_equipos.loc[mask, "EQUIPO_ID"].astype(str).tolist()
return ", ".join(ids)
if "EQUIPOS_POSIBLES" not in df_pasos.columns:
df_pasos["EQUIPOS_POSIBLES"] = ""
for idx, row in df_pasos.iterrows():
if not str(row.get("EQUIPOS_POSIBLES","")).strip():
tipo_norm = str(row["TIPO_EQUIPO"]).upper().replace("-", "_")
df_pasos.at[idx, "EQUIPOS_POSIBLES"] = equipos_posibles_por_tipo(tipo_norm)
if "PASOS_PREDECESORES" not in df_pasos.columns:
df_pasos["PASOS_PREDECESORES"] = ""
clave = df_pasos.set_index(["ESQUEMA_ID","SECUENCIA"])["ID"].to_dict()
for idx, row in df_pasos.iterrows():
if not str(row.get("PASOS_PREDECESORES","")).strip():
seq = int(row["SECUENCIA"])
if seq > 1:
prev_key = (row["ESQUEMA_ID"], seq-1)
prev_id = clave.get(prev_key, "")
df_pasos.at[idx, "PASOS_PREDECESORES"] = prev_id
else:
df_pasos.at[idx, "PASOS_PREDECESORES"] = ""
# =========================
# 4) ANALISTAS
# =========================
print("\n🔄 Procesando ANALISTAS...")
for c in ["NOMBRE","HABILIDADES","TURNO","ACTIVO"]:
if c not in df_ana_raw.columns:
raise ValueError(f"Falta columna en el DataFrame de analistas: {c}")
df_ana = df_ana_raw.copy()
# ✅ NORMALIZAR HABILIDADES antes de agrupar
df_ana["HABILIDADES"] = df_ana["HABILIDADES"].apply(
lambda s: ", ".join([norm_upper(x) for x in list_from_comma(s)])
)
# ✅ AGRUPAR POR NOMBRE: Un analista con múltiples habilidades = UNA sola fila
# REALISMO: Un analista real NO puede trabajar en múltiples tareas simultáneas
if DEBUG_VERBOSE:
print(f" 📋 Filas antes de agrupar: {len(df_ana)}")
df_ana_grouped = df_ana.groupby('NOMBRE', as_index=False).agg({
'HABILIDADES': lambda x: ', '.join([str(h) for h in x if pd.notna(h) and str(h).strip()]),
'TURNO': 'first', # Todos deberían tener el mismo turno
'ACTIVO': 'first', # Todos deberían tener el mismo estado
})
if DEBUG_VERBOSE:
print(f" 📋 Analistas únicos después de agrupar: {len(df_ana_grouped)}")
df_ana = df_ana_grouped
# Generar IDs únicos basados en NOMBRE del analista (UN ID por analista real)
if "ID" not in df_ana.columns:
ids = []
for nombre in df_ana["NOMBRE"]:
nombre_str = str(nombre).strip()
# Crear slug del nombre (primeras letras de nombre y apellido)
partes = nombre_str.upper().split()
if len(partes) >= 2:
# Nombre y Apellido: tomar 2 primeras letras de cada uno
slug = partes[0][:2] + partes[1][:2] # Ej: "JUAN PEREZ" → "JUPE"
elif len(partes) == 1:
# Solo un nombre: tomar 4 primeras letras
slug = partes[0][:4].ljust(4, 'X') # Ej: "JUAN" → "JUAN", "ANA" → "ANAX"
else:
slug = "ANON" # Anónimo
# ID sin contador: cada analista aparece UNA sola vez después del groupby
id_analista = slug + "01" # Ej: JUPE01, MALO01
ids.append(id_analista)
df_ana["ID"] = ids
# Aplicar build_turnos_string con configuración de turnos desde interfaz
df_ana["TURNOS"] = df_ana["TURNO"].apply(lambda x: build_turnos_string(x, turnos_config))
print(f" ✅ {len(df_ana)} analistas procesados")
# =========================
# 5) Conformar dataframes finales
# =========================
print("\n🔄 Conformando DataFrames finales...")
ORDENES = df_ordenes.rename(columns={"ORDEN_ID": "ID"})[["ID", "FECHA_RECEPCION", "TAT_PROMETIDO", "PRIORIDAD"]].copy()
ESQUEMAS = df_esquemas[
["URGENCIA", "TAT_ESPECIFICO", "ID_UNICO", "ESQUEMA_ID", "ORDEN_ID", "NOMBRE", "CANTIDAD_MUESTRAS"]
].rename(columns={"ID_UNICO": "ID"}).copy()
if "ANALISTA_PREFERIDO" not in df_pasos.columns:
df_pasos["ANALISTA_PREFERIDO"] = ""
PASOS = df_pasos[[
"TIPO_EQUIPO","EQUIPOS_POSIBLES","PASOS_PREDECESORES","ANALISTA_PREFERIDO",
"ID","ESQUEMA_ID","NOMBRE","SECUENCIA","DURACION","MODO_INTERACCION",
"TIEMPO_INTERACCION","FRECUENCIA_INTERACCION","UNIFORMIDAD","TIEMPO_MIN","TIEMPO_MAX"
]]
# ✅ FIX: Guardar TURNO (original) y TURNOS (transformado) para que cargar_analistas pueda usar TURNO
ANALISTAS = df_ana[["HABILIDADES","TURNO","TURNOS","ID","NOMBRE","ACTIVO"]].copy()
EQUIPOS = df_equipos.rename(columns={"EQUIPO_ID":"ID"})[[
"ID","TIPO","MODO","CAPACIDAD","ACTIVO","TIEMPO_SETUP"
]]
print(f" ✅ DataFrames finales listos")
# =========================
# ✅ FIX: AGREGAR EQUIPO NONE SI NO EXISTE
# =========================
if 'NONE' not in EQUIPOS['TIPO'].str.upper().values:
print("\n🔧 Agregando equipo NONE automáticamente...")
equipo_none = pd.DataFrame([{
'ID': 'EQ_NONE',
'TIPO': 'NONE',
'MODO': 'BATCH',
'CAPACIDAD': 999,
'ACTIVO': True,
'TIEMPO_SETUP': 0
}])
EQUIPOS = pd.concat([EQUIPOS, equipo_none], ignore_index=True)
print(" ✅ Equipo NONE agregado (ID: EQ_NONE)")
# =========================
# 6) Guardar
# =========================
print(f"\n💾 Guardando datos procesados en: {out_xlsx}")
with pd.ExcelWriter(out_xlsx, engine="xlsxwriter", datetime_format="yyyy-mm-dd hh:mm:ss") as xw:
ORDENES.to_excel(xw, sheet_name="ORDENES", index=False)
ESQUEMAS.to_excel(xw, sheet_name="ESQUEMAS", index=False)
PASOS.to_excel(xw, sheet_name="PASOS", index=False)
ANALISTAS.to_excel(xw, sheet_name="ANALISTAS", index=False)
EQUIPOS.to_excel(xw, sheet_name="EQUIPOS", index=False)
print(f"✅ Archivo de datos de laboratorio creado: {out_xlsx}")
print(f" 📊 {len(ORDENES)} órdenes, {len(ESQUEMAS)} esquemas, {len(PASOS)} pasos")
print(f" 👥 {len(ANALISTAS)} analistas, 🔧 {len(EQUIPOS)} equipos")
print("="*80 + "\n")
# Cell 4:
# ============================================================================
# PARTE 2: CARGA DE DATOS, VALIDACIÓN Y SCHEDULER
# ============================================================================
# ============================================================================
# FUNCIONES DE CARGA DE DATOS DESDE EXCEL
# ============================================================================
def limpiar_nombre_columna(nombre: str) -> str:
"""Limpia nombres de columnas"""
nombre_sin_tildes = ''.join(
c for c in unicodedata.normalize('NFD', str(nombre))
if unicodedata.category(c) != 'Mn'
)
nombre_upper = nombre_sin_tildes.upper()
nombre_limpio = re.sub(r'[^\w\s]', '', nombre_upper)
nombre_limpio = re.sub(r'\s+', '_', nombre_limpio.strip())
return nombre_limpio
def limpiar_dataframe(df: pd.DataFrame) -> pd.DataFrame:
"""Limpia un DataFrame"""
df.columns = [limpiar_nombre_columna(col) for col in df.columns]
df = df.dropna(how='all')
df = df.reset_index(drop=True)
df = df.where(pd.notna(df), None)
return df
def parsear_fecha(valor) -> Optional[datetime]:
"""Parsea diferentes formatos de fecha"""
if valor is None or pd.isna(valor):
return None
if isinstance(valor, datetime):
return valor
if isinstance(valor, str):
formatos = ['%Y-%m-%d', '%d/%m/%Y', '%d-%m-%Y', '%Y/%m/%d',
'%d/%m/%Y %H:%M:%S', '%Y-%m-%d %H:%M:%S']
for formato in formatos:
try:
return datetime.strptime(valor, formato)
except ValueError:
continue
try:
return pd.to_datetime(valor)
except:
return None
def parsear_timedelta(valor) -> Optional[timedelta]:
"""Parsea valores a timedelta"""
if valor is None or pd.isna(valor):
return None
if isinstance(valor, timedelta):
return valor
if isinstance(valor, (int, float)):
return timedelta(hours=float(valor))
if isinstance(valor, str):
valor_lower = valor.lower().strip()
if 'dia' in valor_lower or 'day' in valor_lower:
num = float(re.search(r'\d+\.?\d*', valor_lower).group())
return timedelta(days=num)
elif 'hora' in valor_lower or 'hour' in valor_lower:
num = float(re.search(r'\d+\.?\d*', valor_lower).group())
return timedelta(hours=num)
elif 'min' in valor_lower:
num = float(re.search(r'\d+\.?\d*', valor_lower).group())
return timedelta(minutes=num)
else:
try:
return timedelta(hours=float(valor))
except:
return None
return None
def parsear_lista(valor, separador: str = ',') -> List[str]:
"""Parsea una cadena separada por comas en lista"""
if valor is None or pd.isna(valor):
return []
if isinstance(valor, list):
return valor
if isinstance(valor, str):
return [item.strip() for item in valor.split(separador) if item.strip()]
return [str(valor)]
def cargar_ordenes(df: pd.DataFrame) -> List[Orden]:
"""Carga órdenes desde DataFrame"""
ordenes = []
for _, row in df.iterrows():
try:
# ✅ VALIDAR Y RESTRINGIR PRIORIDAD a 1-3
prioridad_raw = row.get('PRIORIDAD', 1)
try:
prioridad_val = int(prioridad_raw)
# Clamp to 1-3 range
prioridad_val = max(1, min(3, prioridad_val))
except:
prioridad_val = 1
orden = Orden(
id=str(row.get('ID') or row.get('ORDEN_ID', '')),
fecha_recepcion=parsear_fecha(row.get('FECHA_RECEPCION')) or datetime.now(),
tat_prometido=parsear_timedelta(row.get('TAT_PROMETIDO')) or timedelta(hours=24),
cliente=str(row.get('CLIENTE')) if row.get('CLIENTE') else None,
prioridad_global=prioridad_val,
metadata={'esquema_id_original': str(row.get('ESQUEMA_ID', '')), 'fila_excel': _ + 2}
)
ordenes.append(orden)
except Exception as e:
print(f"⚠️ Error al cargar orden en fila {_ + 2}: {e}")
continue
return ordenes
def cargar_esquemas(df: pd.DataFrame) -> List[Esquema]:
"""Carga esquemas desde DataFrame"""
esquemas = []
for _, row in df.iterrows():
try:
# ✅ VALIDAR Y RESTRINGIR URGENCIA a 1-3 (BAJA, MEDIA, ALTA)
urgencia_raw = str(row.get('URGENCIA', 'MEDIA')).upper()
urgencia_map = {
'BAJA': NivelUrgencia.BAJA, 'MEDIA': NivelUrgencia.MEDIA,
'ALTA': NivelUrgencia.ALTA,
'1': NivelUrgencia.BAJA, '2': NivelUrgencia.MEDIA,
'3': NivelUrgencia.ALTA,
# CRITICA (4) eliminado - se mapea a ALTA
'CRITICA': NivelUrgencia.ALTA, '4': NivelUrgencia.ALTA,
}
urgencia = urgencia_map.get(urgencia_raw, NivelUrgencia.MEDIA)
tat_especifico = None
if row.get('TAT_ESPECIFICO'):
tat_especifico = parsear_timedelta(row.get('TAT_ESPECIFICO'))
fecha_recepcion = parsear_fecha(row.get('FECHA_RECEPCION')) if row.get('FECHA_RECEPCION') else None
esquema = Esquema(
id=str(row.get('ID', '')), # USAR ID del Excel (contiene ID_UNICO)
orden_id=str(row.get('ORDEN_ID', '')),
nombre=str(row.get('NOMBRE', 'Sin nombre')),
urgencia=urgencia,
cantidad_muestras=int(row.get('CANTIDAD_MUESTRAS', 1)),
tat_especifico=tat_especifico,
fecha_recepcion=fecha_recepcion,
esquema_tipo=str(row.get("ESQUEMA_ID", "")),
metadata={
'fila_excel': _ + 2,
'esquema_id_original': str(row.get('ESQUEMA_ID', ''))
}
)
esquemas.append(esquema)
except Exception as e:
print(f"⚠️ Error al cargar esquema en fila {_ + 2}: {e}")
continue
return esquemas
def cargar_pasos(df: pd.DataFrame) -> List[Paso]:
"""Carga pasos desde DataFrame"""
pasos = []
for _, row in df.iterrows():
try:
# ✅ CAMBIAR ESTA SECCIÓN:
# ANTES tenía un tipo_equipo_map con enums
# AHORA usa string directo
tipo_equipo_str = str(row.get('TIPO_EQUIPO', 'OTRO')).upper().strip()
equipos_posibles = parsear_lista(row.get('EQUIPOS_POSIBLES', ''))
pasos_predecesores = parsear_lista(row.get('PASOS_PREDECESORES', ''))
analista_preferido = None
if row.get('ANALISTA_PREFERIDO') and not pd.isna(row.get('ANALISTA_PREFERIDO')):
analista_preferido = str(row.get('ANALISTA_PREFERIDO'))
# ✅ LEER TIEMPO_MIN / TIEMPO_MAX desde PASOS
tiempo_min = None
tiempo_max = None
try:
v = row.get('TIEMPO_MIN')
if v is not None and not pd.isna(v) and str(v).strip() != '':
tiempo_min = float(v)
except Exception:
tiempo_min = None
try:
v = row.get('TIEMPO_MAX')
if v is not None and not pd.isna(v) and str(v).strip() != '':
tiempo_max = float(v)
except Exception:
tiempo_max = None
# ✅ PARSEAR MODO_INTERACCION (maneja vacíos, texto y números)
modo_int_raw = row.get('MODO_INTERACCION')
if pd.isna(modo_int_raw) or modo_int_raw == '':
modo_int = 0
else:
# Verificar si es texto o número
if isinstance(modo_int_raw, str) and not modo_int_raw.isdigit():
# Mapeo de texto a número
modo_str = modo_int_raw.strip().upper()
modo_map = {
'SIN_ANALISTA': 0,
'INICIO': 1,
'FIN': 2,
'FINAL': 2,
'INICIO_FIN': 3,
'INICIO_FINAL': 3,
'PERIODICA': 4,
'PERIODICO': 4,
'CONTINUA': 5,
'CONTINUO': 5
}
modo_int = modo_map.get(modo_str, 0)
else:
try:
modo_int = int(float(modo_int_raw))
# Validar rango 0-5
modo_int = max(0, min(5, modo_int))
except:
modo_int = 0
# ✅ CALCULAR TIEMPO_INTERACCION SEGÚN EL MODO
tiempo_interaccion_raw = row.get('TIEMPO_INTERACCION')
if pd.isna(tiempo_interaccion_raw) or tiempo_interaccion_raw == '':
if modo_int == 5:
duracion_raw = row.get('DURACION', 0)
if pd.isna(duracion_raw):
tiempo_interaccion = 5.0 # Valor por defecto: 5 minutos
else:
tiempo_interaccion = float(duracion_raw)
elif modo_int > 0:
# Si requiere analista pero no tiene tiempo especificado, usar 5 minutos por defecto
tiempo_interaccion = 5.0
else:
tiempo_interaccion = 0.0
else:
tiempo_interaccion = float(tiempo_interaccion_raw)
# ✅ CALCULAR FRECUENCIA_INTERACCION (maneja vacíos)
frecuencia_raw = row.get('FRECUENCIA_INTERACCION')
if pd.isna(frecuencia_raw) or frecuencia_raw == '':
frecuencia_interaccion = 1
else:
frecuencia_interaccion = int(float(frecuencia_raw))
# ✅ CALCULAR DURACIÓN (maneja vacíos según UNIFORMIDAD)
uniformidad_raw = row.get('UNIFORMIDAD', 1)
if pd.isna(uniformidad_raw) or uniformidad_raw == '':
uniformidad = True
else:
# Soportar texto y números
if isinstance(uniformidad_raw, str) and not uniformidad_raw.isdigit():
unif_str = uniformidad_raw.strip().upper()
uniformidad_map = {
'UNIFORME': True,
'SI': True,
'TRUE': True,
'NO_UNIFORME': False,
'NO': False,
'FALSE': False
}
uniformidad = uniformidad_map.get(unif_str, True)
else:
try:
uniformidad = bool(int(float(uniformidad_raw)))
except:
uniformidad = True
duracion_raw = row.get('DURACION')
if pd.isna(duracion_raw) or duracion_raw == '':
if not uniformidad and tiempo_min is not None and tiempo_max is not None:
duracion = (tiempo_min + tiempo_max) / 2
else:
duracion = 0.0
else:
duracion = float(duracion_raw)
# ✅ METADATA CON TIEMPO_MIN/MAX
meta = {'fila_excel': _ + 2}
if tiempo_min is not None:
meta['tiempo_min'] = tiempo_min
if tiempo_max is not None:
meta['tiempo_max'] = tiempo_max
paso = Paso(
id=str(row.get('ID') or row.get('PASO_ID', '')),
esquema_id=str(row.get('ESQUEMA_ID', '')),
secuencia=int(row.get('SECUENCIA', 1)),
nombre=str(row.get('NOMBRE', 'Sin nombre')),
duracion=duracion,
tipo_equipo=tipo_equipo_str, # ✅ String directo
equipos_posibles=equipos_posibles,
analista_preferido=analista_preferido,
modo_interaccion=str(modo_int),
tiempo_interaccion=tiempo_interaccion,
frecuencia_interaccion=frecuencia_interaccion,
uniformidad=uniformidad,
pasos_predecesores=pasos_predecesores,
metadata=meta
)
pasos.append(paso)
except Exception as e:
print(f"⚠️ Error al cargar paso en fila {_ + 2}: {e}")
continue
return pasos
def cargar_analistas(df: pd.DataFrame) -> List[Analista]:
"""Carga analistas desde DataFrame (ya viene agrupado del ETL)."""
analistas = []
if DEBUG_VERBOSE:
print(f"\n🔍 DEBUG - cargar_analistas(): Cargando {len(df)} analistas...")
# El ETL ya agrupó por NOMBRE, así que cada fila es un analista único
for idx, row in df.iterrows():
try:
# Soportar HABILIDAD (singular) = un ID de ESQUEMA, o HABILIDADES = lista
skill_raw = row.get('HABILIDAD')
if pd.notna(skill_raw) and str(skill_raw).strip():
# una sola habilidad = exactamente el ID de ESQUEMA
habilidades = [str(skill_raw).strip().upper()]
else:
# fallback a columna HABILIDADES (lista separada por coma/; o similar)
habilidades = [s.strip().upper() for s in parsear_lista(row.get('HABILIDADES', '')) if s and str(s).strip()]
turnos = []
# ✅ FIX: Leer TURNOS transformado primero (tiene todos los días), TURNO como fallback
turnos_raw = row.get('TURNOS') or row.get('TURNO')
if DEBUG_VERBOSE and idx < 2: # DEBUG solo primeros 2 analistas
print(f" 🔍 Analista {row.get('NOMBRE')}: TURNOS/TURNO='{turnos_raw}'")
if pd.notna(turnos_raw) and str(turnos_raw).strip():
turnos_str = str(turnos_raw).strip().upper()
# ✅ FIX: Mapear texto a rangos horarios
turno_map = {
'MAÑANA': ('07:00', '14:00'),
'MANANA': ('07:00', '14:00'),
'TARDE': ('14:00', '22:00'),
'NOCHE': ('22:00', '07:00'),
}
# Si es texto simple (MAÑANA/TARDE/NOCHE), aplicar a todos los días L-V (0-4)
if turnos_str in turno_map:
hora_inicio, hora_fin = turno_map[turnos_str]
for dia in range(5): # Lunes=0 a Viernes=4
turnos.append(Turno(dia_semana=dia, hora_inicio=hora_inicio, hora_fin=hora_fin))
if DEBUG_VERBOSE and idx < 2:
print(f" ✅ Creados {len(turnos)} turnos (L-V) con horario {hora_inicio}-{hora_fin}")
else:
# Formato expandido: "1:08:00-16:00, 2:08:00-16:00, ..."
# Formato es: día:HH:MM-HH:MM
for turno_item in turnos_str.split(','):
turno_item = turno_item.strip()
if not turno_item:
continue
# Buscar el primer ':' que separa día del resto
if ':' in turno_item:
try:
# Separar día del rango horario
primer_colon = turno_item.index(':')
dia_str = turno_item[:primer_colon]
rango_horario = turno_item[primer_colon+1:]
dia = int(dia_str) - 1 # 1-7 → 0-6
# Parsear rango horario "08:00-16:00"
if '-' in rango_horario:
hora_inicio, hora_fin = rango_horario.split('-')
turnos.append(Turno(
dia_semana=dia,
hora_inicio=hora_inicio.strip(),
hora_fin=hora_fin.strip()
))
except Exception as e:
if DEBUG_VERBOSE and idx < 2:
print(f" ⚠️ Error parseando turno '{turno_item}': {e}")
continue
if DEBUG_VERBOSE and idx < 2:
print(f" ✅ Creados {len(turnos)} turnos del formato expandido")
if DEBUG_VERBOSE and idx < 2:
print(f" 📊 Total turnos para {row.get('NOMBRE')}: {len(turnos)}")
analista = Analista(
id=str(row.get('ID') or row.get('ANALISTA_ID', '')),
nombre=str(row.get('NOMBRE', 'Sin nombre')),
habilidades=habilidades,
turnos=turnos,
activo=bool(row.get('ACTIVO', True)),
metadata={
'fila_excel': idx + 2,
'esquema_id_original': str(row.get('ESQUEMA_ID', ''))
}
)
analistas.append(analista)
except Exception as e:
print(f"⚠️ Error al cargar analista en fila {idx + 2}: {e}")
continue
return analistas
def cargar_equipos(df: pd.DataFrame) -> List[Equipo]:
"""Carga equipos desde DataFrame"""
equipos = []
for _, row in df.iterrows():
try:
# ✅ USAR TIPO DIRECTAMENTE COMO STRING (sin mapeo)
tipo_str = str(row.get('TIPO', 'OTRO')).upper().strip()
# Mapear modo (esto sí se mantiene como enum)
modo_str = str(row.get('MODO', 'BATCH')).upper().replace(' ', '_')
modo_map = {
'BATCH': ModoEquipo.BATCH,
'ROLLING': ModoEquipo.ROLLING,
'BATCH_SCALING': ModoEquipo.BATCH_SCALING,
}
modo = modo_map.get(modo_str, ModoEquipo.BATCH)
equipo = Equipo(
id=str(row.get('ID') or row.get('EQUIPO_ID', '')),
tipo=tipo_str, # ✅ String directo, sin conversión
modo=modo,
capacidad=int(row.get('CAPACIDAD', 1)),
activo=bool(row.get('ACTIVO', True)),
tiempo_setup=float(row.get('TIEMPO_SETUP', 0)),
metadata={
'fila_excel': _ + 2,
'esquema_id_original': str(row.get('ESQUEMA_ID', ''))
}
)
equipos.append(equipo)
except Exception as e:
print(f"⚠️ Error al cargar equipo en fila {_ + 2}: {e}")
continue
return equipos
def cargar_datos_excel(
ruta_archivo: str,
hoja_ordenes: str = 'ORDENES',
hoja_esquemas: str = 'ESQUEMAS',
hoja_pasos: str = 'PASOS',
hoja_analistas: str = 'ANALISTAS',
hoja_equipos: str = 'EQUIPOS',
encoding: str = 'utf-8'
) -> Tuple[List[Orden], List[Esquema], List[Paso], List[Analista], List[Equipo]]:
"""Carga todos los datos desde un archivo Excel"""
print(f"📂 Cargando datos desde: {ruta_archivo}")
try:
excel_file = pd.ExcelFile(ruta_archivo)
print("📋 Hojas disponibles:", excel_file.sheet_names)
print("🔄 Cargando ÓRDENES...")
df_ordenes = limpiar_dataframe(pd.read_excel(excel_file, sheet_name=hoja_ordenes))
ordenes = cargar_ordenes(df_ordenes)
print(f" ✅ {len(ordenes)} órdenes cargadas")
print("🔄 Cargando ESQUEMAS...")
df_esquemas = limpiar_dataframe(pd.read_excel(excel_file, sheet_name=hoja_esquemas))
esquemas = cargar_esquemas(df_esquemas)
print(f" ✅ {len(esquemas)} esquemas cargados")
print("🔄 Cargando PASOS...")
df_pasos = limpiar_dataframe(pd.read_excel(excel_file, sheet_name=hoja_pasos))
pasos = cargar_pasos(df_pasos)
print(f" ✅ {len(pasos)} pasos cargados")
print("🔄 Cargando ANALISTAS...")
df_analistas = limpiar_dataframe(pd.read_excel(excel_file, sheet_name=hoja_analistas))
analistas = cargar_analistas(df_analistas)
print(f" ✅ {len(analistas)} analistas cargados")
print("🔄 Cargando EQUIPOS...")
df_equipos = limpiar_dataframe(pd.read_excel(excel_file, sheet_name=hoja_equipos))
equipos = cargar_equipos(df_equipos)
print(f" ✅ {len(equipos)} equipos cargados")
return ordenes, esquemas, pasos, analistas, equipos
except Exception as e:
print(f"❌ Error durante la carga: {e}")
raise
def crear_sistema_desde_excel(
ruta_archivo: str,
fecha_inicio: Optional[datetime] = None, # ✅ AGREGAR ESTE PARÁMETRO
**kwargs
) -> SistemaPlanificacion:
"""Crea un SistemaPlanificacion completo desde un archivo Excel"""
# Cargar datos
ordenes, esquemas, pasos, analistas, equipos = cargar_datos_excel(ruta_archivo, **kwargs)
# Crear sistema
sistema = SistemaPlanificacion(
fecha_inicio_planificacion=fecha_inicio # ✅ PASAR LA FECHA AQUÍ
)
# Agregar analistas y equipos
for analista in analistas:
sistema.agregar_analista(analista)
for equipo in equipos:
sistema.agregar_equipo(equipo)
# ... resto del código sin cambios ...
# ✅ CORRECCIÓN: Usar defaultdict para mantener MÚLTIPLES esquemas con el mismo ID
esquemas_dict = defaultdict(list)
for esq in esquemas:
esquemas_dict[esq.esquema_tipo if esq.esquema_tipo else esq.id].append(esq)
print(f"\n🔗 Asociando pasos a esquemas...")
pasos_asociados = 0
pasos_huerfanos = 0
for paso in pasos:
if paso.esquema_id in esquemas_dict:
# ✅ CORRECCIÓN: Agregar una copia del paso a cada esquema con ese ID
# Esto evita que un mismo objeto Paso sea compartido entre varios
# esquemas. La copia preserva la información original en el
# campo metadata['original_id'], de modo que podamos rastrear
# la fuente si es necesario.
for esquema in esquemas_dict[paso.esquema_id]:
# Clonar el objeto Paso para este esquema
cloned_paso = copy.deepcopy(paso)
try:
# Guardar el ID original por trazabilidad
cloned_paso.metadata = dict(cloned_paso.metadata or {})
cloned_paso.metadata['original_id'] = paso.id
except Exception:
# Si metadata no es un diccionario, recrearlo
cloned_paso.metadata = {'original_id': paso.id}
# Agregar la copia al esquema
esquema.agregar_paso(cloned_paso)
pasos_asociados += 1
else:
pasos_huerfanos += 1
if pasos_huerfanos <= 5:
print(f" ⚠️ Paso {paso.id} → Esquema '{paso.esquema_id}' (no encontrado)")
print(f" ✅ {pasos_asociados} pasos asociados")
if pasos_huerfanos > 0:
print(f" ⚠️ {pasos_huerfanos} pasos huérfanos (esquema no encontrado)")
for esquema in esquemas:
esquema.ordenar_pasos()
print(f"\n🔗 Asociando esquemas a órdenes...")
ordenes_dict = {ord.id: ord for ord in ordenes}
esquemas_asociados = 0
esquemas_huerfanos = 0
for esquema in esquemas:
if esquema.orden_id in ordenes_dict:
ordenes_dict[esquema.orden_id].agregar_esquema(esquema)
esquemas_asociados += 1
else:
esquemas_huerfanos += 1
if esquemas_huerfanos <= 5:
print(f" ⚠️ Esquema {esquema.id} → Orden '{esquema.orden_id}' (no encontrada)")
print(f" ✅ {esquemas_asociados} esquemas asociados")
if esquemas_huerfanos > 0:
print(f" ⚠️ {esquemas_huerfanos} esquemas huérfanos (orden no encontrada)")
for orden in ordenes:
sistema.agregar_orden(orden)
total_esquemas = sum(len(ord.esquemas) for ord in sistema.ordenes)
total_pasos = sum(len(esq.pasos) for ord in sistema.ordenes for esq in ord.esquemas)
print(f"\n🎯 Sistema de planificación creado:")
print(f" - {len(sistema.ordenes)} órdenes")
print(f" - {total_esquemas} esquemas totales")
print(f" - {total_pasos} pasos totales")
print(f" - {len(sistema.analistas)} analistas")
print(f" - {len(sistema.equipos)} equipos")
print(f" - Fecha inicio: {sistema.fecha_inicio_planificacion}") # ✅ MOSTRAR LA FECHA
# ============================================================================
# ✅ VALIDACIÓN: Esquemas sin analistas capacitados
# ============================================================================
print("\n🔍 Validando cobertura de habilidades...")
esquemas_sin_analista = []
for orden in sistema.ordenes:
for esquema in orden.esquemas:
# Usar esquema_id_original en lugar del ID compuesto
esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.id)
esquema_id_upper = str(esquema_id_original).strip().upper()
# Verificar si hay al menos un analista con esta habilidad
tiene_analista = False
for analista in sistema.analistas:
if analista.habilidades and esquema_id_upper in [str(h).strip().upper() for h in analista.habilidades]:
tiene_analista = True
break
if not tiene_analista:
esquemas_sin_analista.append({
'orden_id': orden.id,
'esquema_id': esquema.id,
'esquema_original': esquema_id_original,
'nombre': esquema.nombre
})
if esquemas_sin_analista:
print(f"\n⚠️ ADVERTENCIA: {len(esquemas_sin_analista)} esquemas NO tienen analistas capacitados:")
for esq in esquemas_sin_analista[:10]: # Mostrar solo los primeros 10
print(f" - Orden {esq['orden_id']}: {esq['esquema_original']} ({esq['nombre']})")
if len(esquemas_sin_analista) > 10:
print(f" ... y {len(esquemas_sin_analista) - 10} más")
print(" ℹ️ Estos pasos manuales NO se podrán programar en el scheduler.")
else:
print(" ✅ Todos los esquemas tienen al menos un analista capacitado")
return sistema
# Cell 5:
# ============================================================================
# PARTE 3: VALIDACIÓN Y SCHEDULER BUILDER
# ============================================================================
# ============================================================================
# VALIDACIÓN DE INTEGRIDAD Y JERARQUÍA
# ============================================================================
@dataclass
class ErrorValidacion:
"""Representa un error de validación"""
tipo: str
severidad: str
entidad: str
id_entidad: str
mensaje: str
detalles: Optional[Dict[str, Any]] = None
def __str__(self):
return f"[{self.severidad}] {self.entidad} '{self.id_entidad}': {self.mensaje}"
@dataclass
class ReporteValidacion:
"""Reporte completo de validación"""
es_valido: bool
errores: List[ErrorValidacion] = field(default_factory=list)
warnings: List[ErrorValidacion] = field(default_factory=list)
info: List[ErrorValidacion] = field(default_factory=list)
total_ordenes: int = 0
total_esquemas: int = 0
total_pasos: int = 0
total_analistas: int = 0
total_equipos: int = 0
def agregar_error(self, tipo: str, entidad: str, id_entidad: str, mensaje: str, detalles: Optional[Dict] = None):
error = ErrorValidacion(tipo=tipo, severidad='ERROR', entidad=entidad, id_entidad=id_entidad, mensaje=mensaje, detalles=detalles)
self.errores.append(error)
self.es_valido = False
def agregar_warning(self, tipo: str, entidad: str, id_entidad: str, mensaje: str, detalles: Optional[Dict] = None):
warning = ErrorValidacion(tipo=tipo, severidad='WARNING', entidad=entidad, id_entidad=id_entidad, mensaje=mensaje, detalles=detalles)
self.warnings.append(warning)
def imprimir_reporte(self, mostrar_info: bool = False):
print("\n" + "="*80)
print("📊 REPORTE DE VALIDACIÓN DE DATOS")
print("="*80)
print(f"\n📦 ENTIDADES: Órdenes={self.total_ordenes}, Esquemas={self.total_esquemas}, Pasos={self.total_pasos}")
print(f"📋 VALIDACIÓN: Errores={len(self.errores)}, Warnings={len(self.warnings)}")
if self.es_valido:
print(f"\n✅ DATOS VÁLIDOS")
else:
print(f"\n❌ DATOS INVÁLIDOS - {len(self.errores)} errores críticos")
if self.errores:
print(f"\n{'='*80}\n❌ ERRORES CRÍTICOS:")
for i, error in enumerate(self.errores[:10], 1):
print(f"{i}. {error}")
if self.warnings:
print(f"\n{'='*80}\n⚠️ ADVERTENCIAS:")
for i, warning in enumerate(self.warnings[:10], 1):
print(f"{i}. {warning}")
def validar_sistema(sistema: SistemaPlanificacion) -> ReporteValidacion:
"""Valida la integridad y coherencia de un SistemaPlanificacion"""
reporte = ReporteValidacion(es_valido=True)
reporte.total_ordenes = len(sistema.ordenes)
reporte.total_analistas = len(sistema.analistas)
reporte.total_equipos = len(sistema.equipos)
reporte.total_esquemas = sum(len(ord.esquemas) for ord in sistema.ordenes)
reporte.total_pasos = sum(len(esq.pasos) for ord in sistema.ordenes for esq in ord.esquemas)
print("🔍 Validando sistema...")
# Validar IDs únicos de órdenes
contador_ordenes = Counter([ord.id for ord in sistema.ordenes])
for orden_id, count in contador_ordenes.items():
if count > 1:
reporte.agregar_error('ID_DUPLICADO', 'ORDEN', orden_id, f'ID duplicado {count} veces')
# Validar esquemas
for orden in sistema.ordenes:
if not orden.esquemas:
reporte.agregar_warning('SIN_ESQUEMAS', 'ORDEN', orden.id, 'Orden sin esquemas')
for esquema in orden.esquemas:
if esquema.orden_id != orden.id:
reporte.agregar_error('REFERENCIA_INVALIDA', 'ESQUEMA', esquema.id,
f'Esquema pertenece a orden {esquema.orden_id} pero está en {orden.id}')
# ✅ WARNING: Solo si el esquema está en el sistema pero sin pasos
if not esquema.pasos:
reporte.agregar_warning(
'SIN_PASOS',
'ESQUEMA',
esquema.id,
f'Esquema en orden {orden.id} sin pasos asociados en hoja PASOS'
)
# Validar secuencias
secuencias = [paso.secuencia for paso in esquema.pasos]
if secuencias:
secuencias_ordenadas = sorted(secuencias)
secuencias_esperadas = list(range(1, len(secuencias) + 1))
if secuencias_ordenadas != secuencias_esperadas:
reporte.agregar_error('SECUENCIA_INVALIDA', 'ESQUEMA', esquema.id,
'Secuencias no consecutivas o no empiezan en 1')
# --- Validar habilidades de analistas vs IDs de esquemas ---
ids_esquema = set()
for orden in sistema.ordenes:
for esq in orden.esquemas:
# Usar esquema_id_original (no el ID compuesto)
esquema_id_original = esq.metadata.get('esquema_id_original', esq.esquema_tipo or esq.id)
ids_esquema.add(str(esquema_id_original).strip().upper())
for an in sistema.analistas:
for hab in (an.habilidades or []):
if str(hab).strip().upper() not in ids_esquema:
reporte.agregar_warning(
'HABILIDAD_DESCONOCIDA', 'ANALISTA', an.id,
f'Habilidad "{hab}" no corresponde a ningún ESQUEMA cargado'
)
# --- Validar que si un paso trae analista_preferido, esté capacitado para el esquema ---
for orden in sistema.ordenes:
for esq in orden.esquemas:
esquema_id_up = str(esq.id).strip().upper()
for p in esq.pasos:
pref = getattr(p, 'analista_preferido', None)
if pref:
# buscar analista
analista = next((a for a in sistema.analistas if str(a.id) == str(pref)), None)
if analista is None:
reporte.agregar_warning('ANALISTA_PREFERIDO_INEXISTENTE', 'PASO', str(p.id),
f'Analista preferido "{pref}" no existe')
else:
skills = {str(h).strip().upper() for h in (analista.habilidades or [])}
if esquema_id_up not in skills:
reporte.agregar_error('ANALISTA_NO_CAPACITADO', 'PASO', str(p.id),
f'Analista "{pref}" no está capacitado para ESQUEMA "{esquema_id_up}"')
print(f"✅ Validación completada: {'VÁLIDO' if reporte.es_valido else 'INVÁLIDO'}")
return reporte
# ============================================================================
# SCHEDULER BUILDER
# ============================================================================
@dataclass
class AsignacionRecurso:
"""Representa la asignación de un recurso en un período de tiempo"""
recurso_id: str
tipo_recurso: str
inicio: datetime
fin: datetime
paso_id: str
orden_id: str
esquema_id: str
muestra_id: str
def esta_ocupado(self, inicio_consulta: datetime, fin_consulta: datetime) -> bool:
return not (fin_consulta <= self.inicio or inicio_consulta >= self.fin)
@dataclass
class EstadoSimulacion:
"""Mantiene el estado actual de la simulación"""
asignaciones_analistas: List[AsignacionRecurso] = field(default_factory=list)
asignaciones_equipos: List[AsignacionRecurso] = field(default_factory=list)
pasos_completados: Dict[str, datetime] = field(default_factory=dict)
# ✅ NUEVO: Tracking para modo BATCH y tiempo de setup
batch_queues: Dict[str, List[Dict]] = field(default_factory=dict) # equipo_id -> lista de muestras en espera
ultimo_equipo_usado: Dict[str, Dict] = field(default_factory=dict) # paso_id -> {equipo_id, esquema_id, tiempo}
# ✅ NUEVO: Tracking para BATCH MANUAL (sin máquina)
batch_manual_buffers: Dict[str, Dict] = field(default_factory=dict)
# Key: f"{orden_id}_{esquema_id}_{paso_id}" # ⬅️ INCLUYE orden_id para agrupar solo muestras de la misma orden
# Value: {'primera_muestra_timestamp': datetime, 'muestras': [lista de IDs], 'inicio_batch': datetime}
def analista_disponible(self, analista_id: str, inicio: datetime, fin: datetime,
contexto_batch: Optional[Dict] = None) -> bool:
"""
Verifica si un analista está disponible en el rango [inicio, fin).
Args:
analista_id: ID del analista a verificar
inicio: Fecha/hora de inicio
fin: Fecha/hora de fin
contexto_batch: Dict opcional con:
- {'equipo': Equipo, 'esquema_id': str} para BATCH con máquina
- {'batch_manual': True, 'esquema_id': str} para BATCH MANUAL sin máquina
Returns:
True si está disponible, False si está ocupado
"""
for asig in self.asignaciones_analistas:
if asig.recurso_id == analista_id and asig.esta_ocupado(inicio, fin):
# ⚠️ HAY SOLAPAMIENTO - Verificar si es un batch legítimo
# 🔍 DEBUG MODO 4: Log cuando detecta solapamiento
if DEBUG_MODE_4_OVERLAPS:
is_mode_4_asig = '_interaccion_' in asig.paso_id
DEBUG_MODE_4['verification_logs'].append({
'timestamp': datetime.now(),
'action': 'OVERLAP_DETECTED',
'analista': analista_id,
'consulta_inicio': inicio,
'consulta_fin': fin,
'asignacion_existente': {
'paso_id': asig.paso_id,
'orden': asig.orden_id,
'esquema': asig.esquema_id,
'inicio': asig.inicio,
'fin': asig.fin,
'is_mode_4': is_mode_4_asig
},
'contexto_batch': contexto_batch is not None,
'contexto_batch_data': str(contexto_batch) if contexto_batch else None
})
# ✅ PERMITIR solape si es BATCH del mismo esquema en el mismo momento
if contexto_batch:
esquema_id = contexto_batch.get('esquema_id')
# CASO 1: BATCH con máquina
# Para equipos BATCH, pueden procesar múltiples muestras simultáneamente
# IMPORTANTE: Solo permite solapamiento si es el MISMO EQUIPO
# Un analista NO puede estar cargando dos equipos diferentes al mismo tiempo
equipo_nuevo = contexto_batch.get('equipo')
if (equipo_nuevo and esquema_id and
equipo_nuevo.modo in [ModoEquipo.BATCH, ModoEquipo.BATCH_SCALING]):
# ✅ Buscar qué equipo está usando la asignación existente
equipo_existente_id = None
# ✅ FIX: Buscar también con paso_id parcial (sin sufijos _interaccion_X)
paso_id_base = asig.paso_id.split('_interaccion_')[0] if '_interaccion_' in asig.paso_id else asig.paso_id
for a_eq in self.asignaciones_equipos:
# Comparar paso_id base (sin sufijos de interacción)
paso_eq_base = a_eq.paso_id.split('_interaccion_')[0] if '_interaccion_' in a_eq.paso_id else a_eq.paso_id
if (a_eq.orden_id == asig.orden_id and
a_eq.esquema_id == asig.esquema_id and
a_eq.muestra_id == asig.muestra_id and
paso_eq_base == paso_id_base and
abs((a_eq.inicio - asig.inicio).total_seconds()) < 60):
equipo_existente_id = a_eq.recurso_id
break
# Solo permite solapamiento si es el MISMO EQUIPO
if (equipo_existente_id and equipo_existente_id == equipo_nuevo.id and
asig.esquema_id == esquema_id and
abs((asig.inicio - inicio).total_seconds()) < 60):
# ✅ Mismo equipo - verificar capacidad
muestras_en_equipo = sum(
1 for a in self.asignaciones_equipos
if (a.recurso_id == equipo_nuevo.id and
a.esta_ocupado(inicio, fin) and
abs((a.inicio - inicio).total_seconds()) < 60)
)
if muestras_en_equipo < equipo_nuevo.capacidad:
# ✅ Hay capacidad disponible en el MISMO equipo - batch legítimo
if DEBUG_MODE_4_OVERLAPS:
DEBUG_MODE_4['verification_logs'][-1]['resultado'] = 'ALLOWED_BATCH_LEGITIMO'
continue
# ⚠️ NO hay capacidad - el analista SÍ está ocupado
# Caer a return False al final
# Si son equipos diferentes → NO permitir solapamiento
# Caer a return False al final
# CASO 2: BATCH MANUAL (sin máquina)
# Solo permite solapamiento si es mismo esquema Y misma orden Y MISMO PASO
orden_id_contexto = contexto_batch.get('orden_id')
paso_id_contexto = contexto_batch.get('paso_id') # ✅ FIX: Añadir verificación de paso
# Extraer paso_id base de la asignación existente (sin sufijo _interaccion_X)
paso_id_asig_base = asig.paso_id.split('_interaccion_')[0] if '_interaccion_' in asig.paso_id else asig.paso_id
if (contexto_batch.get('batch_manual') and esquema_id and orden_id_contexto and paso_id_contexto and
asig.esquema_id == esquema_id and
asig.orden_id == orden_id_contexto and
paso_id_asig_base == paso_id_contexto and # ✅ FIX: Verificar que sea el MISMO PASO
abs((asig.inicio - inicio).total_seconds()) < 60):
# ✅ Es un batch manual legítimo (mismo esquema, misma orden, MISMO PASO, mismo momento)
# NO cuenta como ocupado - el analista puede procesar múltiples muestras del mismo paso juntas
if DEBUG_MODE_4_OVERLAPS:
DEBUG_MODE_4['verification_logs'][-1]['resultado'] = 'ALLOWED_BATCH_MANUAL'
continue
# ❌ HAY SOLAPAMIENTO y NO es batch legítimo → Analista NO disponible
if DEBUG_MODE_4_OVERLAPS:
DEBUG_MODE_4['verification_logs'][-1]['resultado'] = 'REJECTED_NOT_AVAILABLE'
return False
# 🔍 DEBUG: Si llegamos aquí sin detectar solapamiento, loguear para casos específicos
if DEBUG_MODE_4_OVERLAPS and len(self.asignaciones_analistas) > 0:
# Solo log si es un analista que tiene asignaciones modo 4
tiene_modo_4 = any('_interaccion_' in a.paso_id and a.recurso_id == analista_id for a in self.asignaciones_analistas)
if tiene_modo_4:
DEBUG_MODE_4['verification_logs'].append({
'timestamp': datetime.now(),
'action': 'NO_OVERLAP_FOUND',
'analista': analista_id,
'consulta_inicio': inicio,
'consulta_fin': fin,
'total_asignaciones_analista': sum(1 for a in self.asignaciones_analistas if a.recurso_id == analista_id),
'contexto_batch': contexto_batch is not None
})
return True
def cuando_se_libera_analista(self, analista_id: str, desde: datetime) -> Optional[datetime]:
"""
Retorna cuándo se libera el analista después de 'desde'.
Si está libre ahora, retorna None.
Si está ocupado, retorna la hora más próxima en que se libera.
"""
# Buscar asignaciones que se SOLAPAN con 'desde' (está ocupado AHORA)
asignaciones_actuales = []
for asig in self.asignaciones_analistas:
if asig.recurso_id == analista_id:
# ¿Esta asignación se solapa con 'desde'?
if asig.inicio <= desde < asig.fin:
# Está ocupado desde AHORA hasta asig.fin
asignaciones_actuales.append(asig.fin)
# Si NO hay asignaciones que se solapen, está LIBRE ahora → retorna None
if not asignaciones_actuales:
return None
# Está ocupado → retornar cuándo se libera de la asignación más próxima
return min(asignaciones_actuales)
def equipo_disponible(self, equipo_id: str, inicio: datetime, fin: datetime) -> bool:
for asig in self.asignaciones_equipos:
if asig.recurso_id == equipo_id and asig.esta_ocupado(inicio, fin):
return False
return True
def cuando_se_libera_equipo(self, equipo_id: str, desde: datetime) -> Optional[datetime]:
"""
Retorna cuándo se libera el equipo después de 'desde'.
Si está libre ahora, retorna None.
Si está ocupado, retorna la hora más próxima en que se libera.
"""
# Buscar asignaciones que se SOLAPAN con 'desde' (está ocupado AHORA)
asignaciones_actuales = []
for asig in self.asignaciones_equipos:
if asig.recurso_id == equipo_id:
# ¿Esta asignación se solapa con 'desde'?
if asig.inicio <= desde < asig.fin:
# Está ocupado desde AHORA hasta asig.fin
asignaciones_actuales.append(asig.fin)
# Si NO hay asignaciones que se solapen, está LIBRE ahora → retorna None
if not asignaciones_actuales:
return None
# Está ocupado → retornar cuándo se libera de la asignación más próxima
return min(asignaciones_actuales)
def registrar_asignacion_analista(self, asignacion: AsignacionRecurso):
"""Registra una nueva asignación de analista."""
self.asignaciones_analistas.append(asignacion)
def registrar_asignacion_equipo(self, asignacion: AsignacionRecurso):
"""Registra una nueva asignación de equipo (SIN verificar overlaps para velocidad)."""
self.asignaciones_equipos.append(asignacion)
def paso_completado(self, paso_id: str) -> bool:
return paso_id in self.pasos_completados
def fecha_fin_paso(self, paso_id: str) -> Optional[datetime]:
return self.pasos_completados.get(paso_id)
def marcar_paso_completado(self, paso_id: str, fecha_fin: datetime):
self.pasos_completados[paso_id] = fecha_fin
# ============================================================================
# ✅ NUEVOS MÉTODOS PARA BATCH MANUAL (sin máquina)
# ============================================================================
def verificar_batch_manual(self, orden_id: str, esquema_id: str, paso_id: str,
timestamp_actual: datetime,
batch_manual_wait: float) -> Optional[Dict]:
"""
Verifica si existe un batch manual activo para esta orden/esquema/paso.
Solo agrupa muestras de la MISMA orden.
Returns:
Dict con info del batch si existe y aún está en ventana de espera
None si no hay batch o ya expiró
"""
buffer_key = f"{orden_id}_{esquema_id}_{paso_id}"
if buffer_key not in self.batch_manual_buffers:
return None
buffer_info = self.batch_manual_buffers[buffer_key]
primera_muestra_time = buffer_info['primera_muestra_timestamp']
# Verificar si la ventana de espera aún está activa
tiempo_transcurrido = (timestamp_actual - primera_muestra_time).total_seconds() / 60
if tiempo_transcurrido < batch_manual_wait:
# Ventana activa, puede unirse al batch
return buffer_info
else:
# Ventana expiró
return None
def agregar_a_batch_manual(self, orden_id: str, esquema_id: str, paso_id: str,
muestra_id: str,
timestamp_actual: datetime,
batch_manual_wait: float):
"""
Agrega una muestra al buffer de batch manual.
Si es la primera muestra, crea el buffer.
Solo agrupa muestras de la MISMA orden.
"""
buffer_key = f"{orden_id}_{esquema_id}_{paso_id}"
if buffer_key not in self.batch_manual_buffers:
# Primera muestra, crear buffer nuevo
inicio_batch = timestamp_actual + timedelta(minutes=batch_manual_wait)
self.batch_manual_buffers[buffer_key] = {
'primera_muestra_timestamp': timestamp_actual,
'muestras': [muestra_id], # Solo guardar muestra_id (orden ya está en la key)
'inicio_batch': inicio_batch,
'orden_id': orden_id,
'esquema_id': esquema_id,
'paso_id': paso_id
}
else:
# Agregar a batch existente
self.batch_manual_buffers[buffer_key]['muestras'].append(muestra_id)
def obtener_y_limpiar_batch_manual(self, orden_id: str, esquema_id: str, paso_id: str) -> Optional[Dict]:
"""
Obtiene el batch manual completo y lo elimina del buffer.
Se llama cuando se va a procesar el batch.
"""
buffer_key = f"{orden_id}_{esquema_id}_{paso_id}"
if buffer_key in self.batch_manual_buffers:
batch_info = self.batch_manual_buffers.pop(buffer_key)
return batch_info
return None
@dataclass
class TareaProgramada:
"""Representa una tarea programada en el cronograma"""
orden_id: str
esquema_id: str
muestra_id: str
paso: Paso
analista_asignado: Optional[str]
equipo_asignado: Optional[str]
fecha_inicio: datetime
fecha_fin: datetime
duracion_real: float
modo_interaccion: int
estado: str = "PROGRAMADO"
notas: str = ""
analista_preferido: Optional[str] = None
tipo_equipo: Optional[str] = None # ✅ Ya debería ser string
batch_id: Optional[str] = None
handoff_count: int = 0
modo_equipo: Optional[str] = None # ✅ NUEVO: BATCH, ROLLING o BATCH_SCALING
def to_dict(self) -> Dict[str, Any]:
return {
'ORDEN': self.orden_id,
'ESQUEMA': self.esquema_id,
'MUESTRA': self.muestra_id,
'PASO': self.paso.id,
'SECUENCIA': self.paso.secuencia,
'NOMBRE_PASO': self.paso.nombre,
'ANALISTA_ASIGNADO': self.analista_asignado or '',
'ANALISTA_PREFERIDO': self.analista_preferido or '',
'EQUIPO_ASIGNADO': self.equipo_asignado or '',
'TIPO_EQUIPO': self.tipo_equipo or '', # ✅ Ya es string
'MODO_EQUIPO': self.modo_equipo or '', # ✅ NUEVO: Modo del equipo (BATCH, ROLLING, BATCH_SCALING)
'FECHA_INICIO': self.fecha_inicio,
'FECHA_FIN': self.fecha_fin,
'FECHA_INICIO_REAL': None, # ✅ Columna para que el usuario registre fecha real
'FECHA_FIN_REAL': None, # ✅ Columna para que el usuario registre fecha real
'DURACION_REAL': self.duracion_real,
'MODO_INTERACCION': self.modo_interaccion,
'TIEMPO_INTERACCION': self.paso.tiempo_interaccion,
'FRECUENCIA_INTERACCION': self.paso.frecuencia_interaccion,
'ESTADO': self.estado,
'NOTAS': self.notas,
'BATCH_ID': self.batch_id or '',
'HANDOFF_COUNT': self.handoff_count,
'TARDANZA_HORAS': 0.0,
'INICIO_TARDIO': False,
'OBSERVACIONES': '',
'RESPONSABLE': '',
'VALIDADO': False,
}
def calcular_duracion_paso(paso: Paso, cantidad_muestras: int = 1,
equipo: Optional[Equipo] = None,
semilla: Optional[int] = None,
incluir_setup: float = 0.0) -> float:
"""
Calcula la duración de un paso considerando:
- Tiempo base del paso
- Variabilidad (uniformidad)
- Escalado por múltiples muestras (si aplica)
- Modo del equipo (si se proporciona)
- Tiempo de setup (si hay cambio de equipo/esquema)
Args:
paso: Paso a calcular
cantidad_muestras: Número de muestras a procesar
equipo: Equipo asignado (opcional)
semilla: Semilla para variabilidad (opcional)
incluir_setup: Tiempo adicional de setup en minutos (opcional)
Returns:
float: Duración en minutos
"""
if semilla is not None:
random.seed(semilla)
duracion_base = paso.duracion
if not paso.uniformidad and paso.metadata:
tiempo_min = paso.metadata.get('tiempo_min')
tiempo_max = paso.metadata.get('tiempo_max')
if tiempo_min is not None and tiempo_max is not None:
duracion_base = random.uniform(float(tiempo_min), float(tiempo_max))
# ✅ AGREGAR TIEMPO DE SETUP SI ES NECESARIO
duracion_total = duracion_base + incluir_setup
if equipo and equipo.modo == ModoEquipo.BATCH_SCALING:
factor_scaling = equipo.metadata.get('factor_scaling', 1.0)
duracion_escalada = duracion_total * (1 + (cantidad_muestras - 1) * factor_scaling * 0.1)
return duracion_escalada
# Manejar otros modos de equipo (BATCH y ROLLING tienen misma duración)
return duracion_total
# ============================================================================
# NUEVAS FUNCIONES PARA MODO_INTERACCION MEJORADO
# ============================================================================
def calcular_momentos_interaccion(modo: int, inicio: datetime, duracion: float,
tiempo_interaccion: float, frecuencia_interaccion: int) -> List[Tuple[datetime, datetime]]:
"""
Calcula los momentos exactos de interacción del analista según el MODO.
Args:
modo: 0=sin analista, 1=inicio, 2=fin, 3=inicio+fin, 4=periódico
inicio: fecha/hora de inicio de la tarea
duracion: duración total de la tarea en minutos
tiempo_interaccion: tiempo de cada interacción en minutos
frecuencia_interaccion: para modo 4, NÚMERO de revisiones/bloques (no minutos)
Ejemplo: frecuencia=4 → divide la tarea en 4 revisiones equidistantes
Returns:
Lista de tuplas (inicio_interaccion, fin_interaccion) donde el analista debe estar presente
"""
# ✅ Validación: tiempo_interaccion debe ser > 0 cuando hay analista
if modo > 0 and tiempo_interaccion <= 0:
tiempo_interaccion = 5.0 # Valor por defecto mínimo
momentos = []
fin_tarea = inicio + timedelta(minutes=duracion)
if modo == 0:
# Sin analista
return []
elif modo == 1:
# Analista solo al INICIO
fin_interaccion = inicio + timedelta(minutes=tiempo_interaccion)
momentos.append((inicio, fin_interaccion))
elif modo == 2:
# Analista solo al FINAL
inicio_interaccion = fin_tarea - timedelta(minutes=tiempo_interaccion)
momentos.append((inicio_interaccion, fin_tarea))
elif modo == 3:
# Analista al INICIO y al FINAL
fin_interaccion_inicio = inicio + timedelta(minutes=tiempo_interaccion)
momentos.append((inicio, fin_interaccion_inicio))
inicio_interaccion_final = fin_tarea - timedelta(minutes=tiempo_interaccion)
momentos.append((inicio_interaccion_final, fin_tarea))
elif modo == 4:
# ✅ PERIÓDICO - FRECUENCIA = número de bloques/revisiones
# Ejemplo: duración=120min, frecuencia=4 → revisiones en min 0, 40, 80, 120
if frecuencia_interaccion <= 0:
frecuencia_interaccion = 1 # Mínimo 1 revisión
# Calcular intervalo entre revisiones
if frecuencia_interaccion == 1:
# Solo una revisión al inicio
fin_interaccion = inicio + timedelta(minutes=tiempo_interaccion)
momentos.append((inicio, fin_interaccion))
else:
# Dividir la duración en bloques iguales
intervalo_minutos = duracion / (frecuencia_interaccion - 1)
for i in range(frecuencia_interaccion):
# Calcular el momento de esta revisión
minuto_revision = i * intervalo_minutos
momento_inicio = inicio + timedelta(minutes=minuto_revision)
momento_fin = momento_inicio + timedelta(minutes=tiempo_interaccion)
# Asegurar que no excede el fin de la tarea
if momento_fin > fin_tarea:
momento_fin = fin_tarea
# Solo agregar si el inicio está dentro del rango de la tarea
if momento_inicio < fin_tarea:
momentos.append((momento_inicio, momento_fin))
else:
# Modo 5 (legacy continua) - bloquea todo el tiempo
momentos.append((inicio, fin_tarea))
return momentos
def verificar_analista_disponible_momentos(analista_id: str, momentos: List[Tuple[datetime, datetime]],
estado: EstadoSimulacion, contexto_batch: Optional[Dict] = None) -> bool:
"""
Verifica si un analista está disponible en TODOS los momentos de interacción requeridos.
Args:
analista_id: ID del analista
momentos: Lista de (inicio, fin) donde el analista debe estar disponible
estado: Estado actual de la simulación
contexto_batch: Contexto de batch para permitir solapamientos legítimos
Returns:
True si está disponible en TODOS los momentos, False si no
"""
for inicio_momento, fin_momento in momentos:
if not estado.analista_disponible(analista_id, inicio_momento, fin_momento, contexto_batch):
return False
return True
def obtener_analista_para_momentos(paso: Paso, esquema: Esquema, momentos: List[Tuple[datetime, datetime]],
estado: EstadoSimulacion, analistas: List[Analista],
verbose: bool = False, contexto_batch: Optional[Dict] = None) -> Optional[List[str]]:
"""
Busca analista(s) capacitado(s) y disponible(s) para TODOS los momentos de interacción.
Permite RELEVO: diferentes analistas pueden cubrir diferentes momentos si un solo analista
no puede cubrir todos (ej: tarea cruza turnos).
Considera turnos y habilidades.
Args:
paso: Paso a programar
esquema: Esquema del paso
momentos: Lista de (inicio, fin) donde debe haber analista
estado: Estado de simulación
analistas: Lista de analistas disponibles
verbose: Modo verbose
contexto_batch: Dict opcional con {'equipo': Equipo, 'esquema_id': str} para solapes BATCH
Returns:
Lista de IDs de analistas (uno por momento), o None si algún momento no se puede cubrir
Ejemplo: ['ANA001', 'ANA001', 'ANA002'] → mismo analista momentos 0-1, relevo en momento 2
"""
if not momentos:
return []
esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.id)
esquema_id_up = str(esquema_id_original).strip().upper()
# 🔍 DEBUG: Info crítica al buscar analistas
if DEBUG_VERBOSE:
print(f"\n🔍 DEBUG obtener_analista_para_momentos: Esquema='{esquema_id_up}', Momentos={len(momentos)}")
print(f" Analistas totales: {len(analistas)}")
analistas_capacitados = [a for a in analistas if a.activo and esquema_id_up in {str(h).strip().upper() for h in (a.habilidades or [])}]
print(f" Analistas capacitados para '{esquema_id_up}': {len(analistas_capacitados)}")
if analistas_capacitados:
for a in analistas_capacitados[:2]: # Mostrar primeros 2
print(f" - {a.nombre}: {len(a.turnos)} turnos definidos")
if len(a.turnos) > 0:
print(f" Ejemplo turno: día={a.turnos[0].dia_semana}, {a.turnos[0].hora_inicio}-{a.turnos[0].hora_fin}")
if verbose:
print(f"\nDEBUG: Buscando analista(s) para {len(momentos)} momentos de interacción (con RELEVO si es necesario)")
for i, (ini, fin) in enumerate(momentos):
print(f" Momento {i+1}: {ini.strftime('%Y-%m-%d %H:%M')} - {fin.strftime('%Y-%m-%d %H:%M')}")
# ✅ ESTRATEGIA 1: Intentar que UN SOLO analista cubra todos los momentos (sin relevo)
# Primero intentar con analista preferido
if paso.analista_preferido:
pref = paso.analista_preferido
analista_pref = next((a for a in analistas if str(a.id) == str(pref)), None)
if analista_pref and analista_pref.activo:
if esquema_id_up in {str(h).strip().upper() for h in (analista_pref.habilidades or [])}:
todos_momentos_ok = True
for inicio_momento, fin_momento in momentos:
dentro_turno = _verificar_turno(analista_pref, inicio_momento, fin_momento)
if not dentro_turno or not estado.analista_disponible(pref, inicio_momento, fin_momento, contexto_batch):
todos_momentos_ok = False
break
if todos_momentos_ok:
if verbose:
print(f"DEBUG: ✅ Analista PREFERIDO {pref} cubre TODOS los momentos (sin relevo)")
return [pref] * len(momentos) # Mismo analista para todos los momentos
# Buscar cualquier analista que pueda cubrir TODOS los momentos
for an in analistas:
if not an.activo:
continue
skills = {str(h).strip().upper() for h in (an.habilidades or [])}
if esquema_id_up not in skills:
continue
todos_momentos_ok = True
if not an.turnos:
if verbose:
print(f" - Analista {an.id} ({an.nombre}): ❌ Sin turnos definidos")
continue
razon_rechazo = None
for inicio_momento, fin_momento in momentos:
dentro_turno = _verificar_turno(an, inicio_momento, fin_momento, verbose_debug=False)
if not dentro_turno:
todos_momentos_ok = False
razon_rechazo = f"Fuera de turno en {inicio_momento.strftime('%H:%M')}"
# 🔍 Si está fuera de turno y verbose está activo, volver a verificar con debug detallado
if verbose:
_verificar_turno(an, inicio_momento, fin_momento, verbose_debug=True)
break
if not estado.analista_disponible(an.id, inicio_momento, fin_momento, contexto_batch):
todos_momentos_ok = False
razon_rechazo = f"Ocupado en {inicio_momento.strftime('%H:%M')}-{fin_momento.strftime('%H:%M')}"
break
if todos_momentos_ok:
if verbose:
print(f" - Analista {an.id} ({an.nombre}): ✅ ASIGNADO")
return [an.id] * len(momentos)
elif verbose:
print(f" - Analista {an.id} ({an.nombre}): ❌ {razon_rechazo}")
# ✅ ESTRATEGIA 2: No se encontró UN analista para todo → Permitir RELEVO
if verbose:
print(f"DEBUG: ⚠️ Ningún analista puede cubrir todos los momentos. Intentando con RELEVO...")
analistas_asignados = []
for idx, (inicio_momento, fin_momento) in enumerate(momentos):
analista_encontrado = None
# Buscar un analista capacitado, de turno y disponible para ESTE momento específico
analistas_capacitados = [a for a in analistas if a.activo and esquema_id_up in {str(h).strip().upper() for h in (a.habilidades or [])}]
for an in analistas_capacitados:
if not an.turnos:
continue
dentro_turno = _verificar_turno(an, inicio_momento, fin_momento)
if dentro_turno and estado.analista_disponible(an.id, inicio_momento, fin_momento, contexto_batch):
analista_encontrado = an.id
if verbose:
print(f" Momento {idx+1}: ✅ Asignado a {an.id}")
break
if not analista_encontrado:
# ❌ No hay analista disponible para este momento
if verbose:
print(f" Momento {idx+1}: ❌ NO hay analista disponible")
return None
analistas_asignados.append(analista_encontrado)
# Verificar si hay relevo
if len(set(analistas_asignados)) > 1:
if verbose:
print(f"DEBUG: ✅ Asignación con RELEVO: {analistas_asignados}")
return analistas_asignados
def _verificar_turno(analista: Analista, inicio_momento: datetime, fin_momento: datetime, verbose_debug: bool = False) -> bool:
"""Función auxiliar para verificar si un momento cae dentro del turno del analista"""
if not analista.turnos:
return False
for turno in analista.turnos:
try:
# 🔧 IMPORTANTE: turno.dia_semana es el día en que COMIENZA el turno
# Para turnos nocturnos (22:00-07:00), dia_semana=0 (lunes) significa lunes 22:00 a martes 07:00
h_i_parts = [int(x) for x in turno.hora_inicio.split(':')]
h_f_parts = [int(x) for x in turno.hora_fin.split(':')]
# Detectar si es turno nocturno (cruza medianoche)
es_turno_nocturno = (h_f_parts[0] < h_i_parts[0]) or (h_f_parts[0] == h_i_parts[0] and h_f_parts[1] <= h_i_parts[1])
# CASO 1: Verificar si inicio_momento está en el día de inicio del turno
if inicio_momento.weekday() == turno.dia_semana:
hora_inicio_turno = inicio_momento.replace(hour=h_i_parts[0], minute=h_i_parts[1], second=0, microsecond=0)
hora_fin_turno = inicio_momento.replace(hour=h_f_parts[0], minute=h_f_parts[1], second=0, microsecond=0)
if es_turno_nocturno:
hora_fin_turno += timedelta(days=1)
if verbose_debug:
print(f" 🔍 {analista.nombre}: Turno día {turno.dia_semana} ({turno.hora_inicio}-{turno.hora_fin}) [INICIO]")
print(f" Momento: {inicio_momento.strftime('%Y-%m-%d %H:%M')} - {fin_momento.strftime('%H:%M')}")
print(f" Turno calculado: {hora_inicio_turno.strftime('%Y-%m-%d %H:%M')} - {hora_fin_turno.strftime('%Y-%m-%d %H:%M')}")
if inicio_momento >= hora_inicio_turno and fin_momento <= hora_fin_turno:
return True
# CASO 2: Si es turno nocturno, verificar si inicio_momento está en el día SIGUIENTE (parte nocturna)
if es_turno_nocturno:
dia_siguiente = (turno.dia_semana + 1) % 7
if inicio_momento.weekday() == dia_siguiente:
# Calcular el turno comenzando el día ANTERIOR
hora_inicio_turno = (inicio_momento - timedelta(days=1)).replace(hour=h_i_parts[0], minute=h_i_parts[1], second=0, microsecond=0)
hora_fin_turno = inicio_momento.replace(hour=h_f_parts[0], minute=h_f_parts[1], second=0, microsecond=0)
if verbose_debug:
print(f" 🔍 {analista.nombre}: Turno día {turno.dia_semana} ({turno.hora_inicio}-{turno.hora_fin}) [FIN nocturno]")
print(f" Momento: {inicio_momento.strftime('%Y-%m-%d %H:%M')} - {fin_momento.strftime('%H:%M')}")
print(f" Turno calculado: {hora_inicio_turno.strftime('%Y-%m-%d %H:%M')} - {hora_fin_turno.strftime('%Y-%m-%d %H:%M')}")
if inicio_momento >= hora_inicio_turno and fin_momento <= hora_fin_turno:
return True
except Exception as e:
if verbose_debug:
print(f" ⚠️ Excepción verificando turno: {e}")
continue
return False
def obtener_analista_disponible(paso: Paso, esquema: Esquema, inicio: datetime, duracion: float,
estado: EstadoSimulacion, analistas: List[Analista], verbose: bool = False) -> Optional[str]:
if verbose:
print(f"\nDEBUG: Buscando analista para paso {paso.id} en {inicio}. Dia semana: {inicio.weekday()}")
fin = inicio + timedelta(minutes=duracion)
# Usar esquema_id_original en lugar del ID compuesto
esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.id)
esquema_id_up = str(esquema_id_original).strip().upper()
# preferido solo si está disponible, capacitado y dentro de turno
if paso.analista_preferido:
pref = paso.analista_preferido
analista_pref = next((a for a in analistas if str(a.id) == str(pref)), None)
if (
analista_pref
and analista_pref.activo
and esquema_id_up in {str(h).strip().upper() for h in (analista_pref.habilidades or [])}
):
# Verificar que esté dentro de alguno de sus turnos
dentro_turno = True
if analista_pref.turnos:
dentro_turno = False
for turno in analista_pref.turnos:
try:
# Comprobar día de la semana y rango horario
if inicio.weekday() == turno.dia_semana:
h_i_parts = [int(x) for x in turno.hora_inicio.split(':')]
h_f_parts = [int(x) for x in turno.hora_fin.split(':')]
hora_inicio = inicio.replace(hour=h_i_parts[0], minute=h_i_parts[1], second=0, microsecond=0)
hora_fin_turno_calc = inicio.replace(hour=h_f_parts[0], minute=h_f_parts[1], second=0, microsecond=0)
# 🔧 FIX: Turno nocturno que cruza medianoche
if hora_fin_turno_calc <= hora_inicio:
hora_fin_turno_calc += timedelta(days=1)
if inicio >= hora_inicio and fin <= hora_fin_turno_calc:
dentro_turno = True
break
except Exception:
continue
if dentro_turno and estado.analista_disponible(pref, inicio, fin):
if verbose:
print(f"DEBUG: Encontrado analista PREFERIDO {pref} para paso {paso.id}")
return pref
# si no hay preferido válido, elegir cualquiera capacitado, disponible y dentro de turno
for an in analistas:
if not an.activo:
continue
skills = {str(h).strip().upper() for h in (an.habilidades or [])}
if esquema_id_up not in skills:
continue
if verbose:
print(f" - Verificando analista {an.id} (skills OK). Turnos: {[f'Dia:{t.dia_semana} {t.hora_inicio}-{t.hora_fin}' for t in an.turnos]}")
# Verificar turnos
dentro_turno = False # Iniciar como False
if not an.turnos:
if verbose:
print(f" -> Analista {an.id} NO tiene turnos definidos. Saltando.")
continue
for turno in an.turnos:
try:
if inicio.weekday() == turno.dia_semana:
if verbose:
print(f" -> Dia coincide (Dia script: {inicio.weekday()}, Dia analista: {turno.dia_semana})")
h_i_parts = [int(x) for x in turno.hora_inicio.split(':')]
h_f_parts = [int(x) for x in turno.hora_fin.split(':')]
hora_inicio_turno = inicio.replace(hour=h_i_parts[0], minute=h_i_parts[1], second=0, microsecond=0)
hora_fin_turno = inicio.replace(hour=h_f_parts[0], minute=h_f_parts[1], second=0, microsecond=0)
# 🔧 FIX: Turno nocturno que cruza medianoche
if hora_fin_turno <= hora_inicio_turno:
hora_fin_turno += timedelta(days=1)
if verbose:
print(f" -> Turno: {hora_inicio_turno.time()} - {hora_fin_turno.time()}. Tarea: {inicio.time()} - {fin.time()}")
if inicio >= hora_inicio_turno and fin <= hora_fin_turno:
dentro_turno = True
if verbose:
print(f" -> ¡DENTRO DE TURNO!")
break
except Exception as e:
if verbose:
print(f" -> ERROR parseando turno para analista {an.id}: {e}")
continue
if not dentro_turno:
if verbose:
print(f" -> Analista {an.id} no está de turno. Saltando.")
continue
if verbose:
print(f" -> Analista {an.id} está de turno. Verificando asignaciones...")
if estado.analista_disponible(an.id, inicio, fin):
if verbose:
print(f"DEBUG: Encontrado analista {an.id} para paso {paso.id}")
return an.id
else:
if verbose:
print(f" -> Analista {an.id} no está disponible (ocupado).")
if verbose:
print(f"DEBUG: NO se encontró analista para paso {paso.id} en {inicio}")
return None
def obtener_equipo_disponible(paso: Paso, inicio: datetime, duracion: float,
estado: EstadoSimulacion, equipos: List[Equipo]) -> Optional[Equipo]:
"""Busca un equipo compatible y disponible"""
fin = inicio + timedelta(minutes=duracion)
# ✅ CAMBIAR: Comparar strings en lugar de enums
equipos_compatibles = [
e for e in equipos
if e.activo and
e.tipo.upper() == paso.tipo_equipo.upper() and # Comparación de strings
(not paso.equipos_posibles or e.id in paso.equipos_posibles)
]
for equipo in equipos_compatibles:
# Verificar disponibilidad temporal del equipo (sin solapamientos) y capacidad
if not estado.equipo_disponible(equipo.id, inicio, fin):
continue
# Comprobar cuántas tareas se ejecutarán simultáneamente en este equipo
ocupadas = 0
for asign in estado.asignaciones_equipos:
if asign.recurso_id == equipo.id and asign.esta_ocupado(inicio, fin):
ocupadas += 1
# Si la capacidad del equipo permite una nueva tarea, asignarlo
if ocupadas < max(1, getattr(equipo, 'capacidad', 1)):
return equipo
return None
def calcular_inicio_mas_temprano(paso: Paso, esquema: Esquema, muestra_id: str,
estado: EstadoSimulacion, fecha_inicio_sistema: datetime,
orden_id: str = None, esquema_id: str = None) -> datetime:
inicio_mas_temprano = fecha_inicio_sistema
# ✅ FIX: Incluir orden_id y esquema_id para búsquedas de predecesores
if orden_id and esquema_id:
for predecesor_id in paso.pasos_predecesores:
predecesor_completo = f"{orden_id}_{esquema_id}_{predecesor_id}_M{muestra_id}"
if estado.paso_completado(predecesor_completo):
fecha_fin_pred = estado.fecha_fin_paso(predecesor_completo)
if fecha_fin_pred > inicio_mas_temprano:
inicio_mas_temprano = fecha_fin_pred
if paso.secuencia > 1:
for otro_paso in esquema.pasos:
if otro_paso.secuencia == paso.secuencia - 1:
paso_ant_id = f"{orden_id}_{esquema_id}_{otro_paso.id}_M{muestra_id}"
if estado.paso_completado(paso_ant_id):
fecha_fin_ant = estado.fecha_fin_paso(paso_ant_id)
if fecha_fin_ant > inicio_mas_temprano:
inicio_mas_temprano = fecha_fin_ant
break
else:
# Fallback para compatibilidad (sin orden_id/esquema_id)
for predecesor_id in paso.pasos_predecesores:
predecesor_completo = f"{predecesor_id}_M{muestra_id}"
if estado.paso_completado(predecesor_completo):
fecha_fin_pred = estado.fecha_fin_paso(predecesor_completo)
if fecha_fin_pred > inicio_mas_temprano:
inicio_mas_temprano = fecha_fin_pred
if paso.secuencia > 1:
for otro_paso in esquema.pasos:
if otro_paso.secuencia == paso.secuencia - 1:
paso_ant_id = f"{otro_paso.id}_M{muestra_id}"
if estado.paso_completado(paso_ant_id):
fecha_fin_ant = estado.fecha_fin_paso(paso_ant_id)
if fecha_fin_ant > inicio_mas_temprano:
inicio_mas_temprano = fecha_fin_ant
break
# ❌ DESHABILITADO: No forzar horarios hardcoded, respetar turnos de analistas
# El scheduler ya maneja la verificación de turnos con _verificar_turno()
# Esta lógica hardcoded (08:00-22:00) contradice los turnos reales de los analistas
#
# hora_inicio_turno = inicio_mas_temprano.replace(hour=8, minute=0, second=0, microsecond=0)
# hora_fin_turno = inicio_mas_temprano.replace(hour=22, minute=0, second=0, microsecond=0)
#
# if inicio_mas_temprano.time() < hora_inicio_turno.time():
# inicio_mas_temprano = hora_inicio_turno
# elif inicio_mas_temprano.time() >= hora_fin_turno.time():
# inicio_mas_temprano = hora_inicio_turno + timedelta(days=1)
return inicio_mas_temprano
def scheduler_builder(
sistema: SistemaPlanificacion,
cromosoma: Optional[List[str]] = None,
previous_plan: Optional[pd.DataFrame] = None,
batch_manual_wait: float = 60.0,
semilla: Optional[int] = None,
fecha_inicio: Optional[datetime] = None,
permitir_handoff: bool = True,
max_iteraciones: int = 50000,
verbose: bool = False,
# ✅ NUEVOS PARÁMETROS PARA HORIZONTE Y REINTENTOS ADAPTATIVOS
horizonte_inicial_dias: int = 9,
horizonte_maximo_dias: int = 15,
horizonte_dinamico: bool = True,
reintentos_adaptativos: bool = True,
max_reintentos_base: int = 500
) -> pd.DataFrame:
"""Construye un cronograma simulado a partir de un cromosoma"""
if semilla is not None:
random.seed(semilla)
if fecha_inicio is None:
fecha_inicio = sistema.fecha_inicio_planificacion
estado = EstadoSimulacion()
# ============================================================================
# ✅ CONFIGURACIÓN DE HORIZONTE ADAPTATIVO
# ============================================================================
if horizonte_dinamico:
# Calcular horizonte inicial basado en TAT máximo de órdenes
if sistema.ordenes:
max_tat_horas = max(
(orden.tat_prometido.total_seconds() / 3600
for orden in sistema.ordenes
if orden.tat_prometido),
default=horizonte_inicial_dias * 24
)
horizonte_inicial_calculado = min(
int(max_tat_horas / 24) + 2, # TAT + 2 días buffer
horizonte_maximo_dias
)
else:
horizonte_inicial_calculado = horizonte_inicial_dias
print(f"🕐 Horizonte DINÁMICO: inicial={horizonte_inicial_calculado}d, máximo={horizonte_maximo_dias}d")
else:
horizonte_inicial_calculado = horizonte_inicial_dias
print(f"🕐 Horizonte ESTÁTICO: {horizonte_inicial_dias} días")
# Variables de tracking global del horizonte
horizonte_global = fecha_inicio + timedelta(days=horizonte_inicial_calculado)
horizonte_absoluto = fecha_inicio + timedelta(days=horizonte_maximo_dias)
ultima_tarea_programada_fin = fecha_inicio
# Cargar previous_plan si existe
if previous_plan is not None and not previous_plan.empty:
print(f"📋 Cargando plan previo con {len(previous_plan)} tareas...")
pasos_en_proceso_cargados = [] # 🔍 DEBUG: Track EN_PROCESO steps
for _, row in previous_plan.iterrows():
# ✅ USAR FECHAS REALES SI EXISTEN, SINO USAR PLANIFICADAS
# Esto permite registrar lo que REALMENTE pasó vs lo que se planeó
fecha_inicio_real = row.get('FECHA_INICIO_REAL')
fecha_fin_real = row.get('FECHA_FIN_REAL')
# ✅ FIX: Verificar que no sea el texto placeholder "Completar aquí"
def es_fecha_valida(valor):
if pd.isna(valor):
return False
if isinstance(valor, str) and ('completar' in valor.lower() or valor.strip() == ''):
return False
return True
# Si hay fechas reales válidas, usarlas; sino, usar las planificadas
fecha_inicio = pd.to_datetime(fecha_inicio_real) if es_fecha_valida(fecha_inicio_real) else pd.to_datetime(row['FECHA_INICIO'])
fecha_fin = pd.to_datetime(fecha_fin_real) if es_fecha_valida(fecha_fin_real) else pd.to_datetime(row['FECHA_FIN'])
if pd.notna(row.get('ANALISTA_ASIGNADO')) and row['ANALISTA_ASIGNADO']:
analista_id = str(row['ANALISTA_ASIGNADO'])
esquema_id_up = str(row['ESQUEMA']).strip().upper()
analista_obj = next((a for a in sistema.analistas if str(a.id) == analista_id), None)
skills = {str(h).strip().upper() for h in (analista_obj.habilidades or [])} if analista_obj else set()
if analista_obj and esquema_id_up in skills:
asig_analista = AsignacionRecurso(
recurso_id=analista_id,
tipo_recurso='ANALISTA',
inicio=fecha_inicio, # ✅ Usa fecha real si existe
fin=fecha_fin, # ✅ Usa fecha real si existe
paso_id=str(row['PASO']),
orden_id=str(row['ORDEN']),
esquema_id=str(row['ESQUEMA']),
muestra_id=str(row.get('MUESTRA', '1'))
)
# ⚠️ NOTA: Al cargar plan previo, NO verificamos el return porque estamos
# cargando datos históricos que ya ocurrieron. Overlaps aquí son permitidos.
estado.registrar_asignacion_analista(asig_analista)
if pd.notna(row.get('EQUIPO_ASIGNADO')) and row['EQUIPO_ASIGNADO']:
asig_equipo = AsignacionRecurso(
recurso_id=str(row['EQUIPO_ASIGNADO']),
tipo_recurso='EQUIPO',
inicio=fecha_inicio, # ✅ Usa fecha real si existe
fin=fecha_fin, # ✅ Usa fecha real si existe
paso_id=str(row['PASO']),
orden_id=str(row['ORDEN']),
esquema_id=str(row['ESQUEMA']),
muestra_id=str(row['MUESTRA'])
)
# ⚠️ NOTA: Al cargar plan previo, NO verificamos el return (datos históricos)
estado.registrar_asignacion_equipo(asig_equipo)
# ✅ FIX: Incluir orden_id y esquema_id para que sea único
paso_id = f"{row['ORDEN']}_{row['ESQUEMA']}_{row['PASO']}_M{row['MUESTRA']}"
estado.marcar_paso_completado(paso_id, fecha_fin) # ✅ Usa fecha real si existe
# 🔍 DEBUG: Track EN_PROCESO steps
if row.get('ESTADO') == 'EN_PROCESO':
pasos_en_proceso_cargados.append(paso_id)
# 🔍 DEBUG: Report EN_PROCESO steps loaded
if pasos_en_proceso_cargados:
print(f" 🔍 DEBUG: {len(pasos_en_proceso_cargados)} pasos EN_PROCESO marcados como completados:")
for paso_id_debug in pasos_en_proceso_cargados[:5]: # Show first 5
print(f" - {paso_id_debug}")
if len(pasos_en_proceso_cargados) > 5:
print(f" ... y {len(pasos_en_proceso_cargados) - 5} más")
# Generar cromosoma por defecto si no se proporciona
if cromosoma is None:
# ✅ CROMOSOMA SEGMENTADO: Ordenar por prioridad_global (1-3) y urgencia (1-3)
# Prioridad 3 (alta) → Prioridad 2 (media) → Prioridad 1 (baja)
# Dentro de cada prioridad: Urgencia 3 → Urgencia 2 → Urgencia 1
print("🧬 Generando cromosoma segmentado por prioridad (1-3) y urgencia (1-3)...")
cromosoma = []
# Ordenar órdenes por prioridad_global (3→2→1) y fecha_recepcion
ordenes_ordenadas = sorted(
sistema.ordenes,
key=lambda o: (
-getattr(o, 'prioridad_global', 1), # ✅ Usa prioridad_global (escala 1-3)
getattr(o, 'fecha_recepcion', datetime.min)
)
)
for orden in ordenes_ordenadas:
# Ordenar esquemas por urgencia (3→2→1) y nombre
esquemas_ordenados = sorted(
orden.esquemas,
key=lambda esq: (
-getattr(esq.urgencia, 'value', 1), # ✅ Urgencia 3→2→1
esq.nombre
)
)
for esquema in esquemas_ordenados:
for muestra in range(1, esquema.cantidad_muestras + 1):
for paso in esquema.pasos:
orig_esq_id = esquema.metadata.get('esquema_id_original', esquema.esquema_tipo or esquema.id)
tarea_id = f"{orden.id}_{orig_esq_id}_{paso.id}_M{muestra}"
cromosoma.append(tarea_id)
print(f"📄 Procesando {len(cromosoma)} tareas del cromosoma...")
# ============================================================================
# ✅ ORDENAR CROMOSOMA POR PRIORIDAD Y URGENCIA (SIN VIOLAR RESTRICCIONES)
# ============================================================================
# Agrupar tareas por (orden_id, esquema_id, muestra_id) para preservar secuencias
grupos_tareas = {}
prioridades_grupos = {}
for idx, tarea_id in enumerate(cromosoma):
try:
partes = tarea_id.split('_')
if len(partes) < 4:
continue
orden_id = partes[0]
muestra_id = partes[-1]
esquema_id = '_'.join(partes[1:-2]) if len(partes) > 4 else partes[1]
grupo_key = f"{orden_id}_{esquema_id}_{muestra_id}"
# Agrupar tareas del mismo esquema-muestra
if grupo_key not in grupos_tareas:
grupos_tareas[grupo_key] = []
# ✅ Buscar orden y su prioridad_global (1-3)
orden = sistema.obtener_orden(orden_id)
prioridad = getattr(orden, 'prioridad_global', 1) if orden else 1 # Default 1 (baja)
# ✅ Buscar esquema y su urgencia (1-3)
urgencia = 1 # Default 1 (baja)
if orden:
for esq in orden.esquemas:
esq_id_original = esq.metadata.get('esquema_id_original', esq.esquema_tipo or esq.id)
if esq_id_original == esquema_id:
urgencia = getattr(esq.urgencia, 'value', 1) if hasattr(esq.urgencia, 'value') else 1
break
# Guardar prioridad del grupo: (prioridad 1-3, urgencia 1-3, idx)
prioridades_grupos[grupo_key] = (prioridad, urgencia, idx)
grupos_tareas[grupo_key].append(tarea_id)
except:
# Si falla el parseo, agregar tarea a un grupo por defecto
if 'default' not in grupos_tareas:
grupos_tareas['default'] = []
prioridades_grupos['default'] = (0, 0, 999999)
grupos_tareas['default'].append(tarea_id)
# ✅ ORDENAR GRUPOS por prioridad/urgencia (NO las tareas individuales)
# Esto mantiene el orden de secuencia dentro de cada grupo
grupos_ordenados = sorted(
grupos_tareas.keys(),
key=lambda gk: (
-prioridades_grupos.get(gk, (0, 0, 999999))[0], # Prioridad descendente
-prioridades_grupos.get(gk, (0, 0, 999999))[1], # Urgencia descendente
prioridades_grupos.get(gk, (0, 0, 999999))[2] # Orden original
)
)
# ✅ RECONSTRUIR cromosoma manteniendo orden interno de cada grupo
cromosoma_ordenado = []
for grupo_key in grupos_ordenados:
cromosoma_ordenado.extend(grupos_tareas[grupo_key])
print(f"🎯 Cromosoma reordenado por prioridad/urgencia ({len(grupos_ordenados)} grupos, respetando secuencias internas)")
tareas_programadas = []
tareas_no_programadas = []
iteracion = 0
tareas_procesadas = 0
for tarea_id in cromosoma_ordenado:
iteracion += 1
if iteracion > max_iteraciones:
print(f"⚠️ Límite de iteraciones alcanzado")
break
if verbose:
print(f"\n[DEBUG] Procesando tarea_id: {tarea_id}")
try:
partes = tarea_id.split('_')
if len(partes) < 4:
if verbose:
print(f"[DEBUG] Tarea ignorada: ID no tiene 4 partes. ({tarea_id})")
continue
# ✅ FIX: Parseo robusto - esquema puede tener múltiples partes
orden_id = partes[0]
muestra_id = partes[-1].replace('M', '')
paso_id = partes[-2]
esquema_id = '_'.join(partes[1:-2]) if len(partes) > 4 else partes[1]
if verbose:
print(f"[DEBUG] - Orden ID: {orden_id}, Esquema ID: {esquema_id}, Paso ID: {paso_id}, Muestra: {muestra_id}")
except:
if verbose:
print(f"[DEBUG] Tarea ignorada: Excepción al parsear ID. ({tarea_id})")
continue
# ✅ FIX: El paso_completo_id debe incluir orden_id para que sea único entre órdenes
# Múltiples órdenes pueden compartir el mismo esquema_id y paso_id (ej: POCALI-P19)
paso_completo_id = f"{orden_id}_{esquema_id}_{paso_id}_M{muestra_id}"
# 🔍 DEBUG: Check if EN_PROCESO step is being found in pasos_completados
es_completado = estado.paso_completado(paso_completo_id)
es_paso_en_proceso = paso_completo_id in pasos_en_proceso_cargados if 'pasos_en_proceso_cargados' in locals() else False
if es_paso_en_proceso and not es_completado:
print(f" ⚠️ DEBUG: Paso EN_PROCESO NO marcado como completado: {paso_completo_id}")
if es_completado:
continue
orden = sistema.obtener_orden(orden_id)
if not orden:
if verbose:
print(f"[DEBUG] Tarea ignorada: Orden no encontrada. (ID: {orden_id})")
continue
if verbose:
print(f"[DEBUG] - Orden encontrada: {orden.id}")
# FIX: Buscar esquema por esquema_id_original en metadata
esquema = None
for esq in orden.esquemas:
if esq.metadata.get("esquema_id_original") == esquema_id:
esquema = esq
break
if not esquema:
if verbose:
print(f"[DEBUG] Tarea ignorada: Esquema no encontrado. (ID: {esquema_id})")
print(f"[DEBUG] - Esquemas en la orden {orden_id}:")
for esq_in_orden in orden.esquemas:
print(f"[DEBUG] - ID: {esq_in_orden.id}, Original ID: {esq_in_orden.metadata.get('esquema_id_original')}")
continue
if verbose:
print(f"[DEBUG] - Esquema encontrado: {esquema.id} (Original: {esquema.metadata.get('esquema_id_original')})")
paso = None
for p in esquema.pasos:
if p.id == paso_id:
paso = p
break
if not paso:
if verbose:
print(f"[DEBUG] Tarea ignorada: Paso no encontrado. (ID: {paso_id})")
print(f"[DEBUG] - Pasos en el esquema {esquema.id}:")
for p_in_esq in esquema.pasos:
print(f"[DEBUG] - ID: {p_in_esq.id}, esquema_id: {p_in_esq.esquema_id}")
print(f"[DEBUG] - Buscando paso con ID={paso_id}")
# Intentar buscar sin el prefijo del esquema
paso_sin_prefijo = paso_id.split('-')[-1] if '-' in paso_id else paso_id
for p_in_esq in esquema.pasos:
if p_in_esq.id == paso_sin_prefijo or p_in_esq.id.endswith(paso_sin_prefijo):
paso = p_in_esq
if verbose:
print(f"[DEBUG] ✅ Encontrado paso por sufijo: {paso.id}")
break
if not paso:
continue
if verbose:
print(f"[DEBUG] - Paso encontrado: {paso.id}")
# ============================================================================
# ✅ CALCULAR REINTENTOS ADAPTATIVOS Y HORIZONTE POR TAREA
# ============================================================================
inicio = calcular_inicio_mas_temprano(paso, esquema, muestra_id, estado, fecha_inicio, orden_id, esquema_id)
# ✅ REINTENTOS ADAPTATIVOS: Aumentar si hay recursos disponibles
if reintentos_adaptativos:
# Verificar si existe al menos un analista capacitado y un equipo compatible
tiene_analista_capacitado = False
tiene_equipo_compatible = False
# Verificar analistas
# ✅ FIX: Manejar modo_interaccion como int o string
if isinstance(paso.modo_interaccion, (int, float)):
modo_int = int(paso.modo_interaccion)
elif isinstance(paso.modo_interaccion, str) and paso.modo_interaccion.isdigit():
modo_int = int(paso.modo_interaccion)
else:
modo_int = 0
if modo_int > 0:
esquema_id_up = str(esquema.metadata.get('esquema_id_original', esquema.id)).strip().upper()
# 🔍 DEBUG: DESACTIVADO para limpieza de output
# if 'POHUM129' in esquema_id_up or 'POCLR05' in esquema_id_up:
# print(f"\n🔍 DEBUG Esquema: {esquema_id_up}")
# analistas_con_skill = [a for a in sistema.analistas if a.activo and esquema_id_up in {str(h).strip().upper() for h in (a.habilidades or [])}]
# print(f" Analistas activos con skill '{esquema_id_up}': {len(analistas_con_skill)}")
# for a in analistas_con_skill[:3]:
# print(f" - {a.nombre}: {len(a.turnos)} turnos")
# if a.turnos:
# for t in a.turnos[:2]:
# print(f" Turno: día {t.dia_semana}, {t.hora_inicio}-{t.hora_fin}")
for an in sistema.analistas:
if an.activo and esquema_id_up in {str(h).strip().upper() for h in (an.habilidades or [])}:
if an.turnos: # Tiene al menos un turno definido
tiene_analista_capacitado = True
break
else:
tiene_analista_capacitado = True # No requiere analista
# Verificar equipos
tipo_equipo_upper = paso.tipo_equipo.upper() if paso.tipo_equipo else 'NONE'
requiere_equipo = (
paso.equipos_posibles or
(tipo_equipo_upper not in ['OTRO', 'NONE', '', 'NAN'])
)
if requiere_equipo:
if paso.equipos_posibles:
for eq_id in paso.equipos_posibles:
eq_obj = sistema.obtener_equipo(eq_id)
if eq_obj and eq_obj.activo:
tiene_equipo_compatible = True
break
else:
for eq in sistema.equipos:
if eq.tipo.upper() == tipo_equipo_upper and eq.activo:
tiene_equipo_compatible = True
break
else:
tiene_equipo_compatible = True # No requiere equipo
# ✅ PROTECCIÓN CONTRA BUCLES INFINITOS
# Si NO hay analista capacitado o NO hay equipo compatible, abortar inmediatamente
if not tiene_analista_capacitado:
logging.error(
f"❌ ABORT: Tarea {tarea_id} requiere analista con skill '{esquema_id_up}' "
f"pero NO existe ningún analista activo con esa habilidad y turnos definidos. "
f"Orden: {orden_id}, Esquema: {esquema_id}, Paso: {paso.id}"
)
# Marcar tarea como NO PROGRAMABLE y continuar con siguiente tarea
tareas_no_programadas.append({
'tarea_id': tarea_id,
'orden': orden_id,
'esquema': esquema_id,
'paso': paso.id,
'razon': f'No existe analista con habilidad {esquema_id_up}'
})
continue # Saltar al siguiente paso/orden
if not tiene_equipo_compatible:
logging.error(
f"❌ ABORT: Tarea {tarea_id} requiere equipo tipo '{tipo_equipo_upper}' "
f"pero NO existe ningún equipo activo de ese tipo. "
f"Orden: {orden_id}, Esquema: {esquema_id}, Paso: {paso.id}"
)
tareas_no_programadas.append({
'tarea_id': tarea_id,
'orden': orden_id,
'esquema': esquema_id,
'paso': paso.id,
'razon': f'No existe equipo tipo {tipo_equipo_upper}'
})
continue # Saltar al siguiente paso/orden
# Ajustar reintentos según disponibilidad de recursos
if tiene_analista_capacitado and tiene_equipo_compatible:
max_reintentos = max_reintentos_base * 3 # Triplicar reintentos si hay recursos
else:
max_reintentos = max_reintentos_base # Reintentos base si faltan recursos
else:
max_reintentos = max_reintentos_base
# ✅ HORIZONTE DINÁMICO POR TAREA
if horizonte_dinamico:
# Extender horizonte hasta la última tarea programada + buffer
horizonte_tarea = max(
horizonte_global, # Al menos el horizonte inicial
ultima_tarea_programada_fin + timedelta(days=1) # +1 día después de última tarea
)
# Limitar al horizonte absoluto
horizonte_tarea = min(horizonte_tarea, horizonte_absoluto)
else:
# Horizonte estático (original)
horizonte_tarea = fecha_inicio + timedelta(days=horizonte_inicial_calculado)
reintento = 0
tarea_programada = False
contador_fallos_analista = 0 # ✅ Contador de fallos consecutivos de analista
MAX_FALLOS_ANALISTA = 50 # ✅ Máximo de fallos consecutivos antes de abortar
while not tarea_programada and reintento < max_reintentos and inicio < horizonte_tarea:
reintento += 1
verbose_local = False # ✅ Definir al inicio del loop para evitar NameError
# ✅ PROTECCIÓN: Si hay demasiados fallos consecutivos de analista, abortar
if contador_fallos_analista >= MAX_FALLOS_ANALISTA:
logging.error(
f"❌ ABORT: Tarea {tarea_id} falló {MAX_FALLOS_ANALISTA} veces consecutivas "
f"al buscar analista. Es probable que los turnos no cubran el horario necesario. "
f"Orden: {orden_id}, Esquema: {esquema_id}, Paso: {paso.id}"
)
tareas_no_programadas.append({
'tarea_id': tarea_id,
'orden': orden_id,
'esquema': esquema_id,
'paso': paso.id,
'razon': f'Analista no disponible después de {MAX_FALLOS_ANALISTA} intentos (revisar turnos)'
})
break # Salir del while y continuar con siguiente tarea
# ❌ ESTA LÍNEA SE ELIMINÓ DE AQUÍ (estaba causando el bug)
# inicio = calcular_inicio_mas_temprano(paso, esquema, muestra_id, estado, fecha_inicio)
# ============================================================================
# ASIGNACIÓN DE EQUIPO (MEJORADA) + CÁLCULO DE SETUP
# ============================================================================
equipo_asignado = None
tipo_equipo_upper = paso.tipo_equipo.upper() if paso.tipo_equipo else 'NONE'
requiere_equipo = (
paso.equipos_posibles or
(tipo_equipo_upper not in ['OTRO', 'NONE', '', 'NAN'])
)
tiempo_setup = 0.0
if requiere_equipo:
equipo = obtener_equipo_disponible(paso, inicio, 0, estado, sistema.equipos) # Duración temporal = 0 para verificar disponibilidad temporal
if equipo:
# ✅ Equipo disponible
equipo_asignado = equipo.id
# ✅ CALCULAR TIEMPO DE SETUP si hay cambio de esquema/equipo
paso_key = f"{orden_id}_{esquema_id}_{paso.id}"
if paso_key in estado.ultimo_equipo_usado:
ultimo_uso = estado.ultimo_equipo_usado[paso_key]
# Aplicar setup si cambió de equipo O si cambió de esquema en el mismo equipo
if (ultimo_uso['equipo_id'] != equipo.id or
ultimo_uso.get('esquema_id') != esquema_id):
tiempo_setup = equipo.tiempo_setup
if verbose:
print(f" ⚙️ Setup de {tiempo_setup:.1f} min (cambio de {'equipo' if ultimo_uso['equipo_id'] != equipo.id else 'esquema'})")
# Registrar este uso para futuros setups
estado.ultimo_equipo_usado[paso_key] = {
'equipo_id': equipo.id,
'esquema_id': esquema_id,
'tiempo': inicio
}
# ✅ Calcular duración DESPUÉS de conocer el equipo y setup
duracion = calcular_duracion_paso(paso, 1, equipo, semilla, incluir_setup=tiempo_setup)
else:
# ❌ Equipo NO disponible - buscar próximo slot
# Identificar equipos candidatos
equipos_candidatos = []
if paso.equipos_posibles:
for eq_id in paso.equipos_posibles:
eq_obj = sistema.obtener_equipo(eq_id)
if eq_obj and eq_obj.activo:
equipos_candidatos.append(eq_obj)
else:
print(f"[Warning] Equipo ID '{eq_id}' requerido por el paso '{paso.id}' no se encuentra o está inactivo.")
else:
equipos_candidatos = [
e for e in sistema.equipos
if e.tipo.upper() == tipo_equipo_upper and e.activo
]
if not equipos_candidatos:
# No hay equipos de este tipo
print(f"[Debug] No se encontraron equipos candidatos para el paso '{paso.id}' que requiere el tipo '{tipo_equipo_upper}'.")
break # Salir del while y pasar a siguiente tarea
# 🎯 Buscar el próximo momento en que ALGUNO se libera (OPTIMIZADO)
proxima_disponibilidad = None
for eq_candidato in equipos_candidatos:
cuando_libre = estado.cuando_se_libera_equipo(eq_candidato.id, inicio)
if cuando_libre:
# Está ocupado, se libera en 'cuando_libre'
if proxima_disponibilidad is None or cuando_libre < proxima_disponibilidad:
proxima_disponibilidad = cuando_libre
if proxima_disponibilidad:
# 🚀 Saltar directamente al momento en que se libera
inicio = proxima_disponibilidad
if verbose:
print(f" ⏭️ Equipo ocupado. Saltando a {inicio.strftime('%Y-%m-%d %H:%M')}")
continue # ⬅️ CONTINÚA el WHILE interno
else:
# Ningún equipo está ocupado ahora, pero tampoco está disponible
# (puede ser por otra razón, ej: modo batch)
inicio = inicio + timedelta(minutes=5)
if verbose:
print(f" ⏭️ Avanzando 5 min a {inicio.strftime('%Y-%m-%d %H:%M')}")
continue # Reintentar con nuevo horario
else:
# ✅ No requiere equipo, calcular duración sin equipo
duracion = calcular_duracion_paso(paso, 1, None, semilla, incluir_setup=0)
# ============================================================================
# ✅ BATCH MANUAL - Verificar si debe esperar para acumular muestras
# ============================================================================
es_batch_manual = False
batch_manual_info = None
if not requiere_equipo and batch_manual_wait > 0:
# Es tarea sin máquina, verificar si puede hacer batch manual
batch_manual_info = estado.verificar_batch_manual(orden_id, esquema_id, paso.id, inicio, batch_manual_wait)
if batch_manual_info:
# ✅ HAY UN BATCH ACTIVO - Unirse al batch (misma orden)
es_batch_manual = True
inicio = batch_manual_info['inicio_batch'] # ⬅️ USAR el inicio del batch
estado.agregar_a_batch_manual(orden_id, esquema_id, paso.id, muestra_id, inicio, batch_manual_wait)
else:
# ✅ NO HAY BATCH - Crear uno nuevo y esperar
es_batch_manual = True
# Calcular el inicio del batch (después de esperar)
inicio_batch = inicio + timedelta(minutes=batch_manual_wait)
estado.agregar_a_batch_manual(orden_id, esquema_id, paso.id, muestra_id, inicio, batch_manual_wait)
inicio = inicio_batch # ⬅️ Inicio después de esperar
# ============================================================================
# ASIGNACIÓN DE ANALISTA - NUEVO SISTEMA CON MOMENTOS DE INTERACCIÓN
# ============================================================================
analista_asignado = None
momentos_interaccion = []
# ✅ FIX: Manejar modo_interaccion como int o string
if isinstance(paso.modo_interaccion, (int, float)):
modo_interaccion_val = int(paso.modo_interaccion)
elif isinstance(paso.modo_interaccion, str) and paso.modo_interaccion.isdigit():
modo_interaccion_val = int(paso.modo_interaccion)
else:
modo_interaccion_val = 0
if modo_interaccion_val > 0:
# 🔍 DEBUG para esquemas problemáticos - DESACTIVADO para limpieza
# if 'POHUM129' in esquema_id or 'POCLR05' in esquema_id:
# print(f"\n🔍 Intentando asignar analista para {tarea_id}")
# print(f" Esquema: {esquema_id}, Modo: {modo_interaccion_val}")
# print(f" Inicio: {inicio.strftime('%Y-%m-%d %H:%M')}, Duración: {duracion} min")
# ✅ CALCULAR MOMENTOS DE INTERACCIÓN según el MODO (1, 2, 3, 4, 5)
momentos_interaccion = calcular_momentos_interaccion(
modo=modo_interaccion_val,
inicio=inicio,
duracion=duracion,
tiempo_interaccion=paso.tiempo_interaccion,
frecuencia_interaccion=paso.frecuencia_interaccion
)
if verbose and momentos_interaccion:
print(f" 📋 MODO {modo_interaccion_val}: Se requiere analista en {len(momentos_interaccion)} momento(s)")
# ✅ PREPARAR CONTEXTO BATCH si el equipo es BATCH o BATCH_SCALING
contexto_batch = None
if equipo_asignado:
equipo_obj = sistema.obtener_equipo(equipo_asignado)
if equipo_obj and equipo_obj.modo in [ModoEquipo.BATCH, ModoEquipo.BATCH_SCALING]:
contexto_batch = {'equipo': equipo_obj, 'esquema_id': esquema_id}
elif es_batch_manual:
# ✅ BATCH MANUAL (sin máquina) - incluir orden_id Y paso_id para verificar que sea misma orden Y mismo paso
contexto_batch = {'batch_manual': True, 'esquema_id': esquema_id, 'orden_id': orden_id, 'paso_id': paso.id}
# ✅ BUSCAR ANALISTA(S) DISPONIBLE(S) EN TODOS LOS MOMENTOS (permite relevo)
verbose_local = False
analistas_ids = obtener_analista_para_momentos(paso, esquema, momentos_interaccion, estado, sistema.analistas, verbose_local, contexto_batch)
if analistas_ids:
# analistas_ids es una LISTA (puede tener relevo)
# Para compatibilidad, guardamos el primer analista en analista_asignado
analista_asignado = analistas_ids[0] if isinstance(analistas_ids, list) else analistas_ids
contador_fallos_analista = 0 # ✅ RESET contador cuando se encuentra analista
else:
# ❌ No hay analista disponible para TODOS los momentos requeridos
contador_fallos_analista += 1 # ✅ INCREMENTAR contador de fallos
# 🔍 DETECCIÓN INTELIGENTE: ¿Todos los analistas capacitados están fuera de turno?
analistas_capacitados = [
a for a in sistema.analistas
if a.activo and esquema_id_up in {str(h).strip().upper() for h in (a.habilidades or [])}
]
todos_fuera_turno = True
fin_momento_prueba = inicio + timedelta(minutes=duracion)
for an in analistas_capacitados:
if not an.turnos:
continue
# ✅ Usar _verificar_turno() que maneja correctamente turnos nocturnos
if _verificar_turno(an, inicio, fin_momento_prueba, verbose_debug=False):
todos_fuera_turno = False
break
# Si TODOS los analistas capacitados están fuera de turno, saltar al siguiente turno disponible
if todos_fuera_turno:
# Buscar el próximo turno disponible (incluyendo HOY si hay turnos más tarde)
proximo_turno_encontrado = False
# 🔧 Buscar en un rango de 8 días (hoy + 7 días más)
for dia_offset in range(0, 8):
dia_a_probar = (inicio + timedelta(days=dia_offset))
dia_semana_a_probar = dia_a_probar.weekday()
# Buscar la hora más temprana de turno en ese día que sea POSTERIOR a 'inicio'
hora_turno_mas_temprana = None
for an in analistas_capacitados:
if not an.turnos:
continue
for turno in an.turnos:
if turno.dia_semana == dia_semana_a_probar:
h_i_parts = [int(x) for x in turno.hora_inicio.split(':')]
hora_turno = dia_a_probar.replace(hour=h_i_parts[0], minute=h_i_parts[1], second=0, microsecond=0)
# Solo considerar si es posterior al inicio actual
if hora_turno > inicio:
if hora_turno_mas_temprana is None or hora_turno < hora_turno_mas_temprana:
hora_turno_mas_temprana = hora_turno
if hora_turno_mas_temprana:
inicio = hora_turno_mas_temprana
proximo_turno_encontrado = True
# ✅ RESETEAR contador cuando saltamos a otro turno (nueva oportunidad)
contador_fallos_analista = 0
if verbose_local:
print(f" ⏩ Todos fuera de turno. Saltando a {inicio.strftime('%Y-%m-%d %H:%M')}")
break
if not proximo_turno_encontrado:
# No se encontró turno disponible en los próximos 7 días
# En lugar de abortar, avanzar 1 día y continuar buscando
inicio = inicio + timedelta(days=1)
inicio = inicio.replace(hour=7, minute=0, second=0, microsecond=0)
if verbose_local:
print(f" ⚠️ No hay turnos libres en próximos 7 días. Avanzando a {inicio.strftime('%Y-%m-%d %H:%M')}")
continue
# ✅ Si NO todos están fuera de turno, significa que ALGUNOS están en turno pero OCUPADOS
# 🎯 ESTRATEGIA INTELIGENTE: Buscar cuándo se libera el analista más próximo
proxima_liberacion = None
analista_proximo = None
for an in analistas_capacitados:
if not an.turnos:
continue
# Verificar si este analista tiene turno en este horario
if _verificar_turno(an, inicio, fin_momento_prueba, verbose_debug=False):
# Está en turno, ¿cuándo se libera?
cuando_libre = estado.cuando_se_libera_analista(an.id, inicio)
if cuando_libre:
# ✅ CRÍTICO: Verificar que cuando se libere, AÚN esté en turno
momento_fin_tarea = cuando_libre + timedelta(minutes=duracion)
if _verificar_turno(an, cuando_libre, momento_fin_tarea, verbose_debug=False):
# Se libera y la tarea completa cabe en su turno
if proxima_liberacion is None or cuando_libre < proxima_liberacion:
proxima_liberacion = cuando_libre
analista_proximo = an.nombre
# Si NO cabe en el turno, este analista no sirve para hoy
if proxima_liberacion:
# 🚀 SALTAR directamente a cuando se libere
inicio = proxima_liberacion
if verbose_local:
print(f" ⏭️ Saltando a {inicio.strftime('%Y-%m-%d %H:%M')} (analista {analista_proximo} se libera)")
print(f" [Intento {reintento}, Fallos consecutivos: {contador_fallos_analista}/{MAX_FALLOS_ANALISTA}]")
else:
# Ningún analista se libera a tiempo dentro de su turno hoy
# 🔄 SALTAR AL SIGUIENTE DÍA con turno disponible
if verbose_local:
print(f" ⚠️ Ningún analista se libera a tiempo hoy. Buscando siguiente día con turno...")
# Usar la misma lógica de búsqueda de próximo turno
proximo_turno_encontrado = False
for dia_offset in range(1, 8): # Empezar desde MAÑANA
dia_a_probar = (inicio + timedelta(days=dia_offset))
dia_semana_a_probar = dia_a_probar.weekday()
hora_turno_mas_temprana = None
for an in analistas_capacitados:
if not an.turnos:
continue
for turno in an.turnos:
if turno.dia_semana == dia_semana_a_probar:
h_i_parts = [int(x) for x in turno.hora_inicio.split(':')]
hora_turno = dia_a_probar.replace(hour=h_i_parts[0], minute=h_i_parts[1], second=0, microsecond=0)
if hora_turno > inicio:
if hora_turno_mas_temprana is None or hora_turno < hora_turno_mas_temprana:
hora_turno_mas_temprana = hora_turno
if hora_turno_mas_temprana:
inicio = hora_turno_mas_temprana
proximo_turno_encontrado = True
# ✅ RESETEAR contador cuando saltamos a otro día (nueva oportunidad)
contador_fallos_analista = 0
if verbose_local:
print(f" ⏩ Saltando al siguiente día con turno: {inicio.strftime('%Y-%m-%d %H:%M')}")
break
if not proximo_turno_encontrado:
# No hay turnos en próximos 7 días, avanzar 1 día
inicio = inicio + timedelta(days=1)
inicio = inicio.replace(hour=7, minute=0, second=0, microsecond=0)
if verbose_local:
print(f" ⚠️ No hay turnos libres en próximos 7 días. Avanzando a {inicio.strftime('%Y-%m-%d %H:%M')}")
continue # Reintentar
# ============================================================================
# ✅ RECURSOS ASIGNADOS - CREAR TAREA
# ============================================================================
fecha_fin = inicio + timedelta(minutes=duracion)
# ✅ Obtener modo del equipo y generar BATCH_ID si aplica
modo_equipo_str = None
batch_id_generado = None
if equipo_asignado:
equipo_obj = sistema.obtener_equipo(equipo_asignado)
if equipo_obj and equipo_obj.modo:
modo_equipo_str = equipo_obj.modo.value # Batch, Rolling o Batch Scaling
# ✅ GENERAR BATCH_ID solo para equipos BATCH/BATCH_SCALING
# Formato: EQUIPO_ESQUEMA_PASO_YYYYMMDD_HHMM
# Todas las muestras del mismo esquema en el mismo equipo/paso/momento compartirán BATCH_ID
if equipo_obj.modo in [ModoEquipo.BATCH, ModoEquipo.BATCH_SCALING]:
batch_id_generado = f"{equipo_asignado}_{esquema_id}_{paso.id}_{inicio.strftime('%Y%m%d_%H%M')}"
elif es_batch_manual:
# ✅ GENERAR BATCH_ID para BATCH MANUAL (sin máquina)
# Formato: MANUAL_ORDEN_ESQUEMA_PASO_YYYYMMDD_HHMM
batch_id_generado = f"MANUAL_{orden_id}_{esquema_id}_{paso.id}_{inicio.strftime('%Y%m%d_%H%M')}"
modo_equipo_str = "Batch Manual" # Indicar que es batch manual
# 🔍 DEBUG: Verificar generación de batch_id (solo primeras 5 tareas)
if tareas_procesadas < 5:
print(f" 🔍 DEBUG Tarea {tareas_procesadas + 1}: {orden_id}_{esquema_id}_{paso.id}")
print(f" - equipo_asignado: {equipo_asignado}")
print(f" - modo_equipo_str: {modo_equipo_str}")
print(f" - es_batch_manual: {es_batch_manual}")
print(f" - batch_id_generado: {batch_id_generado}")
tarea = TareaProgramada(
orden_id=orden_id,
esquema_id=esquema_id,
muestra_id=muestra_id,
paso=paso,
analista_asignado=analista_asignado,
equipo_asignado=equipo_asignado,
fecha_inicio=inicio,
fecha_fin=fecha_fin,
duracion_real=duracion,
modo_interaccion=modo_interaccion_val,
analista_preferido=paso.analista_preferido,
tipo_equipo=paso.tipo_equipo,
modo_equipo=modo_equipo_str, # ✅ Modo del equipo
batch_id=batch_id_generado # ✅ BATCH_ID automático (solo para BATCH/BATCH_SCALING)
)
tareas_programadas.append(tarea)
tareas_procesadas += 1
# ✅ ACTUALIZAR HORIZONTE GLOBAL SI ESTA TAREA LO EXTIENDE
if horizonte_dinamico and fecha_fin > ultima_tarea_programada_fin:
ultima_tarea_programada_fin = fecha_fin
nuevo_horizonte = min(
fecha_fin + timedelta(days=1), # +1 día después de última tarea
horizonte_absoluto
)
if nuevo_horizonte > horizonte_global:
horizonte_global = nuevo_horizonte
if verbose:
print(f" ⏩ Horizonte extendido a {horizonte_global.strftime('%Y-%m-%d %H:%M')}")
# Registrar recursos
if analista_asignado:
# ✅ BLOQUEO DE ANALISTAS según modo de interacción - SOLO los momentos específicos
if momentos_interaccion and analistas_ids:
if DEBUG_VERBOSE:
print(f" 🔍 Registrando analista para {paso_id}: modo={modo_interaccion_val}")
# TODOS LOS MODOS (1, 2, 3, 4, 5): Bloquear solo los momentos específicos
for idx, (momento_inicio, momento_fin) in enumerate(momentos_interaccion):
# Obtener el analista asignado a ESTE momento específico (puede ser diferente por relevo)
analista_momento = analistas_ids[idx] if isinstance(analistas_ids, list) and idx < len(analistas_ids) else analista_asignado
asig = AsignacionRecurso(
recurso_id=analista_momento,
tipo_recurso='ANALISTA',
inicio=momento_inicio,
fin=momento_fin,
paso_id=f"{paso_id}_interaccion_{idx+1}", # ID único para cada momento
orden_id=orden_id,
esquema_id=esquema_id,
muestra_id=muestra_id
)
estado.registrar_asignacion_analista(asig)
# 🔍 DEBUG MODO 4: Log cuando registra asignación
if DEBUG_MODE_4_OVERLAPS and modo_interaccion_val == 4:
DEBUG_MODE_4['registrations'].append({
'timestamp': datetime.now(),
'paso_id': paso_id,
'modo': modo_interaccion_val,
'analista': analista_momento,
'orden': orden_id,
'esquema': esquema_id,
'muestra': muestra_id,
'momento_idx': idx + 1,
'momento_inicio': momento_inicio,
'momento_fin': momento_fin,
'equipo': equipo_asignado if equipo_asignado else '(sin equipo)',
'modo_equipo': modo_equipo_str if 'modo_equipo_str' in locals() else 'N/A'
})
if verbose:
print(f" ✅ Analista {analista_momento} reservado momento {idx+1}: {momento_inicio.strftime('%H:%M')}-{momento_fin.strftime('%H:%M')}")
else:
# Legacy: Si no hay momentos definidos (modo 0), no registrar analista
pass
if equipo_asignado:
asig = AsignacionRecurso(
recurso_id=equipo_asignado,
tipo_recurso='EQUIPO',
inicio=inicio,
fin=fecha_fin,
paso_id=paso_id,
orden_id=orden_id,
esquema_id=esquema_id,
muestra_id=muestra_id
)
estado.registrar_asignacion_equipo(asig)
estado.marcar_paso_completado(paso_completo_id, fecha_fin)
tarea_programada = True # ✅ Salir del while
if not tarea_programada:
if inicio >= horizonte_tarea:
if horizonte_dinamico:
dias_usados = (inicio - fecha_inicio).days
logging.warning(
f"Tarea {tarea_id} excedió horizonte dinámico "
f"(inicio: {inicio.strftime('%Y-%m-%d %H:%M')}, "
f"horizonte: {horizonte_tarea.strftime('%Y-%m-%d %H:%M')}, "
f"días usados: {dias_usados}, límite: {horizonte_maximo_dias}d)"
)
else:
logging.warning(
f"Tarea {tarea_id} excedió horizonte estático de {horizonte_inicial_calculado} días "
f"(inicio: {inicio.strftime('%Y-%m-%d %H:%M')})"
)
elif reintento >= max_reintentos:
logging.warning(
f"Tarea {tarea_id} alcanzó max_reintentos ({max_reintentos}) "
f"{'[ADAPTATIVOS ACTIVADOS]' if reintentos_adaptativos else '[FIJOS]'}"
)
else:
logging.warning(f"Tarea {tarea_id} no pudo programarse después de {reintento} reintentos")
print(f"✅ Procesadas {tareas_procesadas} tareas")
# ✅ REPORTE DE HORIZONTE Y REINTENTOS
if horizonte_dinamico:
dias_finales = (ultima_tarea_programada_fin - fecha_inicio).days
print(f"📊 Horizonte final: {dias_finales} días (límite: {horizonte_maximo_dias} días)")
if dias_finales >= horizonte_maximo_dias:
print(f"⚠️ Horizonte alcanzó el límite máximo")
if reintentos_adaptativos:
print(f"🔄 Reintentos adaptativos: ACTIVADOS (base={max_reintentos_base}, máx={max_reintentos_base*3})")
if not tareas_programadas:
print("⚠️ No se generaron tareas programadas")
return pd.DataFrame()
registros = [tarea.to_dict() for tarea in tareas_programadas]
df_cronograma = pd.DataFrame(registros)
df_cronograma = df_cronograma.sort_values('FECHA_INICIO').reset_index(drop=True)
# 🔍 DEBUG: Verificar valores de BATCH_ID y ESTADO después de crear DataFrame
if not df_cronograma.empty and 'BATCH_ID' in df_cronograma.columns and 'ESTADO' in df_cronograma.columns:
print(f"\n🔍 DEBUG: Primeros valores en DataFrame cronograma:")
print(f" BATCH_ID (primeros 3): {list(df_cronograma['BATCH_ID'].head(3))}")
print(f" ESTADO (primeros 3): {list(df_cronograma['ESTADO'].head(3))}")
# ✅ AGREGAR NOMBRE DEL ANALISTA al cronograma
if not df_cronograma.empty and 'ANALISTA_ASIGNADO' in df_cronograma.columns:
# Crear diccionario ID → NOMBRE
analista_id_to_nombre = {str(a.id): a.nombre for a in sistema.analistas}
# Mapear IDs a nombres
df_cronograma['ANALISTA_NOMBRE'] = df_cronograma['ANALISTA_ASIGNADO'].map(analista_id_to_nombre)
# Reordenar columnas para que ANALISTA_NOMBRE esté junto a ANALISTA_ASIGNADO
cols = df_cronograma.columns.tolist()
if 'ANALISTA_NOMBRE' in cols:
idx_analista = cols.index('ANALISTA_ASIGNADO')
cols.remove('ANALISTA_NOMBRE')
cols.insert(idx_analista + 1, 'ANALISTA_NOMBRE')
df_cronograma = df_cronograma[cols]
print(f"\n📊 Cronograma generado: {len(df_cronograma)} tareas")
if not df_cronograma.empty:
makespan = (df_cronograma['FECHA_FIN'].max() - df_cronograma['FECHA_INICIO'].min()).total_seconds() / 3600
print(f" • Makespan: {makespan:.2f} horas")
return df_cronograma
def exportar_cronograma_excel(df_cronograma: pd.DataFrame, ruta_salida: str):
"""Exporta el cronograma a un archivo Excel"""
with pd.ExcelWriter(ruta_salida, engine='openpyxl') as writer:
df_cronograma.to_excel(writer, sheet_name='CRONOGRAMA', index=False)
print(f"📁 Cronograma exportado a: {ruta_salida}")
print("✅ Validación y Scheduler Builder implementados")
# Cell 6:
# ============================================================================
# PARTE 4: EVALUADOR DE FITNESS Y REPRESENTACIÓN CROMOSÓMICA
# ============================================================================
# ============================================================================
# MÉTRICAS Y EVALUADOR DE FITNESS
# ============================================================================
@dataclass
class MetricasEsquema:
"""Métricas específicas de un esquema"""
esquema_id: str
orden_id: str
fecha_inicio: datetime
fecha_fin: datetime
fecha_compromiso: Optional[datetime]
tat_prometido: Optional[timedelta]
tat_real: timedelta
tardanza_horas: float
cumple_tat: bool
cantidad_muestras: int
total_pasos: int
duracion_total_horas: float
@dataclass
class MetricasOrden:
"""Métricas específicas de una orden"""
orden_id: str
fecha_recepcion: datetime
fecha_inicio: datetime
fecha_fin: datetime
fecha_compromiso: Optional[datetime]
tat_prometido: timedelta
tat_real: timedelta
tardanza_horas: float
cumple_tat: bool
total_esquemas: int
esquemas_cumplidos: int
metricas_esquemas: List[MetricasEsquema]
@dataclass
class MetricasGlobales:
"""Métricas globales del cronograma"""
makespan_horas: float
tardanza_total_horas: float
tardanza_promedio_horas: float
tardanza_maxima_horas: float
total_ordenes: int
ordenes_cumplidas: int
tasa_cumplimiento_ordenes: float
total_esquemas: int
esquemas_cumplidos: int
tasa_cumplimiento_esquemas: float
total_tareas: int
tareas_no_programadas: int # ✅ NUEVO: Tareas que no se pudieron programar
analistas_utilizados: int
equipos_utilizados: int
utilizacion_analistas: float
utilizacion_equipos: float
violaciones_dependencias: int
violaciones_secuencia: int
violaciones_capacidad: int
tardanza_total_esquemas_horas: float # ✅ NUEVO: Suma de tardanzas de esquemas
tardanza_total_ordenes_horas: float # ✅ NUEVO: Suma de tardanzas de ordenes
tat_real_total_esquemas_horas: float # ✅ NUEVO: Suma de TAT real de esquemas
tat_real_total_ordenes_horas: float # ✅ NUEVO: Suma de TAT real de ordenes
fitness: float
@dataclass
class ResultadoSimulacion:
"""Resultado completo de una simulación"""
cronograma: pd.DataFrame
metricas_ordenes: List[MetricasOrden]
metricas_globales: MetricasGlobales
es_valido: bool
errores: List[str]
warnings: List[str]
tiempo_simulacion_segundos: float
def calcular_metricas_esquema(esquema: Esquema, orden: Orden, df_cronograma: pd.DataFrame) -> MetricasEsquema:
"""Calcula métricas específicas de un esquema"""
# ✅ FIX: Usar esquema_id_original para buscar en el cronograma
esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.esquema_tipo or esquema.id)
tareas_esquema = df_cronograma[
(df_cronograma['ORDEN'] == orden.id) &
(df_cronograma['ESQUEMA'] == esquema_id_original) # ✅ CORREGIDO
]
if tareas_esquema.empty:
return None
fecha_fin = tareas_esquema['FECHA_FIN'].max()
fecha_recepcion_esquema = esquema.fecha_recepcion if esquema.fecha_recepcion else tareas_esquema['FECHA_INICIO'].min()
tat_real = fecha_fin - fecha_recepcion_esquema
if esquema.tat_especifico:
tat_prometido = esquema.tat_especifico
fecha_compromiso = fecha_recepcion_esquema + tat_prometido
else:
tat_prometido = orden.tat_prometido
fecha_compromiso = fecha_recepcion_esquema + tat_prometido if tat_prometido else orden.fecha_compromiso
if fecha_compromiso:
tardanza = max(0, (fecha_fin - fecha_compromiso).total_seconds() / 3600)
else:
tardanza = 0
cumple_tat = tardanza == 0
return MetricasEsquema(
esquema_id=esquema.id,
orden_id=orden.id,
fecha_inicio=fecha_recepcion_esquema,
fecha_fin=fecha_fin,
fecha_compromiso=fecha_compromiso,
tat_prometido=tat_prometido,
tat_real=tat_real,
tardanza_horas=tardanza,
cumple_tat=cumple_tat,
cantidad_muestras=esquema.cantidad_muestras,
total_pasos=len(esquema.pasos),
duracion_total_horas=(fecha_fin - fecha_recepcion_esquema).total_seconds() / 3600
)
def calcular_metricas_orden(orden: Orden, df_cronograma: pd.DataFrame) -> MetricasOrden:
"""Calcula métricas específicas de una orden"""
tareas_orden = df_cronograma[df_cronograma['ORDEN'] == orden.id]
if tareas_orden.empty:
return None
fecha_inicio = tareas_orden['FECHA_INICIO'].min()
fecha_fin = tareas_orden['FECHA_FIN'].max()
tat_real = fecha_fin - orden.fecha_recepcion
if orden.fecha_compromiso:
tardanza = max(0, (fecha_fin - orden.fecha_compromiso).total_seconds() / 3600)
else:
tardanza = 0
cumple_tat = tardanza == 0
metricas_esquemas = []
esquemas_cumplidos = 0
for esquema in orden.esquemas:
metrica_esq = calcular_metricas_esquema(esquema, orden, df_cronograma)
if metrica_esq:
metricas_esquemas.append(metrica_esq)
if metrica_esq.cumple_tat:
esquemas_cumplidos += 1
return MetricasOrden(
orden_id=orden.id,
fecha_recepcion=orden.fecha_recepcion,
fecha_inicio=fecha_inicio,
fecha_fin=fecha_fin,
fecha_compromiso=orden.fecha_compromiso,
tat_prometido=orden.tat_prometido,
tat_real=tat_real,
tardanza_horas=tardanza,
cumple_tat=cumple_tat,
total_esquemas=len(orden.esquemas),
esquemas_cumplidos=esquemas_cumplidos,
metricas_esquemas=metricas_esquemas
)
def detectar_violaciones(df_cronograma: pd.DataFrame, sistema: SistemaPlanificacion) -> Tuple[int, int, int]:
"""Detecta violaciones de restricciones (dependencias, secuencia y capacidad/capacitación)"""
violaciones_dep = 0
violaciones_seq = 0
violaciones_cap = 0 # aquí contaremos analista no capacitado
# índice rápido por paso+muestra
tareas_dict = {}
for _, tarea in df_cronograma.iterrows():
clave = f"{tarea['PASO']}_M{tarea['MUESTRA']}"
tareas_dict[clave] = tarea
# mapa de habilidades por analista
skills_por_analista = {
str(a.id): {str(h).strip().upper() for h in (a.habilidades or [])}
for a in sistema.analistas
}
for orden in sistema.ordenes:
for esquema in orden.esquemas:
# ✅ FIX: Usar esquema_id_original en lugar de esquema.id
esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.esquema_tipo or esquema.id)
esquema_id_up = str(esquema_id_original).strip().upper()
for muestra in range(1, esquema.cantidad_muestras + 1):
for paso in esquema.pasos:
clave_actual = f"{paso.id}_M{muestra}"
if clave_actual not in tareas_dict:
continue
tarea_actual = tareas_dict[clave_actual]
# dependencias
for pred_id in paso.pasos_predecesores:
clave_pred = f"{pred_id}_M{muestra}"
if clave_pred in tareas_dict:
tarea_pred = tareas_dict[clave_pred]
if tarea_pred['FECHA_FIN'] > tarea_actual['FECHA_INICIO']:
violaciones_dep += 1
# secuencia
if paso.secuencia > 1:
for otro_paso in esquema.pasos:
if otro_paso.secuencia == paso.secuencia - 1:
clave_ant = f"{otro_paso.id}_M{muestra}"
if clave_ant in tareas_dict:
tarea_ant = tareas_dict[clave_ant]
if tarea_ant['FECHA_FIN'] > tarea_actual['FECHA_INICIO']:
violaciones_seq += 1
break
# capacidad/capacitación del analista
analista_id = str(tarea_actual.get('ANALISTA_ASIGNADO') or '').strip()
if analista_id:
skills = skills_por_analista.get(analista_id, set())
if esquema_id_up not in skills:
violaciones_cap += 1
return violaciones_dep, violaciones_seq, violaciones_cap
def calcular_fitness(metricas: MetricasGlobales, pesos: Optional[Dict[str, float]] = None) -> float:
"""
Calcula el fitness global (menor es mejor)
PRIORIDADES (de mayor a menor importancia):
1. PRIMARY: 100% tareas programadas, cumplir TAT de esquemas y órdenes
2. SECONDARY: Minimizar tardanza total de esquemas y órdenes
3. TERTIARY: Minimizar TAT real total de esquemas y órdenes
4. OTHER: Minimizar makespan, maximizar utilización de recursos
"""
if pesos is None:
pesos = {
# ✅ PRIMARY: Cumplir TAT prometido
'incumplimiento_esquemas': 10000.0, # Muy importante: Cumplir TAT de esquemas
'incumplimiento_ordenes': 10000.0, # Muy importante: Cumplir TAT de órdenes
# ✅ SECONDARY: Minimizar tardanzas (penalización media-alta)
'tardanza_total_esquemas': 500.0, # Minimizar delay en esquemas
'tardanza_total_ordenes': 500.0, # Minimizar delay en órdenes
# ✅ TERTIARY: Minimizar TAT real (penalización media)
'tat_real_total_esquemas': 10.0, # Completar esquemas más rápido
'tat_real_total_ordenes': 10.0, # Completar órdenes más rápido
# ✅ OTHER: Objetivos secundarios (penalización baja)
'makespan': 1.0, # Minimizar tiempo total
'tardanza_maxima': 5.0, # Evitar picos de tardanza
}
# ⚠️ NOTA: Las siguientes restricciones están BLINDADAS en el código y no requieren penalización:
# - 'tareas_no_programadas': Todas las tareas se programan (línea 1802)
# - 'violaciones_dependencias': Secuencias respetadas (línea 1104, 1813-1869)
# - 'violaciones_secuencia': Orden interno preservado (línea 1847, 1869)
# - 'violaciones_capacidad': Solo analistas capacitados (línea 1502, 1530-1532)
fitness = 0.0
# ========================================================================
# PRIMARY: Cumplir TAT prometido
# ========================================================================
ordenes_incumplidas = metricas.total_ordenes - metricas.ordenes_cumplidas
esquemas_incumplidos = metricas.total_esquemas - metricas.esquemas_cumplidos
fitness += pesos['incumplimiento_ordenes'] * ordenes_incumplidas
fitness += pesos['incumplimiento_esquemas'] * esquemas_incumplidos
# ========================================================================
# SECONDARY: Minimizar tardanzas
# ========================================================================
fitness += pesos['tardanza_total_esquemas'] * metricas.tardanza_total_esquemas_horas
fitness += pesos['tardanza_total_ordenes'] * metricas.tardanza_total_ordenes_horas
# ========================================================================
# TERTIARY: Minimizar TAT real
# ========================================================================
fitness += pesos['tat_real_total_esquemas'] * metricas.tat_real_total_esquemas_horas
fitness += pesos['tat_real_total_ordenes'] * metricas.tat_real_total_ordenes_horas
# ========================================================================
# OTHER: Objetivos secundarios
# ========================================================================
fitness += pesos['makespan'] * metricas.makespan_horas
fitness += pesos['tardanza_maxima'] * metricas.tardanza_maxima_horas
return fitness
def validar_cromosoma(cromosoma: List[str], sistema: SistemaPlanificacion) -> Tuple[bool, List[str]]:
"""Valida que un cromosoma sea estructuralmente correcto"""
errores = []
tareas_esperadas = set()
for orden in sistema.ordenes:
for esquema in orden.esquemas:
# ✅ FIX: Usar esquema_id_original para coincidir con crear_gen_desde_paso
esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.esquema_tipo or esquema.id)
for muestra in range(1, esquema.cantidad_muestras + 1):
for paso in esquema.pasos:
tarea_id = f"{orden.id}_{esquema_id_original}_{paso.id}_M{muestra}"
tareas_esperadas.add(tarea_id)
tareas_cromosoma = set(cromosoma)
faltantes = tareas_esperadas - tareas_cromosoma
if faltantes:
errores.append(f"Cromosoma incompleto: faltan {len(faltantes)} tareas")
print(f"[DEBUG VALIDACION] Faltan {len(faltantes)} tareas:")
for tarea in list(faltantes)[:5]: # Mostrar las primeras 5
print(f" - {tarea}")
extras = tareas_cromosoma - tareas_esperadas
if extras:
errores.append(f"Cromosoma con tareas inválidas: {len(extras)} tareas")
print(f"[DEBUG VALIDACION] Sobran {len(extras)} tareas:")
for tarea in list(extras)[:5]: # Mostrar las primeras 5
print(f" - {tarea}")
contador = Counter(cromosoma)
duplicados = [tarea for tarea, count in contador.items() if count > 1]
if duplicados:
errores.append(f"Cromosoma con duplicados: {len(duplicados)} tareas")
return len(errores) == 0, errores
def simular_cromosoma(
cromosoma: List[str],
sistema: SistemaPlanificacion,
previous_plan: Optional[pd.DataFrame] = None,
validar_cromosoma_flag: bool = True,
pesos_fitness: Optional[Dict[str, float]] = None,
semilla: Optional[int] = None,
batch_manual_wait: float = 60.0,
verbose: bool = False,
# ✅ NUEVOS PARÁMETROS PARA HORIZONTE Y REINTENTOS
horizonte_inicial_dias: int = 9,
horizonte_maximo_dias: int = 15,
horizonte_dinamico: bool = True,
reintentos_adaptativos: bool = True,
max_reintentos_base: int = 500
) -> ResultadoSimulacion:
"""Simula completamente un cromosoma"""
import time
tiempo_inicio = time.time()
errores = []
warnings = []
if verbose:
print(f"\n🧬 SIMULANDO CROMOSOMA: {len(cromosoma)} tareas")
if validar_cromosoma_flag:
es_valido, errores_validacion = validar_cromosoma(cromosoma, sistema)
if not es_valido:
metricas_globales = MetricasGlobales(
makespan_horas=999999, tardanza_total_horas=999999, tardanza_promedio_horas=999999,
tardanza_maxima_horas=999999, total_ordenes=len(sistema.ordenes), ordenes_cumplidas=0,
tasa_cumplimiento_ordenes=0.0, total_esquemas=0, esquemas_cumplidos=0,
tasa_cumplimiento_esquemas=0.0, total_tareas=0, tareas_no_programadas=9999,
analistas_utilizados=0, equipos_utilizados=0,
utilizacion_analistas=0.0, utilizacion_equipos=0.0, violaciones_dependencias=9999,
violaciones_secuencia=9999, violaciones_capacidad=9999,
tardanza_total_esquemas_horas=999999, tardanza_total_ordenes_horas=999999,
tat_real_total_esquemas_horas=999999, tat_real_total_ordenes_horas=999999,
fitness=999999999
)
return ResultadoSimulacion(
cronograma=pd.DataFrame(), metricas_ordenes=[], metricas_globales=metricas_globales,
es_valido=False, errores=errores_validacion, warnings=[],
tiempo_simulacion_segundos=time.time() - tiempo_inicio
)
try:
df_cronograma = scheduler_builder(
sistema=sistema, cromosoma=cromosoma, previous_plan=previous_plan,
batch_manual_wait=batch_manual_wait, semilla=semilla,
fecha_inicio=sistema.fecha_inicio_planificacion,
verbose=verbose,
# ✅ PASAR NUEVOS PARÁMETROS
horizonte_inicial_dias=horizonte_inicial_dias,
horizonte_maximo_dias=horizonte_maximo_dias,
horizonte_dinamico=horizonte_dinamico,
reintentos_adaptativos=reintentos_adaptativos,
max_reintentos_base=max_reintentos_base
)
except Exception as e:
errores.append(f"Error en scheduler_builder: {str(e)}")
metricas_globales = MetricasGlobales(
makespan_horas=999999, tardanza_total_horas=999999, tardanza_promedio_horas=999999,
tardanza_maxima_horas=999999, total_ordenes=len(sistema.ordenes), ordenes_cumplidas=0,
tasa_cumplimiento_ordenes=0.0, total_esquemas=0, esquemas_cumplidos=0,
tasa_cumplimiento_esquemas=0.0, total_tareas=0, tareas_no_programadas=9999,
analistas_utilizados=0, equipos_utilizados=0,
utilizacion_analistas=0.0, utilizacion_equipos=0.0, violaciones_dependencias=0,
violaciones_secuencia=0, violaciones_capacidad=0,
tardanza_total_esquemas_horas=999999, tardanza_total_ordenes_horas=999999,
tat_real_total_esquemas_horas=999999, tat_real_total_ordenes_horas=999999,
fitness=999999999
)
return ResultadoSimulacion(
cronograma=pd.DataFrame(), metricas_ordenes=[], metricas_globales=metricas_globales,
es_valido=False, errores=errores, warnings=[], tiempo_simulacion_segundos=time.time() - tiempo_inicio
)
if df_cronograma.empty:
errores.append("Cronograma vacío")
metricas_globales = MetricasGlobales(
makespan_horas=999999, tardanza_total_horas=999999, tardanza_promedio_horas=999999,
tardanza_maxima_horas=999999, total_ordenes=len(sistema.ordenes), ordenes_cumplidas=0,
tasa_cumplimiento_ordenes=0.0, total_esquemas=0, esquemas_cumplidos=0,
tasa_cumplimiento_esquemas=0.0, total_tareas=0, tareas_no_programadas=9999,
analistas_utilizados=0, equipos_utilizados=0,
utilizacion_analistas=0.0, utilizacion_equipos=0.0, violaciones_dependencias=0,
violaciones_secuencia=0, violaciones_capacidad=0,
tardanza_total_esquemas_horas=999999, tardanza_total_ordenes_horas=999999,
tat_real_total_esquemas_horas=999999, tat_real_total_ordenes_horas=999999,
fitness=999999999
)
return ResultadoSimulacion(
cronograma=df_cronograma, metricas_ordenes=[], metricas_globales=metricas_globales,
es_valido=False, errores=errores, warnings=[], tiempo_simulacion_segundos=time.time() - tiempo_inicio
)
viol_dep, viol_seq, viol_cap = detectar_violaciones(df_cronograma, sistema)
metricas_ordenes = []
for orden in sistema.ordenes:
metrica_orden = calcular_metricas_orden(orden, df_cronograma)
if metrica_orden:
metricas_ordenes.append(metrica_orden)
inicio_global = df_cronograma['FECHA_INICIO'].min()
fin_global = df_cronograma['FECHA_FIN'].max()
makespan_horas = (fin_global - inicio_global).total_seconds() / 3600
tardanzas = [m.tardanza_horas for m in metricas_ordenes]
tardanza_total = sum(tardanzas)
tardanza_promedio = tardanza_total / len(tardanzas) if tardanzas else 0
tardanza_maxima = max(tardanzas) if tardanzas else 0
ordenes_cumplidas = sum(1 for m in metricas_ordenes if m.cumple_tat)
total_esquemas = sum(m.total_esquemas for m in metricas_ordenes)
esquemas_cumplidos = sum(m.esquemas_cumplidos for m in metricas_ordenes)
tasa_cumplimiento_ordenes = ordenes_cumplidas / len(metricas_ordenes) if metricas_ordenes else 0
tasa_cumplimiento_esquemas = esquemas_cumplidos / total_esquemas if total_esquemas > 0 else 0
analistas_utilizados = df_cronograma['ANALISTA_ASIGNADO'].nunique()
equipos_utilizados = df_cronograma['EQUIPO_ASIGNADO'].nunique()
tiempo_trabajo_analistas = df_cronograma[df_cronograma['ANALISTA_ASIGNADO'].notna()]['DURACION_REAL'].sum()
tiempo_trabajo_equipos = df_cronograma[df_cronograma['EQUIPO_ASIGNADO'].notna()]['DURACION_REAL'].sum()
tiempo_total_disponible = makespan_horas * 60
utilizacion_analistas = (tiempo_trabajo_analistas / (tiempo_total_disponible * len(sistema.analistas))) if sistema.analistas else 0
utilizacion_equipos = (tiempo_trabajo_equipos / (tiempo_total_disponible * len(sistema.equipos))) if sistema.equipos else 0
# ✅ CALCULAR TAREAS NO PROGRAMADAS
tareas_esperadas = 0
for orden in sistema.ordenes:
for esquema in orden.esquemas:
for _ in range(1, esquema.cantidad_muestras + 1):
tareas_esperadas += len(esquema.pasos)
tareas_no_programadas = max(0, tareas_esperadas - len(df_cronograma))
# ✅ CALCULAR MÉTRICAS DE TAT ADICIONALES
tardanza_total_esquemas = 0.0
tat_real_total_esquemas = 0.0
for metrica_orden in metricas_ordenes:
for metrica_esquema in metrica_orden.metricas_esquemas:
tardanza_total_esquemas += metrica_esquema.tardanza_horas
tat_real_total_esquemas += metrica_esquema.tat_real.total_seconds() / 3600
tardanza_total_ordenes = tardanza_total # Ya calculado arriba
tat_real_total_ordenes = sum((m.tat_real.total_seconds() / 3600) for m in metricas_ordenes)
metricas_globales = MetricasGlobales(
makespan_horas=makespan_horas,
tardanza_total_horas=tardanza_total,
tardanza_promedio_horas=tardanza_promedio,
tardanza_maxima_horas=tardanza_maxima,
total_ordenes=len(metricas_ordenes),
ordenes_cumplidas=ordenes_cumplidas,
tasa_cumplimiento_ordenes=tasa_cumplimiento_ordenes,
total_esquemas=total_esquemas,
esquemas_cumplidos=esquemas_cumplidos,
tasa_cumplimiento_esquemas=tasa_cumplimiento_esquemas,
total_tareas=len(df_cronograma),
tareas_no_programadas=tareas_no_programadas,
analistas_utilizados=analistas_utilizados,
equipos_utilizados=equipos_utilizados,
utilizacion_analistas=min(1.0, utilizacion_analistas),
utilizacion_equipos=min(1.0, utilizacion_equipos),
violaciones_dependencias=viol_dep,
violaciones_secuencia=viol_seq,
violaciones_capacidad=viol_cap,
tardanza_total_esquemas_horas=tardanza_total_esquemas,
tardanza_total_ordenes_horas=tardanza_total_ordenes,
tat_real_total_esquemas_horas=tat_real_total_esquemas,
tat_real_total_ordenes_horas=tat_real_total_ordenes,
fitness=0.0
)
fitness = calcular_fitness(metricas_globales, pesos_fitness)
metricas_globales.fitness = fitness
if verbose:
print(f"✅ Fitness: {fitness:.2f}, Makespan: {makespan_horas:.2f}h")
es_valido = (viol_dep == 0 and viol_seq == 0)
tiempo_total = time.time() - tiempo_inicio
return ResultadoSimulacion(
cronograma=df_cronograma,
metricas_ordenes=metricas_ordenes,
metricas_globales=metricas_globales,
es_valido=es_valido,
errores=errores,
warnings=warnings,
tiempo_simulacion_segundos=tiempo_total
)
print("✅ Evaluador de fitness implementado")
# Cell 7:
# ============================================================================
# PARTE 5: REPRESENTACIÓN CROMOSÓMICA Y OPERADORES GENÉTICOS
# ============================================================================
# ============================================================================
# REPRESENTACIÓN CROMOSÓMICA
# ============================================================================
class TipoGen(Enum):
"""Tipo de gen según su nivel jerárquico"""
ORDEN = "ORDEN"
ESQUEMA = "ESQUEMA"
PASO = "PASO"
@dataclass
class Gen:
"""Representa un gen individual en el cromosoma"""
orden_id: str
esquema_id: str
paso_id: str
muestra_id: str
secuencia_paso: int
nombre_paso: str
duracion_estimada: float
equipo_asignado: Optional[str] = None
analista_asignado: Optional[str] = None
prioridad_orden: int = 1
urgencia_esquema: int = 1
requiere_maquina: bool = False
requiere_analista: bool = True
paso_predecesores: List[str] = field(default_factory=list)
tipo_equipo: Optional[str] = None # ✅ CAMBIAR: Mantener como str (ya debería estar así)
modo_interaccion: int = 0
es_manual: bool = False
puede_batch: bool = False
bloqueado: bool = False
def get_id_completo(self) -> str:
return f"{self.orden_id}_{self.esquema_id}_{self.paso_id}_{self.muestra_id}"
def get_id_paso_muestra(self) -> str:
return f"{self.paso_id}_{self.muestra_id}"
def get_id_esquema_muestra(self) -> str:
# ✅ FIX: Incluir orden_id para que sea único entre órdenes que comparten esquemas
return f"{self.orden_id}_{self.esquema_id}_{self.muestra_id}"
def __eq__(self, other):
if not isinstance(other, Gen):
return False
return self.get_id_completo() == other.get_id_completo()
def __hash__(self):
return hash(self.get_id_completo())
def __repr__(self):
return f"Gen({self.get_id_completo()}, seq={self.secuencia_paso})"
@dataclass
class Cromosoma:
"""Representa un cromosoma completo (solución candidata)"""
genes: List[Gen] = field(default_factory=list)
_indice_por_esquema: Dict[str, List[int]] = field(default_factory=dict, init=False, repr=False)
_indice_por_orden: Dict[str, List[int]] = field(default_factory=dict, init=False, repr=False)
generacion: int = 0
fitness: Optional[float] = None
es_valido: Optional[bool] = None
fue_reparado: bool = False
violaciones: Dict[str, int] = field(default_factory=dict)
def __post_init__(self):
self._construir_indices()
def _construir_indices(self):
self._indice_por_esquema = defaultdict(list)
self._indice_por_orden = defaultdict(list)
for idx, gen in enumerate(self.genes):
esquema_muestra = gen.get_id_esquema_muestra()
self._indice_por_esquema[esquema_muestra].append(idx)
self._indice_por_orden[gen.orden_id].append(idx)
def agregar_gen(self, gen: Gen):
idx = len(self.genes)
self.genes.append(gen)
esquema_muestra = gen.get_id_esquema_muestra()
self._indice_por_esquema[esquema_muestra].append(idx)
self._indice_por_orden[gen.orden_id].append(idx)
def obtener_genes_esquema(self, esquema_id: str, muestra_id: str) -> List[Gen]:
esquema_muestra = f"{esquema_id}_{muestra_id}"
indices = self._indice_por_esquema.get(esquema_muestra, [])
return [self.genes[i] for i in indices]
def obtener_genes_orden(self, orden_id: str) -> List[Gen]:
indices = self._indice_por_orden.get(orden_id, [])
return [self.genes[i] for i in indices]
def validar_secuencias(self) -> Tuple[bool, List[str]]:
errores = []
for esquema_muestra, indices in self._indice_por_esquema.items():
secuencias_posiciones = []
for idx in indices:
gen = self.genes[idx]
secuencias_posiciones.append((gen.secuencia_paso, idx))
secuencias_posiciones.sort(key=lambda x: x[1])
secuencias = [s for s, _ in secuencias_posiciones]
secuencias_ordenadas = sorted(secuencias)
if secuencias != secuencias_ordenadas:
errores.append(f"Esquema {esquema_muestra}: secuencias fuera de orden")
return len(errores) == 0, errores
def reparar_secuencias(self) -> bool:
reparado = False
for esquema_muestra, indices in self._indice_por_esquema.items():
genes_con_idx = [(self.genes[i], i) for i in indices]
genes_con_idx.sort(key=lambda x: x[0].secuencia_paso)
indices_ordenados = [idx for _, idx in genes_con_idx]
if indices_ordenados != sorted(indices):
reparado = True
genes_ordenados = [gen for gen, _ in genes_con_idx]
for i, idx_original in enumerate(sorted(indices)):
self.genes[idx_original] = genes_ordenados[i]
if reparado:
self.fue_reparado = True
self._construir_indices()
return reparado
def to_sequence(self) -> List[str]:
return [gen.get_id_completo() for gen in self.genes]
def copy(self) -> 'Cromosoma':
nuevo_cromosoma = Cromosoma(
genes=[Gen(**{k: v for k, v in gen.__dict__.items()}) for gen in self.genes],
generacion=self.generacion,
fitness=self.fitness,
es_valido=self.es_valido,
fue_reparado=self.fue_reparado
)
nuevo_cromosoma.violaciones = self.violaciones.copy()
return nuevo_cromosoma
def __len__(self):
return len(self.genes)
def __repr__(self):
return f"Cromosoma(genes={len(self.genes)}, fitness={self.fitness})"
def crear_gen_desde_paso(orden: Orden, esquema: Esquema, paso: Paso, muestra_id: str) -> Gen:
"""Crea un Gen a partir de un Paso del sistema"""
urgencia_map = {
NivelUrgencia.BAJA: 1, NivelUrgencia.MEDIA: 2,
NivelUrgencia.ALTA: 3, NivelUrgencia.CRITICA: 4
}
urgencia = urgencia_map.get(esquema.urgencia, 2)
# ✅ CAMBIAR: No verificar con TipoEquipo.OTRO, sino comparar string
requiere_maquina = bool(paso.equipos_posibles) or paso.tipo_equipo.upper() != 'OTRO'
# ✅ FIX: Manejar modo_interaccion como int o string
if isinstance(paso.modo_interaccion, (int, float)):
modo_int = int(paso.modo_interaccion)
elif isinstance(paso.modo_interaccion, str) and paso.modo_interaccion.isdigit():
modo_int = int(paso.modo_interaccion)
else:
modo_int = 0
requiere_analista = modo_int > 0
es_manual = not requiere_maquina
# ✅ FIX: Usar esquema_id_original en lugar del ID compuesto
esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.esquema_tipo or esquema.id)
return Gen(
orden_id=orden.id,
esquema_id=esquema_id_original,
paso_id=paso.id,
muestra_id=muestra_id,
secuencia_paso=paso.secuencia,
nombre_paso=paso.nombre,
duracion_estimada=paso.duracion,
equipo_asignado=paso.equipos_posibles[0] if paso.equipos_posibles else None,
analista_asignado=paso.analista_preferido,
prioridad_orden=orden.prioridad_global,
urgencia_esquema=urgencia,
requiere_maquina=requiere_maquina,
requiere_analista=requiere_analista,
paso_predecesores=paso.pasos_predecesores.copy(),
tipo_equipo=paso.tipo_equipo, # ✅ Ya es string
modo_interaccion=modo_int,
es_manual=es_manual,
puede_batch=es_manual
)
def generar_cromosoma_desde_sistema(sistema: SistemaPlanificacion,
estrategia: str = 'FIFO',
semilla: Optional[int] = None) -> Cromosoma:
"""Genera un cromosoma inicial desde el sistema"""
rng = random.Random(semilla) if semilla is not None else random.Random()
cromosoma = Cromosoma()
todos_genes = []
for orden in sistema.ordenes:
for esquema in orden.esquemas:
for muestra in range(1, esquema.cantidad_muestras + 1):
muestra_id = f"M{muestra}"
for paso in sorted(esquema.pasos, key=lambda p: p.secuencia):
gen = crear_gen_desde_paso(orden, esquema, paso, muestra_id)
todos_genes.append(gen)
# print(f"[DEBUG] Genes totales generados antes de ordenar: {len(todos_genes)} (estrategia: {estrategia})")
if estrategia == 'FIFO':
ordenes_dict = {o.id: o for o in sistema.ordenes}
todos_genes.sort(key=lambda g: (
ordenes_dict[g.orden_id].fecha_recepcion,
g.esquema_id, g.muestra_id, g.secuencia_paso
))
elif estrategia == 'PRIORIDAD':
todos_genes.sort(key=lambda g: (
-g.prioridad_orden, g.urgencia_esquema,
g.esquema_id, g.muestra_id, g.secuencia_paso
))
elif estrategia == 'URGENCIA':
todos_genes.sort(key=lambda g: (
-g.urgencia_esquema, -g.prioridad_orden,
g.esquema_id, g.muestra_id, g.secuencia_paso
))
elif estrategia == 'SPT':
todos_genes.sort(key=lambda g: (
g.duracion_estimada, g.esquema_id, g.muestra_id, g.secuencia_paso
))
elif estrategia == 'RANDOM':
grupos = defaultdict(list)
for gen in todos_genes:
grupos[gen.get_id_esquema_muestra()].append(gen)
claves = list(grupos.keys())
rng.shuffle(claves)
todos_genes = []
for clave in claves:
genes_grupo = sorted(grupos[clave], key=lambda g: g.secuencia_paso)
todos_genes.extend(genes_grupo)
# print(f"[DEBUG] Genes después de ordenar: {len(todos_genes)}")
for gen in todos_genes:
cromosoma.agregar_gen(gen)
# print(f"[DEBUG] Cromosoma generado con {len(cromosoma)} genes (estrategia: {estrategia})")
es_valido, errores = cromosoma.validar_secuencias()
if not es_valido:
cromosoma.reparar_secuencias()
# print(f"[DEBUG] Cromosoma después de reparar: {len(cromosoma)} genes")
return cromosoma
def generar_poblacion_inicial(sistema: SistemaPlanificacion,
tamanio_poblacion: int,
estrategias: Optional[List[str]] = None,
semilla: Optional[int] = None) -> List[Cromosoma]:
"""Genera una población inicial de cromosomas"""
rng = random.Random(semilla) if semilla is not None else random.Random()
if estrategias is None:
estrategias = ['FIFO', 'PRIORIDAD', 'URGENCIA', 'SPT', 'RANDOM']
poblacion = []
for i in range(tamanio_poblacion):
estrategia = estrategias[i % len(estrategias)]
semilla_individual = random.randint(0, 999999) if semilla else None
cromosoma = generar_cromosoma_desde_sistema(sistema, estrategia=estrategia, semilla=semilla_individual)
cromosoma.generacion = 0
poblacion.append(cromosoma)
return poblacion
def comparar_cromosomas(c1: Cromosoma, c2: Cromosoma) -> float:
"""Calcula similitud entre cromosomas (0=diferentes, 1=idénticos)"""
if len(c1) != len(c2):
return 0.0
seq1 = c1.to_sequence()
seq2 = c2.to_sequence()
coincidencias = sum(1 for i in range(len(seq1)) if seq1[i] == seq2[i])
return coincidencias / len(seq1)
# ============================================================================
# OPERADORES GENÉTICOS
# ============================================================================
def seleccion_torneo(poblacion: List[Cromosoma], tamanio_torneo: int = 3,
semilla: Optional[int] = None) -> Cromosoma:
"""Selección por torneo"""
rng = random.Random(semilla) if semilla is not None else random.Random()
competidores = random.sample(poblacion, min(tamanio_torneo, len(poblacion)))
ganador = min(competidores, key=lambda c: c.fitness if c.fitness is not None else float('inf'))
return ganador
def seleccion_ruleta(poblacion: List[Cromosoma], semilla: Optional[int] = None) -> Cromosoma:
"""Selección por ruleta"""
rng = random.Random(semilla) if semilla is not None else random.Random()
fitness_values = [c.fitness if c.fitness is not None else float('inf') for c in poblacion]
max_fitness = max(fitness_values)
fitness_invertidos = [max_fitness - f + 1 for f in fitness_values]
total_fitness = sum(fitness_invertidos)
if total_fitness == 0:
return random.choice(poblacion)
probabilidades = [f / total_fitness for f in fitness_invertidos]
r = random.random()
acumulado = 0
for i, prob in enumerate(probabilidades):
acumulado += prob
if r <= acumulado:
return poblacion[i]
return poblacion[-1]
def cruce_orden_parcial(padre1: Cromosoma, padre2: Cromosoma,
semilla: Optional[int] = None) -> Tuple[Cromosoma, Cromosoma]:
"""Cruce de orden parcial (PMX) - Preserva todos los genes"""
# Si los padres tienen diferente longitud, retornar copias (no cruzar)
if len(padre1.genes) != len(padre2.genes):
logging.warning(f"Cruce omitido: cromosomas con longitudes diferentes ({len(padre1.genes)} vs {len(padre2.genes)})")
return padre1.copy(), padre2.copy()
rng = random.Random(semilla) if semilla is not None else random.Random()
n = len(padre1.genes)
# Casos borde
if n == 0:
return Cromosoma(), Cromosoma()
if n == 1:
return padre1.copy(), padre2.copy()
# Seleccionar puntos de cruce
punto1 = random.randint(0, n - 1)
punto2 = random.randint(punto1 + 1, n + 1) # +1 para permitir hasta n
# Crear mapeos de genes por ID
genes_p1_dict = {gen.get_id_completo(): gen for gen in padre1.genes}
genes_p2_dict = {gen.get_id_completo(): gen for gen in padre2.genes}
# Verificar que ambos padres tienen los mismos genes
ids_p1 = set(genes_p1_dict.keys())
ids_p2 = set(genes_p2_dict.keys())
if ids_p1 != ids_p2:
# Los padres no tienen los mismos genes, retornar copias
logging.warning(f"Cruce omitido: padres con genes diferentes")
return padre1.copy(), padre2.copy()
# Inicializar hijos
genes_hijo1 = [None] * n
genes_hijo2 = [None] * n
# Copiar segmentos centrales
genes_hijo1[punto1:punto2] = padre1.genes[punto1:punto2]
genes_hijo2[punto1:punto2] = padre2.genes[punto1:punto2]
# IDs ya incluidos en los segmentos
ids_en_hijo1 = {gen.get_id_completo() for gen in padre1.genes[punto1:punto2]}
ids_en_hijo2 = {gen.get_id_completo() for gen in padre2.genes[punto1:punto2]}
# Llenar hijo1 con genes de padre2 en el orden de padre2
idx_hijo1 = 0
for gen in padre2.genes:
gen_id = gen.get_id_completo()
if gen_id not in ids_en_hijo1:
# Buscar próxima posición None
while idx_hijo1 < n and genes_hijo1[idx_hijo1] is not None:
idx_hijo1 += 1
if idx_hijo1 < n:
genes_hijo1[idx_hijo1] = gen
ids_en_hijo1.add(gen_id)
# Llenar hijo2 con genes de padre1 en el orden de padre1
idx_hijo2 = 0
for gen in padre1.genes:
gen_id = gen.get_id_completo()
if gen_id not in ids_en_hijo2:
# Buscar próxima posición None
while idx_hijo2 < n and genes_hijo2[idx_hijo2] is not None:
idx_hijo2 += 1
if idx_hijo2 < n:
genes_hijo2[idx_hijo2] = gen
ids_en_hijo2.add(gen_id)
# Verificar que no hay None (todos los genes fueron colocados)
if None in genes_hijo1 or None in genes_hijo2:
logging.error(f"ERROR en cruce: hijo con genes None. Hijo1: {genes_hijo1.count(None)} None, Hijo2: {genes_hijo2.count(None)} None")
return padre1.copy(), padre2.copy()
# Construir cromosomas hijos
hijo1 = Cromosoma()
hijo2 = Cromosoma()
hijo1.genes = genes_hijo1
hijo2.genes = genes_hijo2
hijo1._construir_indices()
hijo2._construir_indices()
# Reparar secuencias si es necesario
hijo1.reparar_secuencias()
hijo2.reparar_secuencias()
return hijo1, hijo2
def cruce_uniforme_esquemas(padre1: Cromosoma, padre2: Cromosoma,
semilla: Optional[int] = None) -> Tuple[Cromosoma, Cromosoma]:
"""Cruce uniforme a nivel de esquemas completos - Preserva todos los genes"""
# Si los padres tienen diferente longitud, retornar copias (no cruzar)
if len(padre1.genes) != len(padre2.genes):
logging.warning(f"Cruce omitido: cromosomas con longitudes diferentes ({len(padre1.genes)} vs {len(padre2.genes)})")
return padre1.copy(), padre2.copy()
rng = random.Random(semilla) if semilla is not None else random.Random()
# Obtener las claves de esquema-muestra de ambos padres
esquemas_p1 = set(padre1._indice_por_esquema.keys())
esquemas_p2 = set(padre2._indice_por_esquema.keys())
# Verificar que ambos padres tienen los mismos esquemas
if esquemas_p1 != esquemas_p2:
logging.warning(f"Cruce omitido: padres con esquemas diferentes")
return padre1.copy(), padre2.copy()
hijo1 = Cromosoma()
hijo2 = Cromosoma()
# Para cada esquema-muestra, decidir aleatoriamente de qué padre heredar
for esquema_muestra_id in esquemas_p1:
if random.random() < 0.5:
# Hijo1 hereda de padre1, Hijo2 hereda de padre2
indices_p1 = padre1._indice_por_esquema[esquema_muestra_id]
genes_p1 = [padre1.genes[i] for i in indices_p1]
for gen in genes_p1:
hijo1.agregar_gen(gen)
indices_p2 = padre2._indice_por_esquema[esquema_muestra_id]
genes_p2 = [padre2.genes[i] for i in indices_p2]
for gen in genes_p2:
hijo2.agregar_gen(gen)
else:
# Hijo1 hereda de padre2, Hijo2 hereda de padre1
indices_p2 = padre2._indice_por_esquema[esquema_muestra_id]
genes_p2 = [padre2.genes[i] for i in indices_p2]
for gen in genes_p2:
hijo1.agregar_gen(gen)
indices_p1 = padre1._indice_por_esquema[esquema_muestra_id]
genes_p1 = [padre1.genes[i] for i in indices_p1]
for gen in genes_p1:
hijo2.agregar_gen(gen)
# Verificar que los hijos tienen la misma cantidad de genes que los padres
if len(hijo1.genes) != len(padre1.genes) or len(hijo2.genes) != len(padre2.genes):
logging.error(f"ERROR en cruce uniforme: hijos con longitud incorrecta. Padre: {len(padre1.genes)}, Hijo1: {len(hijo1.genes)}, Hijo2: {len(hijo2.genes)}")
return padre1.copy(), padre2.copy()
# Reparar secuencias si es necesario
hijo1.reparar_secuencias()
hijo2.reparar_secuencias()
return hijo1, hijo2
def mutacion_intercambio(cromosoma: Cromosoma, tasa_mutacion: float = 0.1,
semilla: Optional[int] = None) -> Cromosoma:
"""Mutación por intercambio de posiciones de dos esquemas-muestra completos"""
rng = random.Random(semilla) if semilla is not None else random.Random()
if random.random() > tasa_mutacion:
return cromosoma
mutado = cromosoma.copy()
esquemas = list(mutado._indice_por_esquema.keys())
if len(esquemas) < 2:
return mutado
# Seleccionar dos esquemas-muestra diferentes
esq1, esq2 = random.sample(esquemas, 2)
# Obtener los genes de cada esquema-muestra (ya ordenados por sus índices)
genes_esq1 = [mutado.genes[i] for i in sorted(mutado._indice_por_esquema[esq1])]
genes_esq2 = [mutado.genes[i] for i in sorted(mutado._indice_por_esquema[esq2])]
# Crear un diccionario de mapeo: gen_id -> nuevo_gen
mapeo = {}
for gen in genes_esq1:
mapeo[gen.get_id_completo()] = genes_esq2[genes_esq1.index(gen)] if genes_esq1.index(gen) < len(genes_esq2) else gen
for gen in genes_esq2:
mapeo[gen.get_id_completo()] = genes_esq1[genes_esq2.index(gen)] if genes_esq2.index(gen) < len(genes_esq1) else gen
# Aplicar el intercambio: intercambiar TODOS los genes de esq1 con TODOS los genes de esq2
nuevos_genes = []
for gen in mutado.genes:
gen_id = gen.get_id_completo()
if gen_id in [g.get_id_completo() for g in genes_esq1]:
# Este gen pertenece a esq1, reemplazar con el correspondiente de esq2
idx_en_esq1 = [g.get_id_completo() for g in genes_esq1].index(gen_id)
nuevos_genes.append(genes_esq2[idx_en_esq1] if idx_en_esq1 < len(genes_esq2) else gen)
elif gen_id in [g.get_id_completo() for g in genes_esq2]:
# Este gen pertenece a esq2, reemplazar con el correspondiente de esq1
idx_en_esq2 = [g.get_id_completo() for g in genes_esq2].index(gen_id)
nuevos_genes.append(genes_esq1[idx_en_esq2] if idx_en_esq2 < len(genes_esq1) else gen)
else:
# Este gen no pertenece a ninguno de los dos esquemas, mantener
nuevos_genes.append(gen)
mutado.genes = nuevos_genes
mutado._construir_indices()
return mutado
def mutacion_inversion(cromosoma: Cromosoma, tasa_mutacion: float = 0.1,
semilla: Optional[int] = None) -> Cromosoma:
"""Mutación por inversión de orden de esquemas-muestra"""
rng = random.Random(semilla) if semilla is not None else random.Random()
if random.random() > tasa_mutacion:
return cromosoma
mutado = cromosoma.copy()
esquemas = list(mutado._indice_por_esquema.keys())
if len(esquemas) < 2:
return mutado
# Seleccionar un rango de esquemas para invertir su orden
num_esquemas = random.randint(2, min(5, len(esquemas)))
inicio = random.randint(0, len(esquemas) - num_esquemas)
esquemas_a_invertir = esquemas[inicio:inicio + num_esquemas]
# Obtener todos los genes de los esquemas seleccionados
genes_por_esquema = {}
for esq_id in esquemas_a_invertir:
genes_por_esquema[esq_id] = [mutado.genes[i] for i in sorted(mutado._indice_por_esquema[esq_id])]
# Invertir el orden de los esquemas
esquemas_invertidos = list(reversed(esquemas_a_invertir))
# Crear mapeo de genes viejos a genes nuevos
mapeo = {}
for idx, esq_id in enumerate(esquemas_a_invertir):
esq_id_invertido = esquemas_invertidos[idx]
genes_origen = genes_por_esquema[esq_id]
genes_destino = genes_por_esquema[esq_id_invertido]
for gen in genes_origen:
# Mantener la secuencia, solo cambiar la posición del esquema
mapeo[gen.get_id_completo()] = gen # Por ahora, simplemente mantenemos los genes
# Reconstruir el cromosoma reemplazando solo los esquemas invertidos
# Estrategia: cambiar el orden de aparición de los esquemas completos
nuevos_genes = []
esquemas_procesados = set()
for gen in mutado.genes:
esq_id = gen.get_id_esquema_muestra()
if esq_id in esquemas_a_invertir and esq_id not in esquemas_procesados:
# Este es uno de los esquemas a invertir y aún no lo hemos procesado
# Agregar TODOS los genes del esquema correspondiente en el orden invertido
idx_en_original = esquemas_a_invertir.index(esq_id)
esq_id_a_usar = esquemas_invertidos[idx_en_original]
nuevos_genes.extend(genes_por_esquema[esq_id_a_usar])
esquemas_procesados.add(esq_id)
elif esq_id not in esquemas_a_invertir and esq_id not in esquemas_procesados:
# Este esquema no está en los a invertir, agregar todos sus genes
nuevos_genes.extend([mutado.genes[i] for i in sorted(mutado._indice_por_esquema[esq_id])])
esquemas_procesados.add(esq_id)
# Si ya procesamos este esquema, no hacemos nada (skip)
mutado.genes = nuevos_genes
mutado._construir_indices()
return mutado
def mutacion_prioridad(cromosoma: Cromosoma, tasa_mutacion: float = 0.1,
semilla: Optional[int] = None) -> Cromosoma:
"""Mutación basada en prioridad"""
rng = random.Random(semilla) if semilla is not None else random.Random()
if random.random() > tasa_mutacion:
return cromosoma
mutado = cromosoma.copy()
esquemas_genes = defaultdict(list)
for gen in mutado.genes:
esquemas_genes[gen.get_id_esquema_muestra()].append(gen)
esquemas_ordenados = sorted(
esquemas_genes.items(),
key=lambda x: (-x[1][0].urgencia_esquema, -x[1][0].prioridad_orden)
)
nuevos_genes = []
for _, genes in esquemas_ordenados:
nuevos_genes.extend(sorted(genes, key=lambda g: g.secuencia_paso))
mutado.genes = nuevos_genes
mutado._construir_indices()
return mutado
print("✅ Cromosomas y operadores genéticos implementados")
# Cell 8:
# ============================================================================
# PARTE 6: ALGORITMO GENÉTICO COMPLETO Y ANÁLISIS DE DESEMPEÑO
# ============================================================================
# ============================================================================
# ALGORITMO GENÉTICO PRINCIPAL
# ============================================================================
@dataclass
class ConfiguracionGA:
"""Configuración del algoritmo genético"""
tamanio_poblacion: int = 100
generaciones: int = 50
tasa_cruce: float = 0.8
tasa_mutacion: float = 0.2
tasa_elitismo: float = 0.1
tamanio_torneo: int = 3
metodo_seleccion: str = 'torneo'
metodo_cruce: str = 'orden_parcial'
metodos_mutacion: List[str] = field(default_factory=lambda: ['intercambio', 'inversion'])
max_generaciones_sin_mejora: int = 20
fitness_objetivo: Optional[float] = None
mantener_diversidad: bool = True
min_diversidad: float = 0.3
semilla: Optional[int] = None
verbose: bool = True
guardar_historial: bool = True
debug_interno: bool = False # Controla el debug dentro de cada generación (búsqueda de analistas, procesamiento de tareas, etc.)
@dataclass
class EstadisticasGeneracion:
"""Estadísticas de una generación"""
generacion: int
mejor_fitness: float
peor_fitness: float
fitness_promedio: float
fitness_mediana: float
diversidad: float
tiempo_segundos: float
mejor_cromosoma: Optional[Cromosoma] = None
@dataclass
class ResultadoGA:
"""Resultado final del algoritmo genético"""
mejor_cromosoma: Cromosoma
mejor_fitness: float
mejor_cronograma: pd.DataFrame
mejor_metricas: MetricasGlobales
historial: List[EstadisticasGeneracion]
poblacion_final: List[Cromosoma]
generaciones_ejecutadas: int
tiempo_total_segundos: float
convergio: bool
razon_parada: str
def calcular_diversidad_poblacion(poblacion: List[Cromosoma]) -> float:
"""Calcula la diversidad de la población (0=iguales, 1=diferentes)"""
if len(poblacion) < 2:
return 0.0
similitudes = []
n = len(poblacion)
num_comparaciones = min(100, n * (n - 1) // 2)
pares_comparados = 0
for i in range(n):
for j in range(i + 1, n):
if pares_comparados >= num_comparaciones:
break
similitudes.append(comparar_cromosomas(poblacion[i], poblacion[j]))
pares_comparados += 1
if pares_comparados >= num_comparaciones:
break
similitud_promedio = sum(similitudes) / len(similitudes) if similitudes else 0
return 1 - similitud_promedio
def algoritmo_genetico(
sistema: SistemaPlanificacion,
config: Optional[ConfiguracionGA] = None,
pesos_fitness: Optional[Dict[str, float]] = None,
previous_plan: Optional[pd.DataFrame] = None,
# ✅ NUEVOS PARÁMETROS PARA HORIZONTE Y REINTENTOS
horizonte_inicial_dias: int = 9,
horizonte_maximo_dias: int = 15,
horizonte_dinamico: bool = True,
reintentos_adaptativos: bool = True,
max_reintentos_base: int = 500
) -> ResultadoGA:
"""Ejecuta el algoritmo genético completo"""
import time
if config is None:
config = ConfiguracionGA()
if config.semilla is not None:
random.seed(config.semilla)
tiempo_inicio = time.time()
print("\n" + "="*80)
print("🧬 ALGORITMO GENÉTICO - OPTIMIZACIÓN DE PLANIFICACIÓN")
print("="*80)
print(f"\nConfiguración:")
print(f" • Población: {config.tamanio_poblacion}")
print(f" • Generaciones: {config.generaciones}")
print(f" • Cruce: {config.tasa_cruce}, Mutación: {config.tasa_mutacion}")
print(f" • Elitismo: {config.tasa_elitismo}")
print(f"\nConfiguración de Planificación:")
print(f" • Horizonte dinámico: {'SÍ' if horizonte_dinamico else 'NO'}")
print(f" • Horizonte inicial: {horizonte_inicial_dias} días")
print(f" • Horizonte máximo: {horizonte_maximo_dias} días")
print(f" • Reintentos adaptativos: {'SÍ' if reintentos_adaptativos else 'NO'}")
print(f" • Reintentos base: {max_reintentos_base} (máx: {max_reintentos_base*3 if reintentos_adaptativos else max_reintentos_base})")
# 1. GENERAR POBLACIÓN INICIAL
print(f"\n{'='*80}")
print("🌱 GENERACIÓN DE POBLACIÓN INICIAL")
print("="*80)
poblacion = generar_poblacion_inicial(sistema, config.tamanio_poblacion, semilla=config.semilla)
print(f"✅ Población inicial: {len(poblacion)} individuos")
# 2. EVALUAR POBLACIÓN INICIAL
print(f"\n{'='*80}")
print("📊 EVALUACIÓN DE POBLACIÓN INICIAL")
print("="*80)
for i, cromosoma in enumerate(poblacion):
if config.verbose and (i % 10 == 0):
print(f"Evaluando {i+1}/{len(poblacion)}...", end='\r')
resultado = simular_cromosoma(
cromosoma.to_sequence(), sistema,
previous_plan=previous_plan,
pesos_fitness=pesos_fitness,
semilla=config.semilla,
verbose=config.debug_interno, # Usar debug_interno para controlar el verbose interno
# ✅ PASAR PARÁMETROS DE HORIZONTE Y REINTENTOS
horizonte_inicial_dias=horizonte_inicial_dias,
horizonte_maximo_dias=horizonte_maximo_dias,
horizonte_dinamico=horizonte_dinamico,
reintentos_adaptativos=reintentos_adaptativos,
max_reintentos_base=max_reintentos_base
)
cromosoma.fitness = resultado.metricas_globales.fitness
cromosoma.es_valido = resultado.es_valido
cromosoma.violaciones = {
'dependencias': resultado.metricas_globales.violaciones_dependencias,
'secuencia': resultado.metricas_globales.violaciones_secuencia,
'capacidad': resultado.metricas_globales.violaciones_capacidad
}
print(f"\n✅ Población inicial evaluada")
# Variables de seguimiento
historial = []
mejor_global = min(poblacion, key=lambda c: c.fitness)
generaciones_sin_mejora = 0
# 3. EVOLUCIÓN
print(f"\n{'='*80}")
print("🔄 PROCESO EVOLUTIVO")
print("="*80 + "\n")
for gen in range(config.generaciones):
tiempo_gen_inicio = time.time()
poblacion.sort(key=lambda c: c.fitness)
fitness_values = [c.fitness for c in poblacion]
diversidad = calcular_diversidad_poblacion(poblacion)
stats = EstadisticasGeneracion(
generacion=gen,
mejor_fitness=fitness_values[0],
peor_fitness=fitness_values[-1],
fitness_promedio=sum(fitness_values) / len(fitness_values),
fitness_mediana=fitness_values[len(fitness_values) // 2],
diversidad=diversidad,
tiempo_segundos=0,
mejor_cromosoma=poblacion[0].copy()
)
if poblacion[0].fitness < mejor_global.fitness:
mejor_global = poblacion[0].copy()
generaciones_sin_mejora = 0
# ✅ OBTENER MÉTRICAS DETALLADAS DEL MEJOR CROMOSOMA
if config.verbose:
# Simular el mejor cromosoma para obtener métricas detalladas
resultado_mejor = simular_cromosoma(
poblacion[0].to_sequence(), sistema,
previous_plan=previous_plan,
pesos_fitness=pesos_fitness,
verbose=False
)
m = resultado_mejor.metricas_globales
print(f"\n{'─'*80}")
print(f"🌟 GENERACIÓN {gen}: NUEVA MEJOR SOLUCIÓN - Fitness: {mejor_global.fitness:.2f}")
print(f"{'─'*80}")
print(f"📊 DESGLOSE DE COMPONENTES DEL FITNESS:")
print(f" PRIMARY (Restricciones Duras):")
print(f" • Tareas no programadas: {m.tareas_no_programadas:5d} (peso: 100,000)")
print(f" • Esquemas incumplidos: {m.total_esquemas - m.esquemas_cumplidos:5d} (peso: 10,000)")
print(f" • Órdenes incumplidas: {m.total_ordenes - m.ordenes_cumplidas:5d} (peso: 10,000)")
print(f" • Violaciones dependencias: {m.violaciones_dependencias:5d} (peso: 5,000)")
print(f" • Violaciones secuencia: {m.violaciones_secuencia:5d} (peso: 5,000)")
print(f" • Violaciones capacidad: {m.violaciones_capacidad:5d} (peso: 3,000)")
print(f" SECONDARY (Minimizar Tardanzas):")
print(f" • Tardanza total esquemas: {m.tardanza_total_esquemas_horas:8.2f}h (peso: 500)")
print(f" • Tardanza total órdenes: {m.tardanza_total_ordenes_horas:8.2f}h (peso: 500)")
print(f" TERTIARY (Minimizar TAT Real):")
print(f" • TAT real total esquemas: {m.tat_real_total_esquemas_horas:8.2f}h (peso: 10)")
print(f" • TAT real total órdenes: {m.tat_real_total_ordenes_horas:8.2f}h (peso: 10)")
print(f" OTHER (Objetivos Secundarios):")
print(f" • Makespan: {m.makespan_horas:8.2f}h (peso: 1)")
print(f" • Tardanza máxima: {m.tardanza_maxima_horas:8.2f}h (peso: 5)")
print(f" MÉTRICAS ADICIONALES:")
print(f" • Tasa cumplimiento órdenes: {m.tasa_cumplimiento_ordenes*100:5.1f}%")
print(f" • Tasa cumplimiento esquemas: {m.tasa_cumplimiento_esquemas*100:5.1f}%")
print(f" • Tareas totales programadas: {m.total_tareas}")
print(f"{'─'*80}\n")
else:
generaciones_sin_mejora += 1
if config.verbose:
print(f"Gen {gen:3d} | Mejor: {stats.mejor_fitness:10.2f} | "
f"Promedio: {stats.fitness_promedio:10.2f} | "
f"Diversidad: {stats.diversidad:.3f}")
# Criterios de parada
if config.fitness_objetivo and mejor_global.fitness <= config.fitness_objetivo:
razon_parada = f"Fitness objetivo alcanzado: {mejor_global.fitness:.2f}"
stats.tiempo_segundos = time.time() - tiempo_gen_inicio
historial.append(stats)
break
if generaciones_sin_mejora >= config.max_generaciones_sin_mejora:
razon_parada = f"Sin mejora durante {config.max_generaciones_sin_mejora} generaciones"
stats.tiempo_segundos = time.time() - tiempo_gen_inicio
historial.append(stats)
break
# ELITISMO
num_elites = int(config.tamanio_poblacion * config.tasa_elitismo)
nueva_poblacion = poblacion[:num_elites]
# GENERAR NUEVA POBLACIÓN
while len(nueva_poblacion) < config.tamanio_poblacion:
# SELECCIÓN
if config.metodo_seleccion == 'torneo':
padre1 = seleccion_torneo(poblacion, config.tamanio_torneo)
padre2 = seleccion_torneo(poblacion, config.tamanio_torneo)
else:
padre1 = seleccion_ruleta(poblacion)
padre2 = seleccion_ruleta(poblacion)
# CRUCE
if random.random() < config.tasa_cruce:
if config.metodo_cruce == 'orden_parcial':
hijo1, hijo2 = cruce_orden_parcial(padre1, padre2)
else:
hijo1, hijo2 = cruce_uniforme_esquemas(padre1, padre2)
else:
hijo1 = padre1.copy()
hijo2 = padre2.copy()
# ✅ Verificar longitud después del cruce
if len(hijo1.genes) != len(padre1.genes) or len(hijo2.genes) != len(padre2.genes):
logging.error(f"[GEN {gen}] ERROR después de cruce: Padre: {len(padre1.genes)}, Hijo1: {len(hijo1.genes)}, Hijo2: {len(hijo2.genes)}")
hijo1 = padre1.copy()
hijo2 = padre2.copy()
# MUTACIÓN
for metodo in config.metodos_mutacion:
if metodo == 'intercambio':
hijo1_pre = len(hijo1.genes)
hijo1 = mutacion_intercambio(hijo1, config.tasa_mutacion)
if len(hijo1.genes) != hijo1_pre:
logging.error(f"[GEN {gen}] ERROR en mutacion_intercambio hijo1: {hijo1_pre} -> {len(hijo1.genes)}")
hijo2_pre = len(hijo2.genes)
hijo2 = mutacion_intercambio(hijo2, config.tasa_mutacion)
if len(hijo2.genes) != hijo2_pre:
logging.error(f"[GEN {gen}] ERROR en mutacion_intercambio hijo2: {hijo2_pre} -> {len(hijo2.genes)}")
elif metodo == 'inversion':
hijo1_pre = len(hijo1.genes)
hijo1 = mutacion_inversion(hijo1, config.tasa_mutacion)
if len(hijo1.genes) != hijo1_pre:
logging.error(f"[GEN {gen}] ERROR en mutacion_inversion hijo1: {hijo1_pre} -> {len(hijo1.genes)}")
hijo2_pre = len(hijo2.genes)
hijo2 = mutacion_inversion(hijo2, config.tasa_mutacion)
if len(hijo2.genes) != hijo2_pre:
logging.error(f"[GEN {gen}] ERROR en mutacion_inversion hijo2: {hijo2_pre} -> {len(hijo2.genes)}")
elif metodo == 'prioridad':
hijo1_pre = len(hijo1.genes)
hijo1 = mutacion_prioridad(hijo1, config.tasa_mutacion)
if len(hijo1.genes) != hijo1_pre:
logging.error(f"[GEN {gen}] ERROR en mutacion_prioridad hijo1: {hijo1_pre} -> {len(hijo1.genes)}")
hijo2_pre = len(hijo2.genes)
hijo2 = mutacion_prioridad(hijo2, config.tasa_mutacion)
if len(hijo2.genes) != hijo2_pre:
logging.error(f"[GEN {gen}] ERROR en mutacion_prioridad hijo2: {hijo2_pre} -> {len(hijo2.genes)}")
hijo1.reparar_secuencias()
hijo2.reparar_secuencias()
# Evaluar hijos
for hijo in [hijo1, hijo2]:
if len(nueva_poblacion) >= config.tamanio_poblacion:
break
resultado = simular_cromosoma(
hijo.to_sequence(), sistema,
previous_plan=previous_plan,
pesos_fitness=pesos_fitness,
verbose=config.debug_interno, # Usar debug_interno para controlar el verbose interno
# ✅ PASAR PARÁMETROS DE HORIZONTE Y REINTENTOS
horizonte_inicial_dias=horizonte_inicial_dias,
horizonte_maximo_dias=horizonte_maximo_dias,
horizonte_dinamico=horizonte_dinamico,
reintentos_adaptativos=reintentos_adaptativos,
max_reintentos_base=max_reintentos_base
)
hijo.fitness = resultado.metricas_globales.fitness
hijo.es_valido = resultado.es_valido
hijo.generacion = gen + 1
nueva_poblacion.append(hijo)
poblacion = nueva_poblacion
stats.tiempo_segundos = time.time() - tiempo_gen_inicio
if config.guardar_historial:
historial.append(stats)
else:
razon_parada = f"Número máximo de generaciones alcanzado ({config.generaciones})"
# 4. RESULTADO FINAL
tiempo_total = time.time() - tiempo_inicio
print(f"\n{'='*80}")
print("🏁 OPTIMIZACIÓN COMPLETADA")
print("="*80)
print(f"Razón: {razon_parada}")
print(f"Generaciones: {len(historial)}")
print(f"Tiempo: {tiempo_total:.2f}s")
print(f"\n🏆 MEJOR SOLUCIÓN:")
print(f" • Fitness: {mejor_global.fitness:.2f}")
print(f" • Generación: {mejor_global.generacion}")
print(f"\n📅 Generando cronograma final...")
# 🔄 RESETEAR DEBUG_MODE_4 para capturar solo la mejor solución
DEBUG_MODE_4['registrations'].clear()
DEBUG_MODE_4['overlaps'].clear()
DEBUG_MODE_4['other_mode_registrations'] = 0
resultado_final = simular_cromosoma(
mejor_global.to_sequence(), sistema,
previous_plan=previous_plan,
pesos_fitness=pesos_fitness,
verbose=config.debug_interno, # Mostrar debug solo si está activado
# ✅ PASAR PARÁMETROS DE HORIZONTE Y REINTENTOS
horizonte_inicial_dias=horizonte_inicial_dias,
horizonte_maximo_dias=horizonte_maximo_dias,
horizonte_dinamico=horizonte_dinamico,
reintentos_adaptativos=reintentos_adaptativos,
max_reintentos_base=max_reintentos_base
)
resultado_ga = ResultadoGA(
mejor_cromosoma=mejor_global,
mejor_fitness=mejor_global.fitness,
mejor_cronograma=resultado_final.cronograma,
mejor_metricas=resultado_final.metricas_globales,
historial=historial,
poblacion_final=poblacion,
generaciones_ejecutadas=len(historial),
tiempo_total_segundos=tiempo_total,
convergio=(generaciones_sin_mejora < config.max_generaciones_sin_mejora),
razon_parada=razon_parada
)
# 🔍 ANÁLISIS COMPLETO DE SOLAPAMIENTOS DEL CRONOGRAMA FINAL
print(f"\n📅 Analizando solapamientos en cronograma final...")
analisis_solapamientos = detectar_solapamientos_cronograma(resultado_final.cronograma, sistema)
# ✅ Si hay solapamientos reales, mostrar TODOS con detalles completos
mostrar_todos = analisis_solapamientos['total_solapamientos'] > 0
imprimir_reporte_solapamientos(analisis_solapamientos, mostrar_todos=mostrar_todos)
# 🔍 DIAGNÓSTICO: Si hay solapamientos, explicar por qué
if analisis_solapamientos['total_solapamientos'] > 0:
print(f"\n{'='*80}")
print("🔎 DIAGNÓSTICO DE SOLAPAMIENTOS")
print("="*80)
print(f"\n⚠️ Se encontraron {analisis_solapamientos['total_solapamientos']} solapamientos.")
print(f"\nPOSIBLES CAUSAS:")
print(f" 1. ❌ BUG en scheduler: Permitió asignar analista cuando NO debió")
print(f" 2. ⚙️ Lógica de batch incorrecta: Verificación de equipos falla")
print(f" 3. 👥 Recursos insuficientes: No hay suficientes analistas capacitados")
print(f"\nPara investigar más:")
print(f" - Revisar logs de asignación (si verbose está activado)")
print(f" - Verificar que analistas capacitados tengan turnos definidos")
print(f" - Comprobar si hay equipos ROLLING mal configurados")
print("="*80)
# 🔍 REPORTE DEBUG MODO 4
if DEBUG_MODE_4_OVERLAPS:
print(f"\n{'='*80}")
print("🔍 ANÁLISIS DETALLADO DE VERIFICACIONES (DEBUG MODO 4)")
print("="*80)
print(f"\n📊 RESUMEN:")
print(f" Total asignaciones modo 4 registradas: {len(DEBUG_MODE_4['registrations'])}")
print(f" Total verificaciones con solapamiento: {len(DEBUG_MODE_4['verification_logs'])}")
# Filtrar solo los logs donde se RECHAZÓ (debería haber rechazado pero no lo hizo)
rechazados = [v for v in DEBUG_MODE_4['verification_logs'] if v.get('resultado') == 'REJECTED_NOT_AVAILABLE']
permitidos_batch = [v for v in DEBUG_MODE_4['verification_logs'] if v.get('resultado') == 'ALLOWED_BATCH_LEGITIMO']
permitidos_manual = [v for v in DEBUG_MODE_4['verification_logs'] if v.get('resultado') == 'ALLOWED_BATCH_MANUAL']
print(f" Rechazados correctamente: {len(rechazados)}")
print(f" Permitidos (batch legítimo): {len(permitidos_batch)}")
print(f" Permitidos (batch manual): {len(permitidos_manual)}")
# Mostrar primeros 5 casos de solapamientos detectados con asignaciones modo 4
print(f"\n🔍 PRIMEROS 5 CASOS DE SOLAPAMIENTO CON MODO 4:")
count = 0
for i, log in enumerate(DEBUG_MODE_4['verification_logs']):
if log.get('action') == 'OVERLAP_DETECTED' and log.get('asignacion_existente', {}).get('is_mode_4'):
count += 1
if count <= 5:
print(f"\n ───────────────────────────────────────────────────────────────")
print(f" CASO #{count}:")
print(f" Analista: {log['analista']}")
print(f" Consulta nueva: {log['consulta_inicio'].strftime('%Y-%m-%d %H:%M')}{log['consulta_fin'].strftime('%Y-%m-%d %H:%M')}")
print(f" Asignación existente modo 4:")
print(f" Paso: {log['asignacion_existente']['paso_id']}")
print(f" Orden: {log['asignacion_existente']['orden']}")
print(f" Esquema: {log['asignacion_existente']['esquema']}")
print(f" Horario: {log['asignacion_existente']['inicio'].strftime('%Y-%m-%d %H:%M')}{log['asignacion_existente']['fin'].strftime('%Y-%m-%d %H:%M')}")
print(f" Contexto batch presente: {log['contexto_batch']}")
if log['contexto_batch']:
print(f" Contexto batch data: {log['contexto_batch_data']}")
print(f" Resultado: {log.get('resultado', '⚠️ SIN_RESULTADO - BUG AQUÍ!')}")
if count > 5:
break
# Buscar casos donde NO se detectó overlap pero terminó en solapamiento final
print(f"\n🔍 CASOS DONDE NO SE DETECTÓ OVERLAP (pero debió detectarse):")
no_overlaps_count = sum(1 for log in DEBUG_MODE_4['verification_logs'] if log.get('action') == 'NO_OVERLAP_FOUND')
print(f" Total casos 'NO_OVERLAP_FOUND': {no_overlaps_count}")
if no_overlaps_count > 0:
print(f"\n Mostrando primeros 3 casos:")
shown = 0
for log in DEBUG_MODE_4['verification_logs']:
if log.get('action') == 'NO_OVERLAP_FOUND' and shown < 3:
shown += 1
print(f"\n ─────────────────────────────────────────")
print(f" CASO NO-OVERLAP #{shown}:")
print(f" Analista: {log['analista']}")
print(f" Consulta: {log['consulta_inicio'].strftime('%Y-%m-%d %H:%M')}{log['consulta_fin'].strftime('%Y-%m-%d %H:%M')}")
print(f" Total asignaciones del analista: {log['total_asignaciones_analista']}")
print(f" Contexto batch: {log['contexto_batch']}")
print(f"\n{'='*80}")
# 📊 ESTADÍSTICAS DE ANALISTAS
estadisticas_analistas = calcular_estadisticas_analistas(resultado_final.cronograma)
if estadisticas_analistas:
print(f"\n{'='*80}")
print("👥 ESTADÍSTICAS DE CARGA DE TRABAJO POR ANALISTA")
print("="*80)
print(f"\n{'Analista':<30} {'Tareas':<10} {'Horas':<10} {'Modo 4':<10}")
print("-"*80)
for analista_id in sorted(estadisticas_analistas.keys()):
stats = estadisticas_analistas[analista_id]
# Manejar NaN en nombre
nombre_raw = stats['nombre']
if pd.isna(nombre_raw):
nombre = "SIN NOMBRE"
else:
nombre = str(nombre_raw)[:28]
print(f"{nombre:<30} {stats['total_tareas']:<10} {stats['tiempo_total_horas']:<10.1f} {stats['modo_4_tareas']:<10}")
print("="*80)
return resultado_ga
# ============================================================================
# ANÁLISIS DE SOLAPAMIENTOS
# ============================================================================
def calcular_momentos_interaccion_desde_tarea(tarea: Dict) -> List[Tuple[datetime, datetime]]:
"""
Calcula los momentos reales de interacción del analista para una tarea.
Basado en el modo de interacción de la tarea.
"""
modo = tarea.get('MODO_INTERACCION', 0)
inicio = pd.to_datetime(tarea['FECHA_INICIO'])
fin = pd.to_datetime(tarea['FECHA_FIN'])
duracion = (fin - inicio).total_seconds() / 60
tiempo_interaccion = tarea.get('TIEMPO_INTERACCION', 5.0)
frecuencia = tarea.get('FRECUENCIA_INTERACCION', 1)
# Llamar a la función existente
return calcular_momentos_interaccion(
modo=int(modo) if pd.notna(modo) else 0,
inicio=inicio,
duracion=duracion,
tiempo_interaccion=float(tiempo_interaccion) if pd.notna(tiempo_interaccion) else 5.0,
frecuencia_interaccion=int(frecuencia) if pd.notna(frecuencia) else 1
)
def detectar_solapamientos_cronograma(df_cronograma: pd.DataFrame,
sistema: Optional[SistemaPlanificacion] = None) -> Dict[str, Any]:
"""
Analiza el cronograma final y detecta TODOS los solapamientos de analistas.
Considera los MOMENTOS REALES de interacción según el modo, no solo las fechas de la tarea.
EXCLUYE solapamientos legítimos en equipos BATCH/BATCH_SCALING del mismo esquema.
Args:
df_cronograma: DataFrame con el cronograma completo
sistema: SistemaPlanificacion para verificar modos de equipos (opcional)
Returns:
Diccionario con estadísticas de solapamientos:
- total_solapamientos: número total de solapamientos
- solapamientos_por_analista: dict con lista de solapamientos por analista
- analistas_con_solapamiento: lista de analistas que tienen solapamientos
- detalles: lista completa de todos los solapamientos
- batches_legitimos_excluidos: número de batches legítimos detectados y excluidos
"""
if df_cronograma.empty or 'ANALISTA_ASIGNADO' not in df_cronograma.columns:
return {
'total_solapamientos': 0,
'solapamientos_por_analista': {},
'analistas_con_solapamiento': [],
'detalles': [],
'batches_legitimos_excluidos': 0
}
# Filtrar solo las tareas con analista asignado
df_con_analista = df_cronograma[df_cronograma['ANALISTA_ASIGNADO'].notna()].copy()
if df_con_analista.empty:
return {
'total_solapamientos': 0,
'solapamientos_por_analista': {},
'analistas_con_solapamiento': [],
'detalles': [],
'batches_legitimos_excluidos': 0
}
# Convertir fechas a datetime si no lo son
df_con_analista['FECHA_INICIO'] = pd.to_datetime(df_con_analista['FECHA_INICIO'])
df_con_analista['FECHA_FIN'] = pd.to_datetime(df_con_analista['FECHA_FIN'])
solapamientos = []
solapamientos_por_analista = {}
batches_legitimos_excluidos = 0 # ✅ NUEVO: Contador de batches legítimos
# ✅ Crear diccionario de equipos para verificar modos BATCH
equipos_dict = {}
if sistema:
for eq in sistema.equipos:
equipos_dict[eq.id] = eq
# Agrupar por analista
analistas_unicos = df_con_analista['ANALISTA_ASIGNADO'].unique()
for analista in analistas_unicos:
# Obtener todas las tareas de este analista
tareas_analista = df_con_analista[df_con_analista['ANALISTA_ASIGNADO'] == analista].sort_values('FECHA_INICIO')
# Convertir a lista de diccionarios para facilitar comparación
tareas_list = tareas_analista.to_dict('records')
# Para cada tarea, calcular sus momentos reales de interacción
tareas_con_momentos = []
for tarea in tareas_list:
momentos = calcular_momentos_interaccion_desde_tarea(tarea)
tareas_con_momentos.append({
'tarea': tarea,
'momentos': momentos
})
# Comparar cada par de tareas y sus momentos
for i in range(len(tareas_con_momentos)):
for j in range(i + 1, len(tareas_con_momentos)):
tarea1_data = tareas_con_momentos[i]
tarea2_data = tareas_con_momentos[j]
tarea1 = tarea1_data['tarea']
tarea2 = tarea2_data['tarea']
momentos1 = tarea1_data['momentos']
momentos2 = tarea2_data['momentos']
# Verificar si hay solapamiento entre CUALQUIER momento de tarea1 con CUALQUIER momento de tarea2
hay_solapamiento = False
tiempo_solapado_total = 0
for momento1_inicio, momento1_fin in momentos1:
for momento2_inicio, momento2_fin in momentos2:
# Verificar solapamiento entre estos dos momentos
if momento1_inicio < momento2_fin and momento2_inicio < momento1_fin:
hay_solapamiento = True
# Calcular tiempo solapado de ESTE par de momentos
inicio_solap = max(momento1_inicio, momento2_inicio)
fin_solap = min(momento1_fin, momento2_fin)
tiempo_solapado_total += (fin_solap - inicio_solap).total_seconds() / 60
if hay_solapamiento:
# ✅ VERIFICAR SI ES UN BATCH LEGÍTIMO antes de reportar como error
equipo1 = tarea1.get('EQUIPO_ASIGNADO', '')
equipo2 = tarea2.get('EQUIPO_ASIGNADO', '')
modo_equipo1 = tarea1.get('MODO_EQUIPO', '')
modo_equipo2 = tarea2.get('MODO_EQUIPO', '')
esquema1 = str(tarea1.get('ESQUEMA', '')).upper()
esquema2 = str(tarea2.get('ESQUEMA', '')).upper()
es_batch_legitimo = False
# CASO 1: BATCH con máquina (BATCH o BATCH_SCALING)
# Para ser batch legítimo, deben cumplirse:
# 1. Mismo equipo
# 2. Mismo esquema
# 3. El equipo debe ser BATCH o BATCH_SCALING
# 4. Los momentos deben ser simultáneos (diferencia < 60 segundos)
if equipo1 and equipo1 == equipo2 and esquema1 == esquema2 and equipos_dict:
equipo_obj = equipos_dict.get(equipo1)
if equipo_obj and equipo_obj.modo in [ModoEquipo.BATCH, ModoEquipo.BATCH_SCALING]:
# Verificar si los momentos son simultáneos
for m1_ini, m1_fin in momentos1:
for m2_ini, m2_fin in momentos2:
diff_segundos = abs((m1_ini - m2_ini).total_seconds())
if diff_segundos < 60: # Mismo momento (< 1 minuto de diferencia)
es_batch_legitimo = True
break
if es_batch_legitimo:
break
# CASO 2: BATCH MANUAL (sin máquina)
# Para ser batch manual legítimo:
# 1. Ambas tareas son "Batch Manual"
# 2. Mismo esquema
# 3. Misma orden
# 4. Los momentos deben ser simultáneos
orden1 = str(tarea1.get('ORDEN', '')).upper()
orden2 = str(tarea2.get('ORDEN', '')).upper()
if (modo_equipo1 == "Batch Manual" and modo_equipo2 == "Batch Manual" and
esquema1 == esquema2 and orden1 == orden2):
# Verificar si los momentos son simultáneos
for m1_ini, m1_fin in momentos1:
for m2_ini, m2_fin in momentos2:
diff_segundos = abs((m1_ini - m2_ini).total_seconds())
if diff_segundos < 60: # Mismo momento (< 1 minuto de diferencia)
es_batch_legitimo = True
break
if es_batch_legitimo:
break
if es_batch_legitimo:
# ✅ Es un batch legítimo, NO reportar como error
batches_legitimos_excluidos += 1
continue # Saltar al siguiente par de tareas
# ⚠️ NO es batch legítimo, reportar como solapamiento
solapamiento = {
'analista_id': analista,
'analista_nombre': tarea1.get('ANALISTA_NOMBRE', analista),
'tarea1': {
'paso_id': tarea1.get('PASO', 'N/A'),
'orden_id': tarea1.get('ORDEN', 'N/A'),
'esquema_id': tarea1.get('ESQUEMA', 'N/A'),
'muestra_id': tarea1.get('MUESTRA', 'N/A'),
'inicio': tarea1['FECHA_INICIO'],
'fin': tarea1['FECHA_FIN'],
'modo_interaccion': tarea1.get('MODO_INTERACCION', 'N/A'),
'tiempo_interaccion': tarea1.get('TIEMPO_INTERACCION', 'N/A'),
'frecuencia_interaccion': tarea1.get('FRECUENCIA_INTERACCION', 'N/A'),
'equipo_asignado': tarea1.get('EQUIPO_ASIGNADO', ''),
'tipo_equipo': tarea1.get('TIPO_EQUIPO', ''),
'modo_equipo': tarea1.get('MODO_EQUIPO', ''),
'batch_id': tarea1.get('BATCH_ID', ''),
'momentos': momentos1
},
'tarea2': {
'paso_id': tarea2.get('PASO', 'N/A'),
'orden_id': tarea2.get('ORDEN', 'N/A'),
'esquema_id': tarea2.get('ESQUEMA', 'N/A'),
'muestra_id': tarea2.get('MUESTRA', 'N/A'),
'inicio': tarea2['FECHA_INICIO'],
'fin': tarea2['FECHA_FIN'],
'modo_interaccion': tarea2.get('MODO_INTERACCION', 'N/A'),
'tiempo_interaccion': tarea2.get('TIEMPO_INTERACCION', 'N/A'),
'frecuencia_interaccion': tarea2.get('FRECUENCIA_INTERACCION', 'N/A'),
'equipo_asignado': tarea2.get('EQUIPO_ASIGNADO', ''),
'tipo_equipo': tarea2.get('TIPO_EQUIPO', ''),
'modo_equipo': tarea2.get('MODO_EQUIPO', ''),
'batch_id': tarea2.get('BATCH_ID', ''),
'momentos': momentos2
},
'tiempo_solapado_minutos': tiempo_solapado_total
}
solapamientos.append(solapamiento)
if analista not in solapamientos_por_analista:
solapamientos_por_analista[analista] = []
solapamientos_por_analista[analista].append(solapamiento)
return {
'total_solapamientos': len(solapamientos),
'solapamientos_por_analista': solapamientos_por_analista,
'analistas_con_solapamiento': list(solapamientos_por_analista.keys()),
'detalles': solapamientos,
'batches_legitimos_excluidos': batches_legitimos_excluidos # ✅ NUEVO: Batches que NO son errores
}
def calcular_estadisticas_analistas(df_cronograma: pd.DataFrame) -> Dict[str, Any]:
"""
Calcula estadísticas de carga de trabajo por analista.
Args:
df_cronograma: DataFrame con el cronograma completo
Returns:
Diccionario con estadísticas por analista
"""
if df_cronograma.empty or 'ANALISTA_ASIGNADO' not in df_cronograma.columns:
return {}
df_con_analista = df_cronograma[df_cronograma['ANALISTA_ASIGNADO'].notna()].copy()
if df_con_analista.empty:
return {}
# Usar DURACION_REAL si existe, sino calcular
if 'DURACION_REAL' not in df_con_analista.columns:
df_con_analista['FECHA_INICIO'] = pd.to_datetime(df_con_analista['FECHA_INICIO'])
df_con_analista['FECHA_FIN'] = pd.to_datetime(df_con_analista['FECHA_FIN'])
df_con_analista['DURACION_MINUTOS'] = (df_con_analista['FECHA_FIN'] - df_con_analista['FECHA_INICIO']).dt.total_seconds() / 60
else:
df_con_analista['DURACION_MINUTOS'] = df_con_analista['DURACION_REAL']
estadisticas = {}
for analista_id in df_con_analista['ANALISTA_ASIGNADO'].unique():
tareas = df_con_analista[df_con_analista['ANALISTA_ASIGNADO'] == analista_id]
estadisticas[analista_id] = {
'nombre': tareas['ANALISTA_NOMBRE'].iloc[0] if 'ANALISTA_NOMBRE' in tareas.columns else analista_id,
'total_tareas': len(tareas),
'tiempo_total_horas': tareas['DURACION_MINUTOS'].sum() / 60,
'modo_4_tareas': len(tareas[tareas['MODO_INTERACCION'] == 4]) if 'MODO_INTERACCION' in tareas.columns else 0
}
return estadisticas
def imprimir_reporte_solapamientos(analisis_solapamientos: Dict[str, Any], mostrar_todos: bool = False):
"""
Imprime un reporte detallado de solapamientos.
Args:
analisis_solapamientos: Resultado de detectar_solapamientos_cronograma()
mostrar_todos: Si True, muestra todos los solapamientos. Si False, solo primeros 20.
"""
print(f"\n{'='*80}")
print("🔍 ANÁLISIS COMPLETO DE SOLAPAMIENTOS EN MEJOR CROMOSOMA")
print("="*80)
total = analisis_solapamientos['total_solapamientos']
batches_excluidos = analisis_solapamientos.get('batches_legitimos_excluidos', 0)
print(f"\n📊 RESUMEN:")
print(f" Solapamientos REALES (errores): {total}")
print(f" Analistas con solapamientos: {len(analisis_solapamientos['analistas_con_solapamiento'])}")
if batches_excluidos > 0:
print(f" ✅ Batches legítimos (NO son error): {batches_excluidos}")
print(f" (Múltiples muestras del mismo esquema cargadas simultáneamente en equipo BATCH)")
if total == 0:
print(f"\n✅ ¡PERFECTO! No se detectaron solapamientos REALES en el cronograma final.")
print(f" Todos los analistas tienen asignaciones válidas sin conflictos.")
if batches_excluidos > 0:
print(f" Se identificaron {batches_excluidos} operaciones BATCH legítimas (esperadas).")
print("="*80)
return
# Mostrar estadísticas por analista
print(f"\n📋 SOLAPAMIENTOS POR ANALISTA:")
for analista_id in sorted(analisis_solapamientos['analistas_con_solapamiento']):
solaps = analisis_solapamientos['solapamientos_por_analista'][analista_id]
nombre = solaps[0]['analista_nombre'] if solaps else analista_id
print(f" • {nombre} ({analista_id}): {len(solaps)} solapamiento(s)")
# Mostrar detalles
limite = None if mostrar_todos else 20
detalles = analisis_solapamientos['detalles'][:limite] if limite else analisis_solapamientos['detalles']
print(f"\n⚠️ DETALLES DE SOLAPAMIENTOS:")
if limite and total > limite:
print(f" (Mostrando primeros {limite} de {total})\n")
else:
print(f" (Mostrando todos los {total} solapamientos)\n")
for i, solap in enumerate(detalles, 1):
print(f"\n {'─'*76}")
print(f" ❌ SOLAPAMIENTO #{i}: {solap['analista_nombre']} ({solap['analista_id']})")
print(f" {'─'*76}")
print(f" ⏱️ Tiempo de solapamiento: {solap['tiempo_solapado_minutos']:.1f} minutos")
print()
# TAREA 1
print(f" 📋 TAREA 1: {solap['tarea1']['paso_id']}")
print(f" Orden: {solap['tarea1']['orden_id']}")
print(f" Esquema: {solap['tarea1']['esquema_id']}")
print(f" Muestra: {solap['tarea1']['muestra_id']}")
print(f" Horario: {solap['tarea1']['inicio'].strftime('%Y-%m-%d %H:%M')}{solap['tarea1']['fin'].strftime('%H:%M')}")
print(f" Modo int.: {solap['tarea1']['modo_interaccion']}")
print(f" Tiempo int.: {solap['tarea1']['tiempo_interaccion']} min")
print(f" Frecuencia: {solap['tarea1']['frecuencia_interaccion']}")
equipo1 = solap['tarea1']['equipo_asignado']
if equipo1:
print(f" Equipo: {equipo1}")
print(f" Tipo equipo: {solap['tarea1']['tipo_equipo']}")
print(f" Modo equipo: {solap['tarea1']['modo_equipo']}")
else:
print(f" Equipo: (sin equipo - manual)")
batch1 = solap['tarea1']['batch_id']
if batch1:
print(f" Batch ID: {batch1}")
# Mostrar momentos de interacción de tarea 1
momentos1 = solap['tarea1'].get('momentos', [])
if momentos1:
print(f" Momentos: {len(momentos1)} momento(s)")
for idx, (mi, mf) in enumerate(momentos1[:3], 1): # Mostrar primeros 3
print(f" {idx}. {mi.strftime('%H:%M')}{mf.strftime('%H:%M')}")
if len(momentos1) > 3:
print(f" ... y {len(momentos1) - 3} momento(s) más")
print()
# TAREA 2
print(f" 📋 TAREA 2: {solap['tarea2']['paso_id']}")
print(f" Orden: {solap['tarea2']['orden_id']}")
print(f" Esquema: {solap['tarea2']['esquema_id']}")
print(f" Muestra: {solap['tarea2']['muestra_id']}")
print(f" Horario: {solap['tarea2']['inicio'].strftime('%Y-%m-%d %H:%M')}{solap['tarea2']['fin'].strftime('%H:%M')}")
print(f" Modo int.: {solap['tarea2']['modo_interaccion']}")
print(f" Tiempo int.: {solap['tarea2']['tiempo_interaccion']} min")
print(f" Frecuencia: {solap['tarea2']['frecuencia_interaccion']}")
equipo2 = solap['tarea2']['equipo_asignado']
if equipo2:
print(f" Equipo: {equipo2}")
print(f" Tipo equipo: {solap['tarea2']['tipo_equipo']}")
print(f" Modo equipo: {solap['tarea2']['modo_equipo']}")
else:
print(f" Equipo: (sin equipo - manual)")
batch2 = solap['tarea2']['batch_id']
if batch2:
print(f" Batch ID: {batch2}")
# Mostrar momentos de interacción de tarea 2
momentos2 = solap['tarea2'].get('momentos', [])
if momentos2:
print(f" Momentos: {len(momentos2)} momento(s)")
for idx, (mi, mf) in enumerate(momentos2[:3], 1): # Mostrar primeros 3
print(f" {idx}. {mi.strftime('%H:%M')}{mf.strftime('%H:%M')}")
if len(momentos2) > 3:
print(f" ... y {len(momentos2) - 3} momento(s) más")
print()
# DIAGNÓSTICO
print(f" 🔍 DIAGNÓSTICO:")
if solap['tarea1']['orden_id'] == solap['tarea2']['orden_id']:
print(f" ⚠️ MISMA ORDEN: {solap['tarea1']['orden_id']}")
else:
print(f" ✓ Órdenes diferentes")
if solap['tarea1']['esquema_id'] == solap['tarea2']['esquema_id']:
print(f" ⚠️ MISMO ESQUEMA: {solap['tarea1']['esquema_id']}")
else:
print(f" ✓ Esquemas diferentes")
# Verificar si deberían ser batch
if (solap['tarea1']['orden_id'] == solap['tarea2']['orden_id'] and
solap['tarea1']['esquema_id'] == solap['tarea2']['esquema_id']):
print(f" ⚠️ POSIBLE BATCH no detectado correctamente")
print(f" 💡 Revisar: ¿Ambas tareas sin máquina? ¿Ventana de batch_manual_wait?")
print(f" {'─'*76}")
if limite and total > limite:
print(f" ... y {total - limite} solapamientos más.\n")
print("="*80)
# ============================================================================
# ANÁLISIS DE DESEMPEÑO
# ============================================================================
def generar_reporte_completo(resultado_ga: ResultadoGA, sistema: SistemaPlanificacion) -> str:
"""Genera reporte completo en texto"""
lineas = []
lineas.append("\n" + "="*80)
lineas.append("📊 REPORTE COMPLETO - OPTIMIZACIÓN GENÉTICA")
lineas.append("="*80)
lineas.append("\n1. RESUMEN EJECUTIVO")
lineas.append("-"*80)
lineas.append(f"Fitness Final: {resultado_ga.mejor_fitness:.2f}")
lineas.append(f"Generaciones: {resultado_ga.generaciones_ejecutadas}")
lineas.append(f"Tiempo Total: {resultado_ga.tiempo_total_segundos:.2f}s")
lineas.append(f"Convergió: {'Sí' if resultado_ga.convergio else 'No'}")
m = resultado_ga.mejor_metricas
lineas.append("\n2. MÉTRICAS DEL CRONOGRAMA")
lineas.append("-"*80)
lineas.append(f"Makespan: {m.makespan_horas:.2f} horas")
lineas.append(f"Tardanza Total: {m.tardanza_total_horas:.2f} horas")
lineas.append(f"Tardanza Promedio: {m.tardanza_promedio_horas:.2f} horas")
lineas.append(f"Cumplimiento Órdenes: {m.tasa_cumplimiento_ordenes*100:.1f}%")
lineas.append(f"Cumplimiento Esquemas: {m.tasa_cumplimiento_esquemas*100:.1f}%")
lineas.append("\n3. UTILIZACIÓN DE RECURSOS")
lineas.append("-"*80)
lineas.append(f"Analistas: {m.analistas_utilizados}/{len(sistema.analistas)} ({m.utilizacion_analistas*100:.1f}%)")
lineas.append(f"Equipos: {m.equipos_utilizados}/{len(sistema.equipos)} ({m.utilizacion_equipos*100:.1f}%)")
lineas.append("\n4. VIOLACIONES")
lineas.append("-"*80)
lineas.append(f"Dependencias: {m.violaciones_dependencias}")
lineas.append(f"Secuencia: {m.violaciones_secuencia}")
lineas.append(f"Capacidad: {m.violaciones_capacidad}")
if m.violaciones_dependencias + m.violaciones_secuencia + m.violaciones_capacidad == 0:
lineas.append("\n✅ SOLUCIÓN VÁLIDA - Sin violaciones")
else:
lineas.append("\n⚠️ SOLUCIÓN CON VIOLACIONES")
if resultado_ga.historial:
primera = resultado_ga.historial[0]
ultima = resultado_ga.historial[-1]
mejora = ((primera.mejor_fitness - ultima.mejor_fitness) / primera.mejor_fitness * 100)
lineas.append("\n5. EVOLUCIÓN")
lineas.append("-"*80)
lineas.append(f"Fitness Inicial: {primera.mejor_fitness:.2f}")
lineas.append(f"Fitness Final: {ultima.mejor_fitness:.2f}")
lineas.append(f"Mejora: {mejora:.1f}%")
lineas.append("\n" + "="*80)
return "\n".join(lineas)
def generar_reporte_utilizacion_equipos(df_final: pd.DataFrame, sistema: SistemaPlanificacion) -> pd.DataFrame:
"""
Genera reporte de utilización de equipos sobre el makespan real de la simulación.
Args:
df_final: DataFrame con el cronograma completo (plan_previo + nuevas tareas)
sistema: Sistema de planificación con la lista de equipos
Returns:
DataFrame con columnas: EQUIPO_ID, NOMBRE_EQUIPO, HORAS_OCUPADAS, HORAS_DISPONIBLES_MAKESPAN, UTILIZACION_%, NUM_TAREAS
"""
if df_final.empty:
return pd.DataFrame(columns=[
'EQUIPO_ID', 'NOMBRE_EQUIPO', 'HORAS_OCUPADAS',
'HORAS_DISPONIBLES_MAKESPAN', 'UTILIZACION_%', 'NUM_TAREAS'
])
# ✅ Calcular makespan real de la simulación
inicio_global = df_final['FECHA_INICIO'].min()
fin_global = df_final['FECHA_FIN'].max()
makespan_horas = (fin_global - inicio_global).total_seconds() / 3600
# ✅ Calcular uso por equipo
equipos_en_uso = df_final[df_final['EQUIPO_ASIGNADO'].notna()].copy()
uso_por_equipo = equipos_en_uso.groupby('EQUIPO_ASIGNADO').agg({
'DURACION_REAL': 'sum', # Total horas ocupadas
'EQUIPO_ASIGNADO': 'count' # Número de tareas
}).rename(columns={'DURACION_REAL': 'HORAS_OCUPADAS', 'EQUIPO_ASIGNADO': 'NUM_TAREAS'})
# ✅ Crear diccionario de equipos con tipo (nombre)
equipos_dict = {str(eq.id): eq.tipo for eq in sistema.equipos}
# ✅ Construir reporte completo (incluyendo equipos con 0% utilización)
registros = []
for equipo_id, equipo_nombre in equipos_dict.items():
if equipo_id in uso_por_equipo.index:
horas_ocupadas = uso_por_equipo.loc[equipo_id, 'HORAS_OCUPADAS'] / 60 # Convertir minutos a horas
num_tareas = int(uso_por_equipo.loc[equipo_id, 'NUM_TAREAS'])
else:
horas_ocupadas = 0.0
num_tareas = 0
# Utilización = (horas ocupadas / makespan) * 100
utilizacion_pct = (horas_ocupadas / makespan_horas * 100) if makespan_horas > 0 else 0.0
registros.append({
'EQUIPO_ID': equipo_id,
'NOMBRE_EQUIPO': equipo_nombre,
'HORAS_OCUPADAS': round(horas_ocupadas, 2),
'HORAS_DISPONIBLES_MAKESPAN': round(makespan_horas, 2),
'UTILIZACION_%': round(utilizacion_pct, 2),
'NUM_TAREAS': num_tareas
})
df_reporte = pd.DataFrame(registros)
df_reporte = df_reporte.sort_values('UTILIZACION_%', ascending=False).reset_index(drop=True)
return df_reporte
def generar_reporte_tat_esquemas(df_cronograma: pd.DataFrame, sistema: SistemaPlanificacion) -> pd.DataFrame:
"""
Genera reporte de TAT por esquema (NO agrupado por orden).
Una fila por cada esquema-orden. Solo incluye NUEVAS órdenes (no plan_previo).
Args:
df_cronograma: DataFrame con el cronograma de NUEVAS tareas solamente (sin plan_previo)
sistema: Sistema de planificación con las órdenes
Returns:
DataFrame con columnas: ORDEN_ID, ESQUEMA_ID, NOMBRE_ESQUEMA, TAT_PROMETIDO_hrs,
TAT_REAL_hrs, DIFERENCIA_hrs, CUMPLE, TARDANZA_hrs,
FECHA_INICIO, FECHA_FIN, FECHA_COMPROMISO
"""
registros = []
for orden in sistema.ordenes:
for esquema in orden.esquemas:
# ✅ Usar esquema_id_original para coincidir con el DataFrame
esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.esquema_tipo or esquema.id)
# Filtrar tareas del esquema
tareas_esquema = df_cronograma[
(df_cronograma['ORDEN'] == orden.id) &
(df_cronograma['ESQUEMA'] == esquema_id_original)
]
if tareas_esquema.empty:
# ✅ Esquema sin tareas programadas (incluir con valores NULL)
tat_prometido_hrs = esquema.tat_especifico.total_seconds() / 3600 if esquema.tat_especifico else (orden.tat_prometido.total_seconds() / 3600 if orden.tat_prometido else None)
registros.append({
'ORDEN_ID': orden.id,
'ESQUEMA_ID': esquema.id,
'NOMBRE_ESQUEMA': esquema.esquema_tipo or esquema.id,
'TAT_PROMETIDO_hrs': tat_prometido_hrs,
'TAT_REAL_hrs': None,
'DIFERENCIA_hrs': None,
'CUMPLE': None,
'TARDANZA_hrs': None,
'FECHA_INICIO': None,
'FECHA_FIN': None,
'FECHA_COMPROMISO': None
})
continue
# ✅ TAT Real del esquema = max(FECHA_FIN todas las muestras) - FECHA_RECEPCION del esquema
fecha_fin_esquema = tareas_esquema['FECHA_FIN'].max()
fecha_recepcion_esquema = esquema.fecha_recepcion if esquema.fecha_recepcion else tareas_esquema['FECHA_INICIO'].min()
tat_real = fecha_fin_esquema - fecha_recepcion_esquema
tat_real_hrs = tat_real.total_seconds() / 3600
# ✅ TAT Prometido del esquema
if esquema.tat_especifico:
tat_prometido = esquema.tat_especifico
fecha_compromiso = fecha_recepcion_esquema + tat_prometido
else:
tat_prometido = orden.tat_prometido
fecha_compromiso = fecha_recepcion_esquema + tat_prometido if tat_prometido else orden.fecha_compromiso
tat_prometido_hrs = tat_prometido.total_seconds() / 3600 if tat_prometido else None
# ✅ Cumplimiento y tardanza
if fecha_compromiso:
tardanza_hrs = max(0, (fecha_fin_esquema - fecha_compromiso).total_seconds() / 3600)
cumple = tardanza_hrs == 0
else:
tardanza_hrs = 0.0
cumple = True
diferencia_hrs = tat_real_hrs - tat_prometido_hrs if tat_prometido_hrs else None
registros.append({
'ORDEN_ID': orden.id,
'ESQUEMA_ID': esquema.id,
'NOMBRE_ESQUEMA': esquema.esquema_tipo or esquema.id,
'TAT_PROMETIDO_hrs': round(tat_prometido_hrs, 2) if tat_prometido_hrs else None,
'TAT_REAL_hrs': round(tat_real_hrs, 2),
'DIFERENCIA_hrs': round(diferencia_hrs, 2) if diferencia_hrs else None,
'CUMPLE': cumple,
'TARDANZA_hrs': round(tardanza_hrs, 2),
'FECHA_RECEPCION': fecha_recepcion_esquema,
'FECHA_FIN': fecha_fin_esquema,
'FECHA_COMPROMISO': fecha_compromiso
})
df_reporte = pd.DataFrame(registros)
return df_reporte
# ============================================================================
# FUNCIONES DE EXPORTACIÓN PROFESIONALES - 4 OUTPUTS ÚNICOS
# ============================================================================
def exportar_detalle_ejecucion_limpio(df_cronograma: pd.DataFrame, timestamp: str):
"""
📋 OUTPUT 1: Detalle de Ejecución (usado como previous_plan en siguiente corrida)
Solo 12 columnas esenciales con formato profesional
"""
ruta = f"Detalle_Ejecución_{timestamp}.xlsx"
# Columnas esenciales (incluyendo información de interacción, modo de equipo y BATCH_ID)
columnas_esenciales = [
'ORDEN', 'ESQUEMA', 'MUESTRA', 'PASO',
'FECHA_INICIO', 'FECHA_FIN',
'FECHA_INICIO_REAL', 'FECHA_FIN_REAL',
'ANALISTA_ASIGNADO', 'EQUIPO_ASIGNADO', 'MODO_EQUIPO', 'BATCH_ID',
'DURACION_REAL', 'ESTADO',
'MODO_INTERACCION', 'TIEMPO_INTERACCION', 'FRECUENCIA_INTERACCION'
]
df_limpio = df_cronograma[columnas_esenciales].copy()
# 🔍 DEBUG: Verificar valores antes de exportar
print(f"\n🔍 DEBUG: Valores en df_limpio antes de exportar Detalle de Ejecución:")
if not df_limpio.empty:
print(f" BATCH_ID (primeros 3): {list(df_limpio['BATCH_ID'].head(3))}")
print(f" ESTADO (primeros 3): {list(df_limpio['ESTADO'].head(3))}")
with pd.ExcelWriter(ruta, engine='xlsxwriter') as writer:
workbook = writer.book
# Formatos
title_format = workbook.add_format({
'bold': True, 'font_size': 16, 'align': 'center', 'valign': 'vcenter',
'bg_color': '#2E75B6', 'font_color': 'white', 'border': 2
})
instruc_format = workbook.add_format({
'italic': True, 'font_size': 9, 'align': 'center',
'font_color': '#0070C0', 'bg_color': '#F2F2F2'
})
header_format = workbook.add_format({
'bold': True, 'bg_color': '#4472C4', 'font_color': 'white',
'border': 1, 'align': 'center', 'valign': 'vcenter', 'text_wrap': True
})
completado_format = workbook.add_format({
'bg_color': '#C6EFCE', 'font_color': '#006100',
'border': 1, 'bold': True, 'align': 'center'
})
en_proceso_format = workbook.add_format({
'bg_color': '#FFEB9C', 'font_color': '#9C5700',
'border': 1, 'bold': True, 'align': 'center'
})
pendiente_format = workbook.add_format({
'bg_color': '#FFC7CE', 'font_color': '#9C0006',
'border': 1, 'bold': True, 'align': 'center'
})
date_format = workbook.add_format({
'num_format': 'yyyy-mm-dd hh:mm', 'border': 1, 'align': 'center'
})
vacio_format = workbook.add_format({
'bg_color': '#FFFACD', 'border': 1, 'align': 'center',
'italic': True, 'font_color': '#808080'
})
# Exportar
df_limpio.to_excel(writer, sheet_name='CRONOGRAMA', index=False, startrow=2)
worksheet = writer.sheets['CRONOGRAMA']
# Título
worksheet.merge_range(0, 0, 0, len(columnas_esenciales)-1,
'📋 DETALLE DE EJECUCIÓN - PLAN DE TRABAJO', title_format)
# Instrucciones
worksheet.merge_range(1, 0, 1, len(columnas_esenciales)-1,
'⚠️ IMPORTANTE: Complete las columnas amarillas (FECHA_INICIO_REAL, FECHA_FIN_REAL) y actualice ESTADO para usar como plan previo',
instruc_format)
# Encabezados
for col_num, col_name in enumerate(columnas_esenciales):
worksheet.write(2, col_num, col_name, header_format)
# Anchos
col_widths = {
'ORDEN': 12, 'ESQUEMA': 14, 'MUESTRA': 10, 'PASO': 10,
'FECHA_INICIO': 18, 'FECHA_FIN': 18,
'FECHA_INICIO_REAL': 18, 'FECHA_FIN_REAL': 18,
'ANALISTA_ASIGNADO': 16, 'EQUIPO_ASIGNADO': 16, 'MODO_EQUIPO': 16, 'BATCH_ID': 30,
'DURACION_REAL': 12, 'ESTADO': 14
}
for col_num, col_name in enumerate(columnas_esenciales):
width = col_widths.get(col_name, 15)
worksheet.set_column(col_num, col_num, width)
# Formatos por celda
for row_num in range(len(df_limpio)):
# Fechas planificadas
worksheet.write(row_num + 3, 4, df_limpio.iloc[row_num, 4], date_format)
worksheet.write(row_num + 3, 5, df_limpio.iloc[row_num, 5], date_format)
# Fechas reales (vacías con formato amarillo)
worksheet.write(row_num + 3, 6, 'Completar aquí', vacio_format)
worksheet.write(row_num + 3, 7, 'Completar aquí', vacio_format)
# Estado con colores
# ✅ FIX: ESTADO está en la columna 13, NO en la 11 (BATCH_ID está en la 11)
# Columnas: 0=ORDEN, 1=ESQUEMA, 2=MUESTRA, 3=PASO, 4=FECHA_INICIO, 5=FECHA_FIN,
# 6=FECHA_INICIO_REAL, 7=FECHA_FIN_REAL, 8=ANALISTA_ASIGNADO,
# 9=EQUIPO_ASIGNADO, 10=MODO_EQUIPO, 11=BATCH_ID, 12=DURACION_REAL, 13=ESTADO
estado = df_limpio.iloc[row_num]['ESTADO']
if estado == 'COMPLETADO':
worksheet.write(row_num + 3, 13, estado, completado_format)
elif estado == 'EN_PROCESO':
worksheet.write(row_num + 3, 13, estado, en_proceso_format)
else:
worksheet.write(row_num + 3, 13, estado, pendiente_format)
# Congelar paneles y filtros
worksheet.freeze_panes(3, 0)
worksheet.autofilter(2, 0, len(df_limpio) + 2, len(columnas_esenciales) - 1)
# Altura de filas
worksheet.set_row(0, 30)
worksheet.set_row(1, 35)
worksheet.set_row(2, 25)
print(f" 📋 Detalle de Ejecución: {ruta}")
return ruta
def exportar_utilizacion(df_cronograma: pd.DataFrame, sistema, timestamp: str):
"""
⚙️ OUTPUT 2: Utilización (2 hojas: Por Equipo + Por Analista con gráficos)
"""
ruta = f"Utilización_{timestamp}.xlsx"
with pd.ExcelWriter(ruta, engine='xlsxwriter') as writer:
workbook = writer.book
# Formatos
title_format = workbook.add_format({
'bold': True, 'font_size': 16, 'align': 'center',
'bg_color': '#2E75B6', 'font_color': 'white', 'border': 2
})
header_format = workbook.add_format({
'bold': True, 'bg_color': '#4472C4', 'font_color': 'white',
'border': 1, 'align': 'center'
})
high_util = workbook.add_format({
'num_format': '0.00%', 'bg_color': '#C6EFCE', 'font_color': '#006100',
'border': 1, 'align': 'center', 'bold': True
})
medium_util = workbook.add_format({
'num_format': '0.00%', 'bg_color': '#FFEB9C', 'font_color': '#9C5700',
'border': 1, 'align': 'center'
})
low_util = workbook.add_format({
'num_format': '0.00%', 'bg_color': '#FFC7CE', 'font_color': '#9C0006',
'border': 1, 'align': 'center'
})
# ==========================================
# HOJA 1: POR EQUIPO
# ==========================================
df_equipo = df_cronograma[
df_cronograma['EQUIPO_ASIGNADO'].notna() &
(df_cronograma['EQUIPO_ASIGNADO'] != '')
].copy()
if not df_equipo.empty and not df_cronograma.empty:
inicio_global = df_cronograma['FECHA_INICIO'].min()
fin_global = df_cronograma['FECHA_FIN'].max()
makespan_total = (fin_global - inicio_global).total_seconds() / 60
df_equipo_stats = df_equipo.groupby('EQUIPO_ASIGNADO').agg({
'DURACION_REAL': 'sum',
'PASO': 'count'
}).reset_index()
df_equipo_stats.columns = ['EQUIPO', 'Minutos_Uso', 'Total_Tareas']
df_equipo_stats['Utilización'] = df_equipo_stats['Minutos_Uso'] / makespan_total if makespan_total > 0 else 0
df_equipo_stats.to_excel(writer, sheet_name='POR_EQUIPO', index=False, startrow=2)
ws_equipo = writer.sheets['POR_EQUIPO']
# Título
ws_equipo.merge_range(0, 0, 0, 3, '⚙️ UTILIZACIÓN POR EQUIPO', title_format)
# Encabezados
for col_num, col_name in enumerate(df_equipo_stats.columns):
ws_equipo.write(2, col_num, col_name, header_format)
# Anchos
ws_equipo.set_column('A:A', 18)
ws_equipo.set_column('B:B', 15)
ws_equipo.set_column('C:C', 15)
ws_equipo.set_column('D:D', 15)
# Formato condicional
for row_num in range(len(df_equipo_stats)):
uso = df_equipo_stats.iloc[row_num]['Utilización']
if uso >= 0.70:
ws_equipo.write(row_num + 3, 3, uso, high_util)
elif uso >= 0.40:
ws_equipo.write(row_num + 3, 3, uso, medium_util)
else:
ws_equipo.write(row_num + 3, 3, uso, low_util)
# Gráfico de barras verticales
chart_equipo = workbook.add_chart({'type': 'column'})
chart_equipo.add_series({
'name': 'Utilización (%)',
'categories': ['POR_EQUIPO', 3, 0, len(df_equipo_stats) + 2, 0],
'values': ['POR_EQUIPO', 3, 3, len(df_equipo_stats) + 2, 3],
'fill': {'color': '#4472C4'}
})
chart_equipo.set_title({'name': 'Utilización por Equipo'})
chart_equipo.set_x_axis({'name': 'Equipo'})
chart_equipo.set_y_axis({'name': 'Utilización (%)', 'num_format': '0%'})
chart_equipo.set_size({'width': 600, 'height': 400})
ws_equipo.insert_chart(len(df_equipo_stats) + 5, 0, chart_equipo)
ws_equipo.freeze_panes(3, 0)
# ==========================================
# HOJA 2: POR ANALISTA
# ==========================================
df_analista = df_cronograma[
df_cronograma['ANALISTA_ASIGNADO'].notna() &
(df_cronograma['ANALISTA_ASIGNADO'] != '')
].copy()
if not df_analista.empty:
# Calcular makespan total (minutos)
inicio_global = df_cronograma['FECHA_INICIO'].min()
fin_global = df_cronograma['FECHA_FIN'].max()
makespan_total_min = (fin_global - inicio_global).total_seconds() / 60
df_analista_stats = df_analista.groupby('ANALISTA_ASIGNADO').agg({
'PASO': 'count',
'DURACION_REAL': 'sum', # suma en minutos
'ESTADO': lambda x: (x == 'COMPLETADO').sum()
}).reset_index()
df_analista_stats.columns = ['ANALISTA', 'Total_Tareas', 'Minutos_Trabajados', 'Tareas_Completadas']
# ✅ CORREGIDO: Utilización = Minutos trabajados / Makespan total
# Se calcula sobre TODAS las tareas asignadas (programadas), no solo completadas
df_analista_stats['Utilización'] = df_analista_stats['Minutos_Trabajados'] / makespan_total_min if makespan_total_min > 0 else 0
df_analista_stats.to_excel(writer, sheet_name='POR_ANALISTA', index=False, startrow=2)
ws_analista = writer.sheets['POR_ANALISTA']
# Título
ws_analista.merge_range(0, 0, 0, 4, '👥 UTILIZACIÓN POR ANALISTA', title_format)
# Encabezados
for col_num, col_name in enumerate(df_analista_stats.columns):
ws_analista.write(2, col_num, col_name, header_format)
# Anchos
ws_analista.set_column('A:A', 15)
ws_analista.set_column('B:B', 15)
ws_analista.set_column('C:C', 18)
ws_analista.set_column('D:D', 20)
ws_analista.set_column('E:E', 15)
# Formato condicional
for row_num in range(len(df_analista_stats)):
utilizacion = df_analista_stats.iloc[row_num]['Utilización']
if utilizacion >= 0.80:
ws_analista.write(row_num + 3, 4, utilizacion, high_util)
elif utilizacion >= 0.50:
ws_analista.write(row_num + 3, 4, utilizacion, medium_util)
else:
ws_analista.write(row_num + 3, 4, utilizacion, low_util)
# Gráfico de barras horizontales
chart_analista = workbook.add_chart({'type': 'bar'})
chart_analista.add_series({
'name': 'Utilización (%)',
'categories': ['POR_ANALISTA', 3, 0, len(df_analista_stats) + 2, 0],
'values': ['POR_ANALISTA', 3, 4, len(df_analista_stats) + 2, 4],
'fill': {'color': '#70AD47'}
})
chart_analista.set_title({'name': 'Utilización por Analista'})
chart_analista.set_x_axis({'name': 'Utilización (%)', 'num_format': '0%'})
chart_analista.set_y_axis({'name': 'Analista'})
chart_analista.set_size({'width': 600, 'height': 400})
ws_analista.insert_chart(len(df_analista_stats) + 5, 0, chart_analista)
ws_analista.freeze_panes(3, 0)
print(f" ⚙️ Utilización: {ruta}")
return ruta
def exportar_metricas(df_cronograma: pd.DataFrame, sistema, timestamp: str):
"""
📊 OUTPUT 3: Métricas (2 hojas: Por Orden + Por Orden-Esquema con gráficos)
"""
ruta = f"Métricas_{timestamp}.xlsx"
with pd.ExcelWriter(ruta, engine='xlsxwriter') as writer:
workbook = writer.book
# Formatos
title_format = workbook.add_format({
'bold': True, 'font_size': 16, 'align': 'center',
'bg_color': '#2E75B6', 'font_color': 'white', 'border': 2
})
header_format = workbook.add_format({
'bold': True, 'bg_color': '#4472C4', 'font_color': 'white',
'border': 1, 'align': 'center'
})
cumple_format = workbook.add_format({
'bg_color': '#C6EFCE', 'font_color': '#006100',
'border': 1, 'bold': True, 'align': 'center'
})
no_cumple_format = workbook.add_format({
'bg_color': '#FFC7CE', 'font_color': '#9C0006',
'border': 1, 'bold': True, 'align': 'center'
})
date_format = workbook.add_format({
'num_format': 'yyyy-mm-dd hh:mm', 'border': 1, 'align': 'center'
})
# ==========================================
# HOJA 1: POR ORDEN
# ==========================================
df_por_orden = df_cronograma.groupby('ORDEN').agg({
'PASO': 'count',
'FECHA_INICIO': 'min',
'FECHA_FIN': 'max',
'ESTADO': [
lambda x: (x == 'COMPLETADO').sum(),
lambda x: (x == 'PENDIENTE').sum()
]
}).reset_index()
df_por_orden.columns = ['ORDEN', 'Total_Pasos', 'Fecha_Inicio', 'Fecha_Fin', 'Completadas', 'Pendientes']
# ✅ CORREGIDO: TAT Real = Fecha_Fin - Fecha_Recepción (no Fecha_Inicio)
# TAT Prometido = máximo TAT de todos los esquemas de la orden
tat_real_list = []
tat_prometido_list = []
fecha_recepcion_list = []
for _, row in df_por_orden.iterrows():
orden_id = row['ORDEN']
orden_obj = next((o for o in sistema.ordenes if o.id == orden_id), None)
if orden_obj:
# TAT Real = Fecha_Fin (último paso) - Fecha_Recepción
tat_real_hrs = (row['Fecha_Fin'] - orden_obj.fecha_recepcion).total_seconds() / 3600
tat_real_list.append(tat_real_hrs)
fecha_recepcion_list.append(orden_obj.fecha_recepcion)
# TAT Prometido = máximo TAT de todos los esquemas asociados
max_tat_hrs = 0
for esquema in orden_obj.esquemas:
if esquema.tat_especifico:
tat_hrs = esquema.tat_especifico.total_seconds() / 3600
elif orden_obj.tat_prometido:
tat_hrs = orden_obj.tat_prometido.total_seconds() / 3600
else:
tat_hrs = 48 # valor por defecto
max_tat_hrs = max(max_tat_hrs, tat_hrs)
tat_prometido_list.append(max_tat_hrs)
else:
# Fallback si no se encuentra la orden
tat_real_hrs = (row['Fecha_Fin'] - row['Fecha_Inicio']).total_seconds() / 3600
tat_real_list.append(tat_real_hrs)
tat_prometido_list.append(48)
fecha_recepcion_list.append(row['Fecha_Inicio'])
df_por_orden['Fecha_Recepcion'] = fecha_recepcion_list
df_por_orden['TAT_Real_horas'] = tat_real_list
df_por_orden['TAT_Prometido_horas'] = tat_prometido_list
df_por_orden['Cumple_TAT'] = df_por_orden['TAT_Real_horas'] <= df_por_orden['TAT_Prometido_horas']
df_por_orden.to_excel(writer, sheet_name='POR_ORDEN', index=False, startrow=2)
ws_orden = writer.sheets['POR_ORDEN']
# Título
ws_orden.merge_range(0, 0, 0, 9, '📦 MÉTRICAS POR ORDEN', title_format)
# Encabezados
for col_num, col_name in enumerate(df_por_orden.columns):
ws_orden.write(2, col_num, col_name, header_format)
# Anchos (ajustados para nueva columna Fecha_Recepcion)
ws_orden.set_column('A:A', 15) # ORDEN
ws_orden.set_column('B:B', 13) # Total_Pasos
ws_orden.set_column('C:C', 18) # Fecha_Inicio
ws_orden.set_column('D:D', 18) # Fecha_Fin
ws_orden.set_column('E:F', 14) # Completadas, Pendientes
ws_orden.set_column('G:G', 18) # Fecha_Recepcion
ws_orden.set_column('H:I', 16) # TAT_Real_horas, TAT_Prometido_horas
ws_orden.set_column('J:J', 12) # Cumple_TAT
# Formatos
for row_num in range(len(df_por_orden)):
# Fechas
ws_orden.write(row_num + 3, 2, df_por_orden.iloc[row_num]['Fecha_Inicio'], date_format)
ws_orden.write(row_num + 3, 3, df_por_orden.iloc[row_num]['Fecha_Fin'], date_format)
ws_orden.write(row_num + 3, 6, df_por_orden.iloc[row_num]['Fecha_Recepcion'], date_format)
# Cumple TAT con color
cumple = df_por_orden.iloc[row_num]['Cumple_TAT']
texto = 'SÍ' if cumple else 'NO'
formato = cumple_format if cumple else no_cumple_format
ws_orden.write(row_num + 3, 9, texto, formato)
ws_orden.freeze_panes(3, 0)
# Gráfico de barras comparativas (columnas actualizadas)
chart_tat = workbook.add_chart({'type': 'column'})
chart_tat.add_series({
'name': 'TAT Real',
'categories': ['POR_ORDEN', 3, 0, len(df_por_orden) + 2, 0],
'values': ['POR_ORDEN', 3, 7, len(df_por_orden) + 2, 7], # Columna H (TAT_Real_horas)
'fill': {'color': '#5B9BD5'}
})
chart_tat.add_series({
'name': 'TAT Prometido',
'categories': ['POR_ORDEN', 3, 0, len(df_por_orden) + 2, 0],
'values': ['POR_ORDEN', 3, 8, len(df_por_orden) + 2, 8], # Columna I (TAT_Prometido_horas)
'fill': {'color': '#ED7D31'}
})
chart_tat.set_title({'name': 'TAT Real vs TAT Prometido por Orden'})
chart_tat.set_x_axis({'name': 'Orden'})
chart_tat.set_y_axis({'name': 'Horas'})
chart_tat.set_size({'width': 700, 'height': 400})
ws_orden.insert_chart(len(df_por_orden) + 5, 0, chart_tat)
# ==========================================
# HOJA 2: POR ORDEN-ESQUEMA
# ==========================================
df_orden_esquema = df_cronograma.groupby(['ORDEN', 'ESQUEMA']).agg({
'PASO': 'count',
'FECHA_FIN': 'max',
'ESTADO': lambda x: (x == 'COMPLETADO').sum()
}).reset_index()
df_orden_esquema.columns = ['ORDEN', 'ESQUEMA', 'Total_Pasos', 'Fecha_Fin', 'Completadas']
# ✅ Calcular TAT Real del esquema (Fecha_Fin - Fecha_Recepcion del esquema)
tat_real_list_esq = []
tat_prometido_list_esq = []
cumple_tat_list = []
fecha_recepcion_list_esq = []
for _, row in df_orden_esquema.iterrows():
orden_id = row['ORDEN']
esquema_id = row['ESQUEMA']
# Buscar el esquema en el sistema
tat_hrs = 48 # valor por defecto
fecha_recepcion_esq = row['Fecha_Fin'] # fallback
for orden_obj in sistema.ordenes:
if orden_obj.id == orden_id:
for esquema_obj in orden_obj.esquemas:
esquema_id_orig = esquema_obj.metadata.get('esquema_id_original', esquema_obj.esquema_tipo or esquema_obj.id)
if esquema_id_orig == esquema_id:
# Usar fecha_recepcion del esquema
if esquema_obj.fecha_recepcion:
fecha_recepcion_esq = esquema_obj.fecha_recepcion
else:
fecha_recepcion_esq = orden_obj.fecha_recepcion
if esquema_obj.tat_especifico:
tat_hrs = esquema_obj.tat_especifico.total_seconds() / 3600
elif orden_obj.tat_prometido:
tat_hrs = orden_obj.tat_prometido.total_seconds() / 3600
break
break
fecha_recepcion_list_esq.append(fecha_recepcion_esq)
tat_real_hrs = (row['Fecha_Fin'] - fecha_recepcion_esq).total_seconds() / 3600
tat_real_list_esq.append(tat_real_hrs)
tat_prometido_list_esq.append(tat_hrs)
cumple = tat_real_hrs <= tat_hrs
cumple_tat_list.append(cumple)
df_orden_esquema['Fecha_Recepcion'] = fecha_recepcion_list_esq
df_orden_esquema['TAT_Real_horas'] = tat_real_list_esq
df_orden_esquema['TAT_Prometido_horas'] = tat_prometido_list_esq
df_orden_esquema['Cumple_TAT'] = cumple_tat_list
df_orden_esquema.to_excel(writer, sheet_name='POR_ORDEN_ESQUEMA', index=False, startrow=2)
ws_esq = writer.sheets['POR_ORDEN_ESQUEMA']
# Título (actualizado para más columnas)
ws_esq.merge_range(0, 0, 0, 9, '📋 MÉTRICAS POR ORDEN-ESQUEMA', title_format)
# Encabezados
for col_num, col_name in enumerate(df_orden_esquema.columns):
ws_esq.write(2, col_num, col_name, header_format)
# Anchos (actualizados para nuevas columnas: ORDEN, ESQUEMA, Total_Pasos, Fecha_Fin, Completadas, Fecha_Recepcion, TAT_Real_horas, TAT_Prometido_horas, Cumple_TAT)
ws_esq.set_column('A:A', 15) # ORDEN
ws_esq.set_column('B:B', 18) # ESQUEMA
ws_esq.set_column('C:C', 13) # Total_Pasos
ws_esq.set_column('D:D', 18) # Fecha_Fin
ws_esq.set_column('E:E', 14) # Completadas
ws_esq.set_column('F:F', 18) # Fecha_Recepcion
ws_esq.set_column('G:H', 16) # TAT_Real_horas, TAT_Prometido_horas
ws_esq.set_column('I:I', 12) # Cumple_TAT
# Formatos
for row_num in range(len(df_orden_esquema)):
# Fechas
ws_esq.write(row_num + 3, 3, df_orden_esquema.iloc[row_num]['Fecha_Fin'], date_format)
ws_esq.write(row_num + 3, 5, df_orden_esquema.iloc[row_num]['Fecha_Recepcion'], date_format)
# Cumple TAT con color
cumple = df_orden_esquema.iloc[row_num]['Cumple_TAT']
texto = 'SÍ' if cumple else 'NO'
formato = cumple_format if cumple else no_cumple_format
ws_esq.write(row_num + 3, 8, texto, formato)
ws_esq.freeze_panes(3, 0)
# Calcular TAT promedio por esquema y TAT prometido por esquema
df_tat_por_esquema = df_orden_esquema.groupby('ESQUEMA')['TAT_Real_horas'].mean().reset_index()
df_tat_por_esquema.columns = ['ESQUEMA', 'TAT_Promedio']
# ✅ Calcular TAT prometido específico para cada esquema
tat_prometido_por_esquema = []
for esquema_id in df_tat_por_esquema['ESQUEMA']:
# Buscar el esquema en el sistema
tat_hrs = 48 # valor por defecto
for orden_obj in sistema.ordenes:
for esquema_obj in orden_obj.esquemas:
esquema_id_orig = esquema_obj.metadata.get('esquema_id_original', esquema_obj.esquema_tipo or esquema_obj.id)
if esquema_id_orig == esquema_id:
if esquema_obj.tat_especifico:
tat_hrs = esquema_obj.tat_especifico.total_seconds() / 3600
elif orden_obj.tat_prometido:
tat_hrs = orden_obj.tat_prometido.total_seconds() / 3600
break
tat_prometido_por_esquema.append(tat_hrs)
df_tat_por_esquema['TAT_Prometido'] = tat_prometido_por_esquema
# Gráfico de barras + línea de TAT prometido
chart_esq = workbook.add_chart({'type': 'column'})
# Agregar barras de TAT promedio por esquema
if not df_tat_por_esquema.empty:
# Crear hoja temporal para datos del gráfico
df_tat_por_esquema.to_excel(writer, sheet_name='_TEMP_GRAFICO', index=False)
# Serie de barras: TAT Promedio Real
chart_esq.add_series({
'name': 'TAT Promedio Real',
'categories': ['_TEMP_GRAFICO', 1, 0, len(df_tat_por_esquema), 0],
'values': ['_TEMP_GRAFICO', 1, 1, len(df_tat_por_esquema), 1],
'fill': {'color': '#70AD47'}
})
# Serie de línea: TAT Prometido por esquema
chart_esq.add_series({
'name': 'TAT Prometido',
'categories': ['_TEMP_GRAFICO', 1, 0, len(df_tat_por_esquema), 0],
'values': ['_TEMP_GRAFICO', 1, 2, len(df_tat_por_esquema), 2],
'line': {'color': '#C00000', 'width': 3, 'dash_type': 'dash'},
'marker': {'type': 'diamond', 'size': 6}
})
chart_esq.set_title({'name': 'TAT Promedio por Esquema vs TAT Prometido'})
chart_esq.set_x_axis({'name': 'Esquema'})
chart_esq.set_y_axis({'name': 'Horas'})
chart_esq.set_size({'width': 700, 'height': 400})
ws_esq.insert_chart(len(df_orden_esquema) + 5, 0, chart_esq)
# Ocultar hoja temporal
if '_TEMP_GRAFICO' in writer.sheets:
writer.sheets['_TEMP_GRAFICO'].hide()
print(f" 📊 Métricas: {ruta}")
return ruta
def exportar_cronograma_por_analista_optimizado(df_cronograma: pd.DataFrame, timestamp: str):
"""
👤 OUTPUT 4: Cronograma por Analista (1 hoja por analista con información completa de interacciones)
"""
ruta = f"Cronograma_por_Analista_{timestamp}.xlsx"
df_con_analista = df_cronograma[
df_cronograma['ANALISTA_ASIGNADO'].notna() &
(df_cronograma['ANALISTA_ASIGNADO'] != '')
].copy()
if df_con_analista.empty:
print(f" ⚠️ No hay tareas con analistas asignados")
return
analistas = sorted(df_con_analista['ANALISTA_ASIGNADO'].unique())
with pd.ExcelWriter(ruta, engine='xlsxwriter') as writer:
workbook = writer.book
# Formatos
title_format = workbook.add_format({
'bold': True, 'font_size': 20, 'align': 'center', 'valign': 'vcenter',
'bg_color': '#4472C4', 'font_color': 'white', 'border': 2
})
header_format = workbook.add_format({
'bold': True, 'bg_color': '#2E75B6', 'font_color': 'white',
'border': 1, 'align': 'center', 'valign': 'vcenter', 'text_wrap': True
})
completado_format = workbook.add_format({
'bg_color': '#C6EFCE', 'font_color': '#006100',
'border': 1, 'bold': True, 'align': 'center'
})
en_proceso_format = workbook.add_format({
'bg_color': '#FFEB9C', 'font_color': '#9C5700',
'border': 1, 'bold': True, 'align': 'center'
})
pendiente_format = workbook.add_format({
'bg_color': '#FFC7CE', 'font_color': '#9C0006',
'border': 1, 'bold': True, 'align': 'center'
})
date_format = workbook.add_format({
'num_format': 'yyyy-mm-dd hh:mm', 'border': 1, 'align': 'center'
})
stats_label = workbook.add_format({
'bold': True, 'bg_color': '#D9E2F3', 'border': 1
})
stats_value = workbook.add_format({
'bg_color': '#F2F2F2', 'border': 1, 'align': 'center',
'bold': True, 'font_size': 12
})
modo_format = workbook.add_format({
'border': 1, 'align': 'center', 'bold': True,
'bg_color': '#E7E6E6'
})
# Crear hoja por analista
for analista_id in analistas:
df_analista = df_con_analista[
df_con_analista['ANALISTA_ASIGNADO'] == analista_id
].sort_values('FECHA_INICIO').copy()
# Obtener nombre del analista para el título de la hoja
analista_nombre = analista_id # Default
if 'ANALISTA_NOMBRE' in df_analista.columns and not df_analista.empty:
nombre_raw = df_analista['ANALISTA_NOMBRE'].iloc[0]
if pd.notna(nombre_raw) and str(nombre_raw).strip():
analista_nombre = str(nombre_raw).strip()
# Limpiar nombre para que sea válido como nombre de hoja (max 31 caracteres, sin caracteres especiales)
sheet_name = str(analista_nombre)[:31].replace('/', '-').replace('\\', '-').replace('*', '').replace('[', '').replace(']', '').replace(':', '').replace('?', '')
if not sheet_name:
sheet_name = str(analista_id)[:31]
# Columnas expandidas con información de interacción y BATCH_ID
columnas = [
'ORDEN', 'ESQUEMA', 'MUESTRA', 'PASO', 'NOMBRE_PASO',
'FECHA_INICIO', 'FECHA_FIN',
'DURACION_REAL',
'MODO_INTERACCION', 'TIEMPO_INTERACCION', 'FRECUENCIA_INTERACCION',
'EQUIPO_ASIGNADO', 'TIPO_EQUIPO', 'MODO_EQUIPO', 'BATCH_ID', 'ESTADO'
]
# Verificar que todas las columnas existen en el DataFrame
columnas_disponibles = [col for col in columnas if col in df_analista.columns]
df_export = df_analista[columnas_disponibles].copy()
# Usar el sheet_name ya calculado arriba (basado en nombre del analista)
df_export.to_excel(writer, sheet_name=sheet_name, index=False, startrow=5)
worksheet = writer.sheets[sheet_name]
# Título con NOMBRE del analista
titulo_display = f"{analista_nombre} (ID: {analista_id})" if analista_nombre != analista_id else analista_id
worksheet.merge_range(0, 0, 1, len(columnas_disponibles)-1,
f'👤 ANALISTA: {titulo_display}', title_format)
# Estadísticas
total = len(df_analista)
completadas = len(df_analista[df_analista['ESTADO'] == 'COMPLETADO'])
en_proceso = len(df_analista[df_analista['ESTADO'] == 'EN_PROCESO'])
pendientes = len(df_analista[df_analista['ESTADO'] == 'PENDIENTE'])
worksheet.write(3, 0, 'Total:', stats_label)
worksheet.write(3, 1, total, stats_value)
worksheet.write(3, 2, '✅ Completadas:', stats_label)
worksheet.write(3, 3, completadas, stats_value)
worksheet.write(3, 4, '⏳ En Proceso:', stats_label)
worksheet.write(3, 5, en_proceso, stats_value)
worksheet.write(3, 6, '⏸️ Pendientes:', stats_label)
worksheet.write(3, 7, pendientes, stats_value)
# Encabezados (usar columnas_disponibles)
for col_num, col_name in enumerate(columnas_disponibles):
worksheet.write(5, col_num, col_name, header_format)
# Anchos ajustados para las nuevas columnas
# ORDEN, ESQUEMA, MUESTRA, PASO, NOMBRE_PASO, FECHA_INICIO, FECHA_FIN, DURACION_REAL,
# MODO_INTERACCION, TIEMPO_INTERACCION, FRECUENCIA_INTERACCION, EQUIPO_ASIGNADO, TIPO_EQUIPO, MODO_EQUIPO, BATCH_ID, ESTADO
worksheet.set_column('A:A', 12) # ORDEN
worksheet.set_column('B:B', 14) # ESQUEMA
worksheet.set_column('C:C', 10) # MUESTRA
worksheet.set_column('D:D', 12) # PASO
worksheet.set_column('E:E', 25) # NOMBRE_PASO
worksheet.set_column('F:G', 18) # FECHA_INICIO, FECHA_FIN
worksheet.set_column('H:H', 14) # DURACION_REAL
worksheet.set_column('I:I', 16) # MODO_INTERACCION
worksheet.set_column('J:J', 18) # TIEMPO_INTERACCION
worksheet.set_column('K:K', 20) # FRECUENCIA_INTERACCION
worksheet.set_column('L:L', 16) # EQUIPO_ASIGNADO
worksheet.set_column('M:M', 14) # TIPO_EQUIPO
worksheet.set_column('N:N', 16) # MODO_EQUIPO
worksheet.set_column('O:O', 30) # BATCH_ID
worksheet.set_column('P:P', 14) # ESTADO
# Formato por fila
for row_num in range(len(df_export)):
# Obtener estado y modo
if 'ESTADO' in df_export.columns:
estado = df_export.iloc[row_num]['ESTADO']
else:
estado = 'PENDIENTE'
if 'MODO_INTERACCION' in df_export.columns:
modo = df_export.iloc[row_num]['MODO_INTERACCION']
else:
modo = 0
# Encontrar índices de columnas dinámicamente
idx_fecha_inicio = columnas_disponibles.index('FECHA_INICIO') if 'FECHA_INICIO' in columnas_disponibles else -1
idx_fecha_fin = columnas_disponibles.index('FECHA_FIN') if 'FECHA_FIN' in columnas_disponibles else -1
idx_modo = columnas_disponibles.index('MODO_INTERACCION') if 'MODO_INTERACCION' in columnas_disponibles else -1
idx_estado = columnas_disponibles.index('ESTADO') if 'ESTADO' in columnas_disponibles else -1
# Fechas
if idx_fecha_inicio >= 0:
worksheet.write(row_num + 6, idx_fecha_inicio, df_export.iloc[row_num]['FECHA_INICIO'], date_format)
if idx_fecha_fin >= 0:
worksheet.write(row_num + 6, idx_fecha_fin, df_export.iloc[row_num]['FECHA_FIN'], date_format)
# Modo de interacción con formato especial
if idx_modo >= 0:
modo_text = {
0: "🤖 Automático",
1: "▶️ Inicio",
2: "⏹️ Fin",
3: "⏯️ Inicio+Fin",
4: "🔄 Periódico",
5: "👤 Continuo"
}.get(modo, str(modo))
worksheet.write(row_num + 6, idx_modo, modo_text, modo_format)
# Estado con color
if idx_estado >= 0:
if estado == 'COMPLETADO':
worksheet.write(row_num + 6, idx_estado, estado, completado_format)
elif estado == 'EN_PROCESO':
worksheet.write(row_num + 6, idx_estado, estado, en_proceso_format)
else:
worksheet.write(row_num + 6, idx_estado, estado, pendiente_format)
# Congelar
worksheet.freeze_panes(6, 0)
print(f" 👥 Cronograma por Analista: {ruta} ({len(analistas)} analistas)")
return ruta
# ============================================================================
# NUEVAS FUNCIONES: GANTT VISUAL POR ANALISTA Y POR ORDEN
# ============================================================================
def calcular_bloques_analista(fecha_inicio, fecha_fin, modo_interaccion, tiempo_interaccion, frecuencia_interaccion):
"""
Calcula los bloques de tiempo donde el analista realmente trabaja.
Retorna lista de tuplas (inicio_bloque, fin_bloque)
"""
bloques = []
duracion_total = (fecha_fin - fecha_inicio).total_seconds() / 60 # en minutos
modo = int(modo_interaccion) if pd.notna(modo_interaccion) else 5
tiempo_int = int(tiempo_interaccion) if pd.notna(tiempo_interaccion) else 0
frecuencia = int(frecuencia_interaccion) if pd.notna(frecuencia_interaccion) else 1
if modo == 0: # Sin analista
return []
elif modo == 1: # Solo al inicio
bloques.append((fecha_inicio, fecha_inicio + timedelta(minutes=tiempo_int)))
elif modo == 2: # Solo al final
bloques.append((fecha_fin - timedelta(minutes=tiempo_int), fecha_fin))
elif modo == 3: # Inicio + Fin
bloques.append((fecha_inicio, fecha_inicio + timedelta(minutes=tiempo_int)))
bloques.append((fecha_fin - timedelta(minutes=tiempo_int), fecha_fin))
elif modo == 4: # Periódico (revisiones cada X minutos)
if frecuencia > 0:
intervalo = duracion_total / frecuencia
for i in range(frecuencia):
momento = fecha_inicio + timedelta(minutes=i * intervalo)
bloques.append((momento, momento + timedelta(minutes=tiempo_int)))
elif modo == 5: # Continuo (todo el tiempo)
bloques.append((fecha_inicio, fecha_fin))
return bloques
def exportar_gantt_por_analista(df_cronograma: pd.DataFrame, timestamp: str, output_dir: str = '.') -> str:
"""
Genera un archivo Excel con Gantt visual por analista.
Cada hoja = un analista, con columnas de 1 minuto cada una.
Características:
- Formato profesional con colores por esquema
- Fila superior: Fecha
- Fila segunda: Hora
- Cada celda = 1 minuto
- Texto en celda indica ID de tarea
- Ancho optimizado de columnas
- CONSIDERA MODO DE INTERACCIÓN: Solo pinta cuando el analista trabaja
"""
ruta = f"Gantt_por_Analista_{timestamp}.xlsx"
# Filtrar solo tareas con analista asignado
df_con_analista = df_cronograma[df_cronograma['ANALISTA_ASIGNADO'].notna()].copy()
if df_con_analista.empty:
print(f" ⚠️ No hay tareas con analistas asignados para Gantt")
return None
analistas = sorted(df_con_analista['ANALISTA_ASIGNADO'].unique())
# Paleta de colores profesionales (hasta 20 esquemas diferentes)
colores_esquemas = [
'#4472C4', '#ED7D31', '#A5A5A5', '#FFC000', '#5B9BD5',
'#70AD47', '#264478', '#9E480E', '#636363', '#997300',
'#255E91', '#43682B', '#698ED0', '#F1975A', '#B7B7B7',
'#FFCD33', '#7CAFDD', '#8CC168', '#325287', '#C55A11'
]
with pd.ExcelWriter(ruta, engine='xlsxwriter') as writer:
workbook = writer.book
# Formatos base
title_format = workbook.add_format({
'bold': True, 'font_size': 18, 'align': 'center', 'valign': 'vcenter',
'bg_color': '#2E75B6', 'font_color': 'white', 'border': 2
})
fecha_format = workbook.add_format({
'bold': True, 'font_size': 9, 'align': 'center', 'valign': 'vcenter',
'bg_color': '#4472C4', 'font_color': 'white', 'border': 1
})
hora_format = workbook.add_format({
'bold': True, 'font_size': 8, 'align': 'center', 'valign': 'vcenter',
'bg_color': '#5B9BD5', 'font_color': 'white', 'border': 1
})
tarea_label_format = workbook.add_format({
'bold': True, 'font_size': 9, 'align': 'left', 'valign': 'vcenter',
'bg_color': '#E7E6E6', 'border': 1, 'text_wrap': False
})
# Formatos para celdas de Gantt por esquema (con texto)
gantt_formats = {}
for idx, color in enumerate(colores_esquemas):
gantt_formats[idx] = workbook.add_format({
'bg_color': color,
'font_color': 'white',
'bold': True,
'font_size': 7,
'align': 'center',
'valign': 'vcenter',
'border': 1
})
vacio_format = workbook.add_format({
'bg_color': '#FFFFFF',
'border': 1
})
stats_format = workbook.add_format({
'bold': True, 'font_size': 10, 'align': 'left',
'bg_color': '#D9E2F3', 'border': 1
})
# Procesar cada analista
for analista_id in analistas:
df_analista = df_con_analista[
df_con_analista['ANALISTA_ASIGNADO'] == analista_id
].sort_values('FECHA_INICIO').copy()
# Obtener nombre del analista para el título de la hoja
analista_nombre = analista_id # Default
if 'ANALISTA_NOMBRE' in df_analista.columns and not df_analista.empty:
nombre_raw = df_analista['ANALISTA_NOMBRE'].iloc[0]
if pd.notna(nombre_raw) and str(nombre_raw).strip():
analista_nombre = str(nombre_raw).strip()
# Limpiar nombre para que sea válido como nombre de hoja
sheet_name = str(analista_nombre)[:31].replace('/', '-').replace('\\', '-').replace('*', '').replace('[', '').replace(']', '').replace(':', '').replace('?', '')
if not sheet_name:
sheet_name = str(analista_id)[:31]
if df_analista.empty:
continue
# Calcular rango temporal del analista
fecha_min = df_analista['FECHA_INICIO'].min()
fecha_max = df_analista['FECHA_FIN'].max()
# Generar lista de minutos desde fecha_min hasta fecha_max
minutos_totales = int((fecha_max - fecha_min).total_seconds() / 60) + 1
timeline = [fecha_min + timedelta(minutes=i) for i in range(minutos_totales)]
# Mapeo de orden+esquema a índices de color (cada combinación tiene un color único)
df_analista['ORDEN_ESQUEMA'] = df_analista['ORDEN'].astype(str) + '_' + df_analista['ESQUEMA'].astype(str)
orden_esquema_unicos = sorted(df_analista['ORDEN_ESQUEMA'].unique())
orden_esquema_to_color = {combo: idx % len(colores_esquemas) for idx, combo in enumerate(orden_esquema_unicos)}
# Crear hoja (usar sheet_name ya calculado arriba)
worksheet = workbook.add_worksheet(sheet_name)
# --- ENCABEZADO ---
# Título (fila 0-1) con NOMBRE del analista
titulo_display = f"{analista_nombre} (ID: {analista_id})" if analista_nombre != analista_id else analista_id
worksheet.merge_range(0, 0, 1, 5,
f'📊 GANTT - ANALISTA: {titulo_display}',
title_format)
# Estadísticas (fila 2)
total_tareas = len(df_analista)
duracion_total = (fecha_max - fecha_min).total_seconds() / 3600
worksheet.write(2, 0, f'Total Tareas: {total_tareas}', stats_format)
worksheet.write(2, 1, f'Duración: {duracion_total:.1f}h', stats_format)
worksheet.write(2, 2, f'Inicio: {fecha_min.strftime("%Y-%m-%d %H:%M")}', stats_format)
worksheet.write(2, 3, f'Fin: {fecha_max.strftime("%Y-%m-%d %H:%M")}', stats_format)
# --- GANTT ---
# Columnas fijas: TAREA, ORDEN, ESQUEMA, INICIO, FIN, DUR, MODO_INT, FREQ, EQUIPO, TIPO_EQ, MODO_EQ, BATCH_ID
col_fijas = ['TAREA', 'ORDEN', 'ESQUEMA', 'INICIO', 'FIN', 'DUR(min)', 'MODO_INT', 'FREQ', 'EQUIPO', 'TIPO_EQ', 'MODO_EQ', 'BATCH_ID']
num_cols_fijas = len(col_fijas)
# Fila 4: Encabezados de columnas fijas
for col_idx, col_name in enumerate(col_fijas):
worksheet.write(4, col_idx, col_name, tarea_label_format)
# Fila 4: FECHAS (agrupadas por día)
# Fila 5: HORAS:MINUTOS
fecha_actual = None
col_offset = num_cols_fijas
for min_idx, momento in enumerate(timeline):
col_idx = col_offset + min_idx
# Escribir FECHA solo cuando cambia
fecha_str = momento.strftime("%Y-%m-%d")
if fecha_str != fecha_actual:
worksheet.write(4, col_idx, fecha_str, fecha_format)
fecha_actual = fecha_str
else:
worksheet.write(4, col_idx, '', fecha_format)
# Escribir HORA:MIN en fila 5
hora_str = momento.strftime("%H:%M")
worksheet.write(5, col_idx, hora_str, hora_format)
# Ajustar anchos
worksheet.set_column(0, 0, 25) # TAREA
worksheet.set_column(1, 1, 12) # ORDEN
worksheet.set_column(2, 2, 12) # ESQUEMA
worksheet.set_column(3, 3, 14) # INICIO
worksheet.set_column(4, 4, 14) # FIN
worksheet.set_column(5, 5, 10) # DUR
worksheet.set_column(6, 6, 8) # MODO_INT
worksheet.set_column(7, 7, 8) # FREQ
worksheet.set_column(8, 8, 12) # EQUIPO
worksheet.set_column(9, 9, 12) # TIPO_EQ
worksheet.set_column(10, 10, 12) # MODO_EQ
worksheet.set_column(11, 11, 30) # BATCH_ID
worksheet.set_column(num_cols_fijas, col_offset + len(timeline) - 1, 2.5) # Columnas de minutos
# --- FILAS DE TAREAS ---
fila_actual = 6
for _, tarea in df_analista.iterrows():
tarea_id = f"{tarea['ORDEN']}-{tarea['ESQUEMA']}-M{tarea['MUESTRA']}-{tarea['PASO']}"
orden = str(tarea['ORDEN'])
esquema = str(tarea['ESQUEMA'])
inicio = tarea['FECHA_INICIO']
fin = tarea['FECHA_FIN']
duracion_min = int((fin - inicio).total_seconds() / 60)
# Columnas fijas
worksheet.write(fila_actual, 0, tarea_id, tarea_label_format)
worksheet.write(fila_actual, 1, orden, tarea_label_format)
worksheet.write(fila_actual, 2, esquema, tarea_label_format)
worksheet.write(fila_actual, 3, inicio.strftime("%Y-%m-%d %H:%M"), tarea_label_format)
worksheet.write(fila_actual, 4, fin.strftime("%Y-%m-%d %H:%M"), tarea_label_format)
# ✅ FIX: Convertir NaN a string vacío antes de escribir
def safe_value(val):
if pd.isna(val):
return ''
return val
worksheet.write(fila_actual, 5, duracion_min, tarea_label_format)
worksheet.write(fila_actual, 6, safe_value(tarea.get('MODO_INTERACCION', '')), tarea_label_format)
worksheet.write(fila_actual, 7, safe_value(tarea.get('FRECUENCIA_INTERACCION', '')), tarea_label_format)
worksheet.write(fila_actual, 8, safe_value(tarea.get('EQUIPO_ASIGNADO', '')), tarea_label_format)
worksheet.write(fila_actual, 9, safe_value(tarea.get('TIPO_EQUIPO', '')), tarea_label_format)
worksheet.write(fila_actual, 10, safe_value(tarea.get('MODO_EQUIPO', '')), tarea_label_format)
worksheet.write(fila_actual, 11, safe_value(tarea.get('BATCH_ID', '')), tarea_label_format)
# Calcular bloques donde el analista trabaja según modo de interacción
modo_int = tarea.get('MODO_INTERACCION', 5)
tiempo_int = tarea.get('TIEMPO_INTERACCION', 0)
freq_int = tarea.get('FRECUENCIA_INTERACCION', 1)
bloques_trabajo = calcular_bloques_analista(
inicio, fin, modo_int, tiempo_int, freq_int
)
# Pintar columnas de Gantt - Color por orden+esquema
orden_esquema_combo = f"{orden}_{esquema}"
color_idx = orden_esquema_to_color.get(orden_esquema_combo, 0)
gantt_format = gantt_formats[color_idx]
# Texto a mostrar en las celdas (abreviado para que quepa)
paso_corto = str(tarea['PASO'])[:4] # Primeros 4 caracteres
for min_idx, momento in enumerate(timeline):
col_idx = col_offset + min_idx
# Verificar si el momento está en algún bloque de trabajo
esta_trabajando = False
for bloque_inicio, bloque_fin in bloques_trabajo:
if bloque_inicio <= momento < bloque_fin:
esta_trabajando = True
break
if esta_trabajando:
# Mostrar texto solo en el primer minuto de cada hora o al inicio del bloque
if momento.minute == 0 or any(momento == bi for bi, _ in bloques_trabajo):
worksheet.write(fila_actual, col_idx, paso_corto, gantt_format)
else:
worksheet.write(fila_actual, col_idx, '', gantt_format)
else:
worksheet.write(fila_actual, col_idx, '', vacio_format)
fila_actual += 1
# Congelar paneles (congelar hasta columna 6 y fila 6)
worksheet.freeze_panes(6, num_cols_fijas)
print(f" 📊 Gantt por Analista: {ruta} ({len(analistas)} analistas)")
return ruta
def exportar_gantt_por_orden(df_cronograma: pd.DataFrame, timestamp: str, output_dir: str = '.') -> str:
"""
Genera un archivo Excel con Gantt visual por orden.
Cada hoja = una orden, con todas sus tareas (esquema-muestra-paso).
Características:
- Formato FLAT (Opción B): Una fila por tarea
- Colores por esquema
- Columnas de 1 minuto
- Formato profesional
"""
ruta = f"Gantt_por_Orden_{timestamp}.xlsx"
if df_cronograma.empty:
print(f" ⚠️ No hay tareas para Gantt por Orden")
return None
ordenes = sorted(df_cronograma['ORDEN'].unique())
# Paleta de colores profesionales
colores_esquemas = [
'#4472C4', '#ED7D31', '#A5A5A5', '#FFC000', '#5B9BD5',
'#70AD47', '#264478', '#9E480E', '#636363', '#997300',
'#255E91', '#43682B', '#698ED0', '#F1975A', '#B7B7B7',
'#FFCD33', '#7CAFDD', '#8CC168', '#325287', '#C55A11'
]
with pd.ExcelWriter(ruta, engine='xlsxwriter') as writer:
workbook = writer.book
# Formatos
title_format = workbook.add_format({
'bold': True, 'font_size': 18, 'align': 'center', 'valign': 'vcenter',
'bg_color': '#2E75B6', 'font_color': 'white', 'border': 2
})
fecha_format = workbook.add_format({
'bold': True, 'font_size': 9, 'align': 'center', 'valign': 'vcenter',
'bg_color': '#4472C4', 'font_color': 'white', 'border': 1
})
hora_format = workbook.add_format({
'bold': True, 'font_size': 8, 'align': 'center', 'valign': 'vcenter',
'bg_color': '#5B9BD5', 'font_color': 'white', 'border': 1
})
tarea_label_format = workbook.add_format({
'bold': True, 'font_size': 9, 'align': 'left', 'valign': 'vcenter',
'bg_color': '#E7E6E6', 'border': 1, 'text_wrap': False
})
gantt_formats = {}
for idx, color in enumerate(colores_esquemas):
gantt_formats[idx] = workbook.add_format({
'bg_color': color,
'font_color': 'white',
'bold': True,
'font_size': 7,
'align': 'center',
'valign': 'vcenter',
'border': 1
})
vacio_format = workbook.add_format({
'bg_color': '#FFFFFF',
'border': 1
})
stats_format = workbook.add_format({
'bold': True, 'font_size': 10, 'align': 'left',
'bg_color': '#D9E2F3', 'border': 1
})
# Procesar cada orden
for orden_id in ordenes:
df_orden = df_cronograma[
df_cronograma['ORDEN'] == orden_id
].sort_values(['ESQUEMA', 'MUESTRA', 'FECHA_INICIO']).copy()
if df_orden.empty:
continue
# Calcular rango temporal de la orden
fecha_min = df_orden['FECHA_INICIO'].min()
fecha_max = df_orden['FECHA_FIN'].max()
# Generar timeline
minutos_totales = int((fecha_max - fecha_min).total_seconds() / 60) + 1
timeline = [fecha_min + timedelta(minutes=i) for i in range(minutos_totales)]
# Mapeo de orden+esquema a colores (para consistencia con Gantt por Analista)
df_orden['ORDEN_ESQUEMA'] = df_orden['ORDEN'].astype(str) + '_' + df_orden['ESQUEMA'].astype(str)
orden_esquema_unicos = sorted(df_orden['ORDEN_ESQUEMA'].unique())
orden_esquema_to_color = {combo: idx % len(colores_esquemas) for idx, combo in enumerate(orden_esquema_unicos)}
# Crear hoja
sheet_name = str(orden_id)[:31]
worksheet = workbook.add_worksheet(sheet_name)
# --- ENCABEZADO ---
worksheet.merge_range(0, 0, 1, 7,
f'📦 GANTT - ORDEN: {orden_id}',
title_format)
# Estadísticas
total_tareas = len(df_orden)
total_esquemas = df_orden['ESQUEMA'].nunique()
duracion_total = (fecha_max - fecha_min).total_seconds() / 3600
worksheet.write(2, 0, f'Tareas: {total_tareas}', stats_format)
worksheet.write(2, 1, f'Esquemas: {total_esquemas}', stats_format)
worksheet.write(2, 2, f'Duración: {duracion_total:.1f}h', stats_format)
worksheet.write(2, 3, f'Inicio: {fecha_min.strftime("%Y-%m-%d %H:%M")}', stats_format)
worksheet.write(2, 4, f'Fin: {fecha_max.strftime("%Y-%m-%d %H:%M")}', stats_format)
# --- GANTT ---
col_fijas = ['ESQUEMA', 'MUESTRA', 'PASO', 'ANALISTA', 'INICIO', 'FIN', 'DUR(min)', 'MODO_INT', 'FREQ', 'EQUIPO', 'TIPO_EQ', 'MODO_EQ', 'BATCH_ID']
num_cols_fijas = len(col_fijas)
# Encabezados columnas fijas
for col_idx, col_name in enumerate(col_fijas):
worksheet.write(4, col_idx, col_name, tarea_label_format)
# Fechas y horas
fecha_actual = None
col_offset = num_cols_fijas
for min_idx, momento in enumerate(timeline):
col_idx = col_offset + min_idx
fecha_str = momento.strftime("%Y-%m-%d")
if fecha_str != fecha_actual:
worksheet.write(4, col_idx, fecha_str, fecha_format)
fecha_actual = fecha_str
else:
worksheet.write(4, col_idx, '', fecha_format)
hora_str = momento.strftime("%H:%M")
worksheet.write(5, col_idx, hora_str, hora_format)
# Anchos
worksheet.set_column(0, 0, 14) # ESQUEMA
worksheet.set_column(1, 1, 10) # MUESTRA
worksheet.set_column(2, 2, 12) # PASO
worksheet.set_column(3, 3, 14) # ANALISTA
worksheet.set_column(4, 4, 14) # INICIO
worksheet.set_column(5, 5, 14) # FIN
worksheet.set_column(6, 6, 10) # DUR
worksheet.set_column(7, 7, 8) # MODO_INT
worksheet.set_column(8, 8, 8) # FREQ
worksheet.set_column(9, 9, 12) # EQUIPO
worksheet.set_column(10, 10, 12) # TIPO_EQ
worksheet.set_column(11, 11, 12) # MODO_EQ
worksheet.set_column(12, 12, 30) # BATCH_ID
worksheet.set_column(num_cols_fijas, col_offset + len(timeline) - 1, 2.5)
# --- FILAS DE TAREAS ---
fila_actual = 6
for _, tarea in df_orden.iterrows():
esquema = str(tarea['ESQUEMA'])
muestra = f"M{tarea['MUESTRA']}"
paso = str(tarea['PASO'])
analista = str(tarea.get('ANALISTA_ASIGNADO', 'N/A'))
inicio = tarea['FECHA_INICIO']
fin = tarea['FECHA_FIN']
duracion_min = int((fin - inicio).total_seconds() / 60)
# Columnas fijas
worksheet.write(fila_actual, 0, esquema, tarea_label_format)
worksheet.write(fila_actual, 1, muestra, tarea_label_format)
worksheet.write(fila_actual, 2, paso, tarea_label_format)
worksheet.write(fila_actual, 3, analista, tarea_label_format)
# ✅ FIX: Convertir NaN a string vacío
def safe_value(val):
if pd.isna(val):
return ''
return val
worksheet.write(fila_actual, 4, inicio.strftime("%Y-%m-%d %H:%M"), tarea_label_format)
worksheet.write(fila_actual, 5, fin.strftime("%Y-%m-%d %H:%M"), tarea_label_format)
worksheet.write(fila_actual, 6, duracion_min, tarea_label_format)
worksheet.write(fila_actual, 7, safe_value(tarea.get('MODO_INTERACCION', '')), tarea_label_format)
worksheet.write(fila_actual, 8, safe_value(tarea.get('FRECUENCIA_INTERACCION', '')), tarea_label_format)
worksheet.write(fila_actual, 9, safe_value(tarea.get('EQUIPO_ASIGNADO', '')), tarea_label_format)
worksheet.write(fila_actual, 10, safe_value(tarea.get('TIPO_EQUIPO', '')), tarea_label_format)
worksheet.write(fila_actual, 11, safe_value(tarea.get('MODO_EQUIPO', '')), tarea_label_format)
worksheet.write(fila_actual, 12, safe_value(tarea.get('BATCH_ID', '')), tarea_label_format)
# Gantt - Color por orden+esquema
orden_actual = str(tarea['ORDEN'])
orden_esquema_combo = f"{orden_actual}_{esquema}"
color_idx = orden_esquema_to_color.get(orden_esquema_combo, 0)
gantt_format = gantt_formats[color_idx]
paso_corto = paso[:4]
for min_idx, momento in enumerate(timeline):
col_idx = col_offset + min_idx
if inicio <= momento < fin:
if momento == inicio or momento.minute == 0:
worksheet.write(fila_actual, col_idx, paso_corto, gantt_format)
else:
worksheet.write(fila_actual, col_idx, '', gantt_format)
else:
worksheet.write(fila_actual, col_idx, '', vacio_format)
fila_actual += 1
# Congelar
worksheet.freeze_panes(6, num_cols_fijas)
print(f" 📦 Gantt por Orden: {ruta} ({len(ordenes)} órdenes)")
return ruta
# ============================================================================
# FUNCIÓN PRINCIPAL DE EJECUCIÓN
# ============================================================================
def ejecutar_optimizacion_completa(
ruta_excel: str,
nombre_hojas: Optional[Dict[str, str]] = None,
config_ga: Optional[ConfiguracionGA] = None,
pesos_fitness: Optional[Dict[str, float]] = None,
exportar_resultados: bool = True,
ruta_salida: str = "resultados_ga",
fecha_inicio_sistema: Optional[datetime] = None,
ruta_plan_previo: Optional[str] = None # ✅ NUEVO: Ruta al plan previo (si existe)
) -> ResultadoGA:
"""Ejecuta el flujo completo de optimización"""
print("\n" + "█"*80)
print("█" + " "*15 + "SISTEMA DE OPTIMIZACIÓN DE LABORATORIO" + " "*24 + "█")
print("█"*80 + "\n")
# 1. CARGAR DATOS
print("🔹 FASE 1: CARGA DE DATOS")
print("─"*80)
sistema = crear_sistema_desde_excel(
ruta_excel,
fecha_inicio=fecha_inicio_sistema,
**(nombre_hojas or {})
)
# ✅ CARGAR PLAN PREVIO (si existe)
previous_plan = None
if ruta_plan_previo and os.path.exists(ruta_plan_previo):
print(f"\n📂 Cargando plan previo: {ruta_plan_previo}")
previous_plan = pd.read_excel(ruta_plan_previo)
previous_plan['FECHA_INICIO'] = pd.to_datetime(previous_plan['FECHA_INICIO'])
previous_plan['FECHA_FIN'] = pd.to_datetime(previous_plan['FECHA_FIN'])
print(f" • Tareas del plan previo: {len(previous_plan)}")
# 2. VALIDAR DATOS
print("\n🔹 FASE 2: VALIDACIÓN DE DATOS")
print("─"*80)
reporte = validar_sistema(sistema)
reporte.imprimir_reporte(mostrar_info=False)
if not reporte.es_valido:
print("\n❌ Los datos contienen errores críticos")
return None
# 3. EJECUTAR GA
print("\n🔹 FASE 3: OPTIMIZACIÓN GENÉTICA")
print("─"*80)
resultado_ga = algoritmo_genetico(sistema, config_ga, pesos_fitness, previous_plan)
# 4. GENERAR REPORTE
print("\n🔹 FASE 4: REPORTE")
print("─"*80)
reporte_texto = generar_reporte_completo(resultado_ga, sistema)
print(reporte_texto)
# 5. EXPORTAR
if exportar_resultados:
print("\n🔹 FASE 5: EXPORTACIÓN")
print("─"*80)
exportar_resultados_completos(resultado_ga, sistema, ruta_salida, previous_plan)
print("\n" + "█"*80)
print("█" + " "*20 + "✅ OPTIMIZACIÓN COMPLETADA" + " "*32 + "█")
print("█"*80 + "\n")
return resultado_ga
# ============================================================================
# EJEMPLO DE USO
# ============================================================================
def ejemplo_uso():
"""Ejemplo de uso del sistema completo"""
print("""
╔════════════════════════════════════════════════════════════════════════════╗
║ EJEMPLO DE USO - SISTEMA COMPLETO ║
╚════════════════════════════════════════════════════════════════════════════╝
# 1. CONFIGURACIÓN
config = ConfiguracionGA(
tamanio_poblacion=50,
generaciones=30,
tasa_cruce=0.8,
tasa_mutacion=0.2,
verbose=True, # Muestra resumen por generación
debug_interno=False # False = Sin debug interno (búsqueda analistas, etc.)
)
# 2. EJECUCIÓN COMPLETA
resultado = ejecutar_optimizacion_completa(
ruta_excel='datos_laboratorio.xlsx',
config_ga=config,
exportar_resultados=True,
ruta_salida='mi_optimizacion'
)
# 3. ACCEDER A RESULTADOS
print(f"Mejor fitness: {resultado.mejor_fitness}")
cronograma = resultado.mejor_cronograma
metricas = resultado.mejor_metricas
# 4. EXPORTAR
cronograma.to_excel('cronograma_final.xlsx', index=False)
╔════════════════════════════════════════════════════════════════════════════╗
║ ✅ SISTEMA COMPLETO LISTO ║
╚════════════════════════════════════════════════════════════════════════════╝
""")
# Cell 9:
def timestamp_peru():
"""Devuelve un timestamp con la hora local de Perú (UTC-5)."""
return datetime.now(ZoneInfo("America/Lima")).strftime("%Y-%m-%d_%H.%M")
# Cell 10:
# Main pipeline function
def run_pipeline(archivos=None, hora_actual: Optional[datetime] = None, turnos_config: dict = None):
"""
Ejecuta el pipeline del algoritmo genético para obtener la planificación.
Parámetros:
- archivos: lista de objetos (Gradio files o MockFile) o lista de strings (paths).
Si es None, se asume que los archivos están en el directorio actual de Colab.
- hora_actual: datetime.now() or specific datetime for the start of the planning.
If None, datetime.now() is used.
- turnos_config: dict con horarios personalizados para mañana/tarde/noche desde la interfaz.
"""
# Limpieza preventiva: asegurarse de que no haya residuos de ejecuciones previas
previous_plan = None
# Archivos requeridos
requeridos = ["Equipos.xlsx", "Pasos.xlsx", "Analistas.xlsx", "Ordenes.xlsx"]
if archivos is not None:
# Detectar si los elementos tienen .name (Gradio / MockFile) o son str
if hasattr(archivos[0], "name"):
# Caso: lista de objetos tipo File
mapping = {os.path.basename(f.name): f.name for f in archivos}
else:
# Caso: lista de strings
mapping = {os.path.basename(f): f for f in archivos}
else:
# Caso: se asume que los archivos ya están en el directorio actual (Colab)
mapping = {req: req for req in requeridos}
# Archivo opcional: Plan anterior
RUTA_PLAN_ANTERIOR = "previous_plan.xlsx"
# Solo agregar si fue incluido entre los archivos subidos
if archivos is not None:
nombres_subidos = [os.path.basename(f.name if hasattr(f, "name") else f) for f in archivos]
if RUTA_PLAN_ANTERIOR in nombres_subidos:
mapping[RUTA_PLAN_ANTERIOR] = next(
(f.name if hasattr(f, "name") else f for f in archivos if os.path.basename(f.name if hasattr(f, "name") else f) == RUTA_PLAN_ANTERIOR),
None
)
# Verificar que estén los 4 requeridos
for req in requeridos:
if req not in mapping:
raise ValueError(f"Falta el archivo requerido: {req}")
# Leemos los archivos requeridos para transformar y cargar
raw_ord_df = pd.read_excel(mapping["Ordenes.xlsx"])
raw_equipos_df = pd.read_excel(mapping["Equipos.xlsx"])
raw_pasos_df = pd.read_excel(mapping["Pasos.xlsx"])
raw_analistas_df = pd.read_excel(mapping["Analistas.xlsx"])
# Define output filename for transformed data
transformed_data_file = "datos_laboratorio.xlsx"
# Transformar y cargar datos a un archivo intermedio
transformar_y_cargar_datos(raw_ord_df, raw_equipos_df, raw_pasos_df, raw_analistas_df, out_xlsx=transformed_data_file, turnos_config=turnos_config)
# ============================================================================
# EJECUCIÓN CON REPROGRAMACIÓN AUTOMÁTICA
# ============================================================================
# ============================================================================
# CONFIGURACIÓN
# ============================================================================
# Use provided hora_actual or default to now if not provided
if hora_actual is None:
HORA_ACTUAL = datetime.now()
else:
HORA_ACTUAL = hora_actual
# ============================================================================
# 1. DETECTAR SI HAY PLAN ANTERIOR
# ============================================================================
if RUTA_PLAN_ANTERIOR in mapping and os.path.exists(mapping[RUTA_PLAN_ANTERIOR]):
print(f"\n📂 Detectado plan anterior: {RUTA_PLAN_ANTERIOR}")
# Cargar y filtrar automáticamente
df_plan_completo = pd.read_excel(mapping[RUTA_PLAN_ANTERIOR])
df_plan_completo['FECHA_INICIO'] = pd.to_datetime(df_plan_completo['FECHA_INICIO'])
df_plan_completo['FECHA_FIN'] = pd.to_datetime(df_plan_completo['FECHA_FIN'])
# ⭐ FILTRAR: Solo tareas que YA empezaron o están completas
# ✅ FIX: El estado real está en la columna BATCH_ID (con colores en Excel)
columna_estado_real = None
# Buscar la columna correcta que contiene los estados
if 'BATCH_ID' in df_plan_completo.columns:
# Verificar si BATCH_ID tiene estados válidos
batch_valores = df_plan_completo['BATCH_ID'].dropna().unique()
if any(val in ['COMPLETADO', 'EN_PROCESO'] for val in batch_valores):
columna_estado_real = 'BATCH_ID'
print(f" 🔍 Usando columna BATCH_ID para estados (tiene valores COMPLETADO/EN_PROCESO)")
if not columna_estado_real and 'ESTADO' in df_plan_completo.columns:
# Verificar si ESTADO tiene estados válidos
estado_valores = df_plan_completo['ESTADO'].dropna().unique()
if any(val in ['COMPLETADO', 'EN_PROCESO'] for val in estado_valores):
columna_estado_real = 'ESTADO'
print(f" 🔍 Usando columna ESTADO para estados")
if columna_estado_real:
# 🔍 DEBUG: Ver qué valores únicos tiene la columna
estados_unicos = df_plan_completo[columna_estado_real].dropna().unique()
print(f" 🔍 DEBUG: Estados únicos en {columna_estado_real}: {list(estados_unicos)}")
# Filtrar por estado COMPLETADO o EN_PROCESO
previous_plan = df_plan_completo[
df_plan_completo[columna_estado_real].isin(['COMPLETADO', 'EN_PROCESO'])
].copy()
print(f" ✅ Filtrando por {columna_estado_real} (ignora pasos no iniciados)")
else:
# Si no hay columna de estado válida, usar FECHA_INICIO
print(f" ⚠️ No se encontró columna con estados COMPLETADO/EN_PROCESO")
print(f" 🔄 Filtrando por FECHA_INICIO (tareas que ya empezaron antes de {HORA_ACTUAL})")
previous_plan = df_plan_completo[
df_plan_completo['FECHA_INICIO'] <= HORA_ACTUAL
].copy()
print(f" • Tareas del plan anterior: {len(df_plan_completo)}")
print(f" • Tareas que ya empezaron: {len(previous_plan)}")
print(f" • Tareas a reprogramar: {len(df_plan_completo) - len(previous_plan)}")
else:
print(f"\n📂 No se encontró plan anterior → Primera planificación del día")
# ============================================================================
# 2. CARGAR SISTEMA (EXCLUYENDO PASOS YA COMPLETADOS/EN_PROCESO)
# ============================================================================
print(f"\n📂 Cargando sistema desde {transformed_data_file}...")
# ✅ SOLUCIÓN SIMPLE: Cargar el sistema COMPLETO sin filtrar
# El previous_plan ya se cargó en el estado (línea 2347-2400) y automáticamente:
# 1. Bloquea recursos (analistas y equipos) para las tareas completadas/en proceso
# 2. Marca pasos como completados en estado.pasos_completados
# 3. El scheduler salta tareas completadas (línea 2554-2555: if estado.paso_completado(...): continue)
# Resultado: Solo programa lo que falta, respetando recursos bloqueados
sistema = crear_sistema_desde_excel(transformed_data_file, fecha_inicio=HORA_ACTUAL)
if False: # ← Deshabilitar el código de filtrado incorrecto
# TODO: Eliminar este bloque completo en futuras versiones
previous_plan_disabled = previous_plan # Mantener referencia
print(f" 🔍 Filtrando pasos ya completados/en proceso del transformed_data...")
# Crear identificadores únicos para los pasos del previous_plan
previous_plan['PASO_ID'] = (
previous_plan['ORDEN'].astype(str) + '_' +
previous_plan['ESQUEMA'].astype(str) + '_' +
previous_plan['PASO'].astype(str) + '_M' +
previous_plan['MUESTRA'].astype(str)
)
pasos_a_excluir = set(previous_plan['PASO_ID'].tolist())
# 🔍 DEBUG: Ver qué hojas tiene el archivo transformed_data
try:
xls = pd.ExcelFile(transformed_data_file)
print(f" 🔍 DEBUG: Hojas en {transformed_data_file}: {xls.sheet_names}")
except Exception as e:
print(f" ⚠️ ERROR al leer {transformed_data_file}: {e}")
print(f" ⚠️ Verificar que el archivo existe: {os.path.exists(transformed_data_file)}")
# Cargar transformed_data como DataFrame
# ✅ FIX: Intentar con diferentes nombres de hoja (PASOS, Pasos, pasos)
sheet_name_pasos = None
df_transformed = None
for possible_name in ['PASOS', 'Pasos', 'pasos']:
try:
df_transformed = pd.read_excel(transformed_data_file, sheet_name=possible_name)
sheet_name_pasos = possible_name
print(f" ✅ Hoja encontrada: '{possible_name}'")
break
except:
continue
if df_transformed is None or sheet_name_pasos is None:
xls = pd.ExcelFile(transformed_data_file)
raise ValueError(f"No se encontró hoja de pasos en {transformed_data_file}. Hojas disponibles: {xls.sheet_names}")
# 🔍 DEBUG: Mostrar nombres de columnas
print(f" 🔍 DEBUG: Primeras columnas en transformed_data: {list(df_transformed.columns[:10])}")
# Normalizar nombres de columnas (pueden estar en mayúsculas o con guiones)
col_orden = next((c for c in df_transformed.columns if c.upper() in ['ORDEN_ID', 'ORDEN']), 'orden_id')
col_esquema = next((c for c in df_transformed.columns if c.upper() in ['ESQUEMA_ID', 'ESQUEMA']), 'esquema_id')
col_paso = next((c for c in df_transformed.columns if c.upper() in ['PASO_ID', 'PASO']), 'paso_id')
col_muestra = next((c for c in df_transformed.columns if c.upper() in ['MUESTRA_ID', 'MUESTRA']), 'muestra_id')
print(f" 🔍 DEBUG: Usando columnas: {col_orden}, {col_esquema}, {col_paso}, {col_muestra}")
# Crear identificadores únicos para los pasos del transformed_data
df_transformed['PASO_ID_UNICO'] = (
df_transformed[col_orden].astype(str) + '_' +
df_transformed[col_esquema].astype(str) + '_' +
df_transformed[col_paso].astype(str) + '_M' +
df_transformed[col_muestra].astype(str)
)
# 🔍 DEBUG: Mostrar algunos IDs generados
print(f" 🔍 DEBUG: Primeros IDs generados: {list(df_transformed['PASO_ID_UNICO'].head(3))}")
print(f" 🔍 DEBUG: Primeros IDs a excluir: {list(pasos_a_excluir)[:3]}")
# Filtrar: EXCLUIR los pasos que están en previous_plan
df_filtered = df_transformed[~df_transformed['PASO_ID_UNICO'].isin(pasos_a_excluir)].copy()
df_filtered.drop(columns=['PASO_ID_UNICO'], inplace=True)
print(f" • Pasos totales en transformed_data: {len(df_transformed)}")
print(f" • Pasos ya completados/en proceso: {len(df_transformed) - len(df_filtered)}")
print(f" • Pasos a programar (pendientes): {len(df_filtered)}")
# Guardar temporalmente el DataFrame filtrado
with tempfile.NamedTemporaryFile(mode='wb', suffix='.xlsx', delete=False) as tmp_file:
tmp_path = tmp_file.name
with pd.ExcelWriter(tmp_path, engine='openpyxl') as writer:
df_filtered.to_excel(writer, sheet_name='Pasos', index=False)
# Copiar las otras hojas también
for sheet_name in ['Ordenes', 'Esquemas', 'Analistas', 'Equipos']:
try:
df_sheet = pd.read_excel(transformed_data_file, sheet_name=sheet_name)
df_sheet.to_excel(writer, sheet_name=sheet_name, index=False)
except:
pass # Sheet doesn't exist
sistema = crear_sistema_desde_excel(tmp_path, fecha_inicio=HORA_ACTUAL)
# Limpiar archivo temporal
os.unlink(tmp_path)
else:
sistema = crear_sistema_desde_excel(transformed_data_file, fecha_inicio=HORA_ACTUAL)
# ============================================================================
# 3. VALIDAR
# ============================================================================
print(f"\n✅ Validando datos...")
reporte = validar_sistema(sistema)
reporte.imprimir_reporte(mostrar_info=False)
if not reporte.es_valido:
raise ValueError("❌ Datos inválidos - corrige los errores")
# ============================================================================
# 4. OPTIMIZAR CON ALGORITMO GENÉTICO
# ============================================================================
print(f"\n🧬 Ejecutando algoritmo genético...")
# Default GA configuration
config = ConfiguracionGA(
tamanio_poblacion=20,
generaciones=30,
tasa_cruce=0.6,
tasa_mutacion=0.2,
verbose=True
)
resultado = algoritmo_genetico(sistema, config, previous_plan=previous_plan)
# ============================================================================
# 5. GENERAR CRONOGRAMA CON/SIN PLAN ANTERIOR
# ============================================================================
print(f"\n📅 Generando cronograma...")
df_cronograma_nuevo = scheduler_builder(
sistema=sistema,
cromosoma=resultado.mejor_cromosoma.to_sequence(),
previous_plan=previous_plan, # ⭐ None si no hay, DataFrame si hay
fecha_inicio=HORA_ACTUAL,
verbose=False # ✅ Desactivar prints detallados
)
# ============================================================================
# 6. COMBINAR (si hay plan anterior)
# ============================================================================
if previous_plan is not None:
print(f"\n🔗 Combinando plan anterior + nuevo cronograma...")
df_final = pd.concat([previous_plan, df_cronograma_nuevo], ignore_index=True)
df_final = df_final.sort_values('FECHA_INICIO').reset_index(drop=True)
print(f" • Tareas del plan anterior: {len(previous_plan)}")
print(f" • Tareas nuevas programadas: {len(df_cronograma_nuevo)}")
print(f" • Total: {len(df_final)}")
else:
df_final = df_cronograma_nuevo
print(f" • Tareas programadas: {len(df_final)}")
# ============================================================================
# 7. EXPORTAR
# ============================================================================
# Timestamp local (hora de Perú)
ts = timestamp_peru()
# ============================================================================
# 8. ✨ EXPORTAR REPORTES FINALES
# ============================================================================
print(f"\n{'='*80}")
print("📊 GENERANDO 6 REPORTES PROFESIONALES")
print("="*80)
# Output 1: Detalle de Ejecución (previous_plan siguiente corrida)
exportar_detalle_ejecucion_limpio(df_final, ts)
# Output 2: Utilización (2 hojas: Equipo + Analista con gráficos)
exportar_utilizacion(df_final, sistema, ts)
# Output 3: Métricas (2 hojas: Por Orden + Por Orden-Esquema con gráficos)
exportar_metricas(df_final, sistema, ts)
# Output 4: Cronograma por Analista (1 hoja por analista)
exportar_cronograma_por_analista_optimizado(df_final, ts)
# Output 5: Gantt por Analista (visualización timeline)
exportar_gantt_por_analista(df_final, ts, '.')
# Output 6: Gantt por Orden (visualización timeline)
exportar_gantt_por_orden(df_final, ts, '.')
print(f"\n✅ Todos los reportes generados exitosamente")
print(f"✅ Fitness final: {resultado.mejor_fitness:.2f}")
print(f"{'='*80}\n")
print("\n" + "="*80)
print("✅ OPTIMIZACIÓN COMPLETADA")
print("="*80)
return f"Detalle_Ejecución_{ts}.xlsx", f"Utilización_{ts}.xlsx", f"Métricas_{ts}.xlsx"
# ============================================================================
# FUNCIÓN PARA INTERFAZ GRADIO
# ============================================================================
def run_pipeline_custom(
hora_actual: datetime,
tamano_poblacion: int = 50,
num_generaciones: int = 100,
tasa_mutacion: float = 0.1,
tasa_crossover: float = 0.7,
pesos: dict = None,
path_ordenes: str = None,
path_esquemas: str = None,
path_pasos: str = None,
path_dependencias: str = None,
path_analistas: str = None,
path_equipos: str = None,
path_previous: str = None,
output_dir: str = "."
):
"""
Función personalizada para ejecutar desde Gradio
Retorna un diccionario con métricas y rutas de archivos generados
"""
import os
os.chdir(output_dir)
# Preparar archivos
archivos = {
'ordenes': path_ordenes,
'esquemas': path_esquemas,
'pasos': path_pasos,
'dependencias': path_dependencias,
'analistas': path_analistas,
'equipos': path_equipos
}
# Cargar datos
print("\n" + "="*80)
print("📂 CARGANDO DATOS")
print("="*80)
df_ordenes = pd.read_excel(archivos['ordenes'])
df_esquemas = pd.read_excel(archivos['esquemas'])
df_pasos = pd.read_excel(archivos['pasos'])
df_dependencias = pd.read_excel(archivos['dependencias'])
df_analistas = pd.read_excel(archivos['analistas'])
df_equipos = pd.read_excel(archivos['equipos'])
previous_plan = None
if path_previous and os.path.exists(path_previous):
previous_plan = pd.read_excel(path_previous)
# Detectar y saltar headers si existen
if 'IMPORTANTE' in str(previous_plan.columns[0]).upper() or 'UNNAMED' in str(previous_plan.columns[0]).upper():
for skip_rows in [2, 3, 4, 1]:
try:
previous_plan = pd.read_excel(path_previous, header=skip_rows)
valid_cols = [col for col in previous_plan.columns if 'UNNAMED' not in str(col).upper()]
has_estado = any('ESTADO' in str(col).upper() for col in previous_plan.columns)
if has_estado and len(valid_cols) > 5:
break
except:
continue
print(f"📋 Plan previo cargado: {len(previous_plan)} tareas")
# Construir sistema
print("\n🔨 Construyendo sistema...")
sistema = construir_sistema_completo(
df_ordenes, df_esquemas, df_pasos, df_dependencias,
df_analistas, df_equipos
)
# Validar
print(f"\n✅ Validando datos...")
reporte = validar_sistema(sistema)
reporte.imprimir_reporte(mostrar_info=False)
if not reporte.es_valido:
raise ValueError("❌ Datos inválidos - corrige los errores")
# Optimizar
print(f"\n🧬 Ejecutando algoritmo genético...")
resultado = ejecutar_algoritmo_genetico_mejorado(
sistema=sistema,
tamano_poblacion=tamano_poblacion,
num_generaciones=num_generaciones,
tasa_mutacion=tasa_mutacion,
tasa_crossover=tasa_crossover,
pesos=pesos,
previous_plan=previous_plan,
hora_actual=hora_actual,
graficar=False,
usar_horizonte_dinamico=False
)
# Preparar cronograma final
if previous_plan is not None and not previous_plan.empty:
df_cronograma_nuevo = resultado.mejor_cronograma
df_final = pd.concat([previous_plan, df_cronograma_nuevo], ignore_index=True)
else:
df_cronograma_nuevo = resultado.mejor_cronograma
df_final = df_cronograma_nuevo
# Timestamp
ts = timestamp_peru()
# Exportar reportes
print(f"\n{'='*80}")
print("📊 GENERANDO 6 REPORTES PROFESIONALES")
print("="*80)
exportar_detalle_ejecucion_limpio(df_final, ts)
exportar_utilizacion(df_final, sistema, ts)
exportar_metricas(df_final, sistema, ts)
exportar_cronograma_por_analista_optimizado(df_final, ts)
exportar_gantt_por_analista(df_final, ts, output_dir)
exportar_gantt_por_orden(df_final, ts, output_dir)
# Calcular métricas para retornar
metricas = resultado.metricas_globales
archivos_generados = [
os.path.join(output_dir, f"Detalle_Ejecución_{ts}.xlsx"),
os.path.join(output_dir, f"Utilización_{ts}.xlsx"),
os.path.join(output_dir, f"Métricas_{ts}.xlsx"),
os.path.join(output_dir, f"Cronograma_por_Analista_{ts}.xlsx"),
os.path.join(output_dir, f"Gantt_por_Analista_{ts}.xlsx"),
os.path.join(output_dir, f"Gantt_por_Orden_{ts}.xlsx")
]
return {
'fitness': resultado.mejor_fitness,
'makespan_horas': metricas.makespan_horas,
'total_tareas': len(df_final),
'tareas_programadas': len(df_final),
'total_ordenes': metricas.total_ordenes,
'ordenes_cumplidas': metricas.ordenes_cumplidas,
'ordenes_incumplidas': metricas.total_ordenes - metricas.ordenes_cumplidas,
'tasa_cumplimiento_ordenes': (metricas.ordenes_cumplidas / metricas.total_ordenes * 100) if metricas.total_ordenes > 0 else 0,
'total_esquemas': metricas.total_esquemas,
'esquemas_cumplidos': metricas.esquemas_cumplidos,
'esquemas_incumplidos': metricas.total_esquemas - metricas.esquemas_cumplidos,
'tasa_cumplimiento_esquemas': (metricas.esquemas_cumplidos / metricas.total_esquemas * 100) if metricas.total_esquemas > 0 else 0,
'tardanza_total_ordenes': metricas.tardanza_total_ordenes_horas,
'tardanza_total_esquemas': metricas.tardanza_total_esquemas_horas,
'tardanza_maxima': metricas.tardanza_maxima_horas,
'tat_real_promedio_ordenes': metricas.tat_real_total_ordenes_horas / metricas.total_ordenes if metricas.total_ordenes > 0 else 0,
'tat_real_promedio_esquemas': metricas.tat_real_total_esquemas_horas / metricas.total_esquemas if metricas.total_esquemas > 0 else 0,
'fecha_inicio': df_final['FECHA_INICIO'].min().strftime("%Y-%m-%d %H:%M"),
'fecha_fin': df_final['FECHA_FIN'].max().strftime("%Y-%m-%d %H:%M"),
'archivos_generados': archivos_generados
}
# ============================================================================
# INTERFAZ GRADIO COMPLETA - TODO EN UN SOLO ARCHIVO
# ============================================================================
import gradio as gr
import tempfile
import zipfile
import shutil
# Variable global para control de ejecución
stop_optimization = False
# ============================================================================
# FUNCIONES PARA GENERAR PLANTILLAS INDIVIDUALES
# ============================================================================
def crear_plantilla_ordenes():
"""Genera plantilla de Ordenes.xlsx con columnas obligatorias"""
temp_dir = tempfile.mkdtemp()
df = pd.DataFrame({
'Orden': ['ORD001', 'ORD002', 'ORD003'],
'Esquema': ['ESQ_A', 'ESQ_B', 'ESQ_C'],
'Determinacion': ['Análisis A', 'Análisis B', 'Análisis C'],
'Cant.': [1, 1, 1],
'TAT': [2, 3, 1],
'URGENCIA_ESQUEMA': ['ALTA', 'MEDIA', 'BAJA'],
'PRIORIDAD_ORDEN': [3, 2, 1],
'Entregado x PMO': ['2026-02-01 08:00:00', '2026-02-01 09:00:00', '2026-02-01 10:00:00']
})
path = os.path.join(temp_dir, 'Ordenes_plantilla.xlsx')
df.to_excel(path, index=False)
return path
def crear_plantilla_pasos():
"""Genera plantilla de Pasos.xlsx con columnas obligatorias
MODO_INTERACCION valores numéricos:
0 = Sin analista
1 = Analista solo al INICIO (tiempo_interaccion minutos)
2 = Analista solo al FINAL (tiempo_interaccion minutos)
3 = Analista al INICIO y FINAL (tiempo_interaccion cada vez)
4 = Analista PERIÓDICO (frecuencia_interaccion = NÚMERO de revisiones)
5 = Analista CONTINUO (todo el tiempo) [legacy]
FRECUENCIA_INTERACCION (solo para MODO 4):
- Es el NÚMERO de revisiones/bloques, NO minutos
- Ejemplo: duracion=60min, frecuencia=3 → revisiones en min 0, 30, 60
"""
temp_dir = tempfile.mkdtemp()
df = pd.DataFrame({
'ESQUEMA_ID': ['ESQ_A', 'ESQ_A', 'ESQ_B', 'ESQ_B', 'ESQ_C', 'ESQ_C'],
'SECUENCIA': [1, 2, 1, 2, 1, 2],
'NOMBRE': ['Preparación', 'Análisis', 'Cultivo', 'Medición', 'Extracción', 'Detección'],
'TIPO_EQUIPO': ['TIPO_A', 'TIPO_B', 'TIPO_A', 'TIPO_C', 'TIPO_B', 'TIPO_A'],
'UNIFORMIDAD': [1, 1, 0, 1, 0, 1], # 1=UNIFORME, 0=NO_UNIFORME
'DURACION': [30, 45, 120, 40, 20, 35],
'TIEMPO_MIN': [25, 40, 100, 35, 15, 30],
'TIEMPO_MAX': [35, 50, 140, 45, 25, 40],
'MODO_INTERACCION': [5, 1, 4, 2, 3, 5], # 5=continuo, 1=inicio, 4=periódico, 2=fin, 3=inicio+fin
'TIEMPO_INTERACCION': [5, 5, 10, 5, 10, 5], # Duración de cada revisión (min)
'FRECUENCIA_INTERACCION': [1, 1, 4, 1, 2, 1] # ✅ Para modo 4: NÚMERO de revisiones (4 revisiones en 120min)
})
path = os.path.join(temp_dir, 'Pasos_plantilla.xlsx')
df.to_excel(path, index=False)
return path
def crear_plantilla_analistas():
"""Genera plantilla de Analistas.xlsx con columnas obligatorias"""
temp_dir = tempfile.mkdtemp()
df = pd.DataFrame({
'NOMBRE': ['Juan Pérez', 'María García', 'Carlos López'],
'HABILIDADES': ['ESQ_A,ESQ_B', 'ESQ_B,ESQ_C', 'ESQ_A,ESQ_C'],
'TURNO': ['mañana', 'tarde', 'noche'],
'ACTIVO': [1, 1, 1]
})
path = os.path.join(temp_dir, 'Analistas_plantilla.xlsx')
df.to_excel(path, index=False)
return path
def crear_plantilla_equipos():
"""Genera plantilla de Equipos.xlsx con columnas obligatorias
IMPORTANTE: ID es obligatorio - debe ser el ID real del equipo en el laboratorio
"""
temp_dir = tempfile.mkdtemp()
df = pd.DataFrame({
'ID': ['EQ001', 'EQ002', 'EQ003'],
'TIPO': ['TIPO_A', 'TIPO_B', 'TIPO_C'],
'MODO': ['BATCH', 'BATCH', 'ROLLING'],
'CAPACIDAD': [1, 2, 1],
'ACTIVO': [1, 1, 1],
'TIEMPO_SETUP': [0, 5, 0]
})
path = os.path.join(temp_dir, 'Equipos_plantilla.xlsx')
df.to_excel(path, index=False)
return path
def crear_plantillas_ejemplo():
"""Genera ZIP con los 4 archivos de plantilla"""
temp_dir = tempfile.mkdtemp()
archivos = []
# Crear cada plantilla
for crear_func, nombre in [
(crear_plantilla_ordenes, 'Ordenes_ejemplo.xlsx'),
(crear_plantilla_pasos, 'Pasos_ejemplo.xlsx'),
(crear_plantilla_analistas, 'Analistas_ejemplo.xlsx'),
(crear_plantilla_equipos, 'Equipos_ejemplo.xlsx')
]:
path_temp = crear_func()
path_final = os.path.join(temp_dir, nombre)
shutil.copy(path_temp, path_final)
archivos.append(path_final)
# Crear ZIP
zip_path = os.path.join(temp_dir, 'plantillas_ejemplo.zip')
with zipfile.ZipFile(zip_path, 'w') as zipf:
for archivo in archivos:
zipf.write(archivo, os.path.basename(archivo))
return zip_path
def ejecutar_optimizacion(
# Parámetros GA
tamano_poblacion, num_generaciones, tasa_mutacion, tasa_crossover,
# Hora actual
hora_actual_str,
# Pesos
peso_incump_esq, peso_incump_ord, peso_tard_esq, peso_tard_ord,
peso_tat_esq, peso_tat_ord, peso_makespan, peso_tard_max,
# Archivos (solo 4 obligatorios)
file_ordenes, file_pasos, file_analistas, file_equipos, file_previous,
# Configuración de turnos
manana_ini, manana_fin, tarde_ini, tarde_fin, noche_ini, noche_fin
):
"""Ejecuta la optimización con los parámetros dados"""
global stop_optimization
stop_optimization = False
try:
# Validar archivos obligatorios (solo 4)
if not all([file_ordenes, file_pasos, file_analistas, file_equipos]):
return ("ERROR: Faltan archivos obligatorios. Sube: ORDENES, PASOS, ANALISTAS y EQUIPOS.",
None, None, None, None, None, None, None,
"<div style='padding: 2rem; text-align: center; color: #ef4444;'>⚠️ Falta cargar archivos</div>",
"<div style='padding: 2rem; text-align: center; color: #ef4444;'>⚠️ Falta cargar archivos</div>",
gr.update(choices=["Todos"], value="Todos"))
# Parsear hora actual
if hora_actual_str and hora_actual_str.strip():
try:
hora_actual = datetime.strptime(hora_actual_str.strip(), "%Y-%m-%d %H:%M")
except:
return ("ERROR: Formato de fecha incorrecto. Use: YYYY-MM-DD HH:MM",
None, None, None, None, None, None, None,
"<div style='padding: 2rem; text-align: center; color: #ef4444;'>⚠️ Error en formato de fecha</div>",
"<div style='padding: 2rem; text-align: center; color: #ef4444;'>⚠️ Error en formato de fecha</div>",
gr.update(choices=["Todos"], value="Todos"))
else:
hora_actual = datetime.now()
# Crear diccionario de pesos
pesos = {
'incumplimiento_esquemas': float(peso_incump_esq),
'incumplimiento_ordenes': float(peso_incump_ord),
'tardanza_total_esquemas': float(peso_tard_esq),
'tardanza_total_ordenes': float(peso_tard_ord),
'tat_real_total_esquemas': float(peso_tat_esq),
'tat_real_total_ordenes': float(peso_tat_ord),
'makespan': float(peso_makespan),
'tardanza_maxima': float(peso_tard_max)
}
# Guardar archivos temporalmente
temp_dir = tempfile.mkdtemp()
# ✅ FIX: Helper function para obtener ruta del archivo (Gradio puede devolver string o objeto)
def get_file_path(file_obj):
if file_obj is None:
return None
if isinstance(file_obj, str):
return file_obj
if hasattr(file_obj, 'name'):
return file_obj.name
return str(file_obj)
# Copiar los 4 archivos CRUDOS (los nombres que espera run_pipeline)
path_ordenes = os.path.join(temp_dir, 'Ordenes.xlsx')
path_pasos = os.path.join(temp_dir, 'Pasos.xlsx')
path_analistas = os.path.join(temp_dir, 'Analistas.xlsx')
path_equipos = os.path.join(temp_dir, 'Equipos.xlsx')
shutil.copy(get_file_path(file_ordenes), path_ordenes)
shutil.copy(get_file_path(file_pasos), path_pasos)
shutil.copy(get_file_path(file_analistas), path_analistas)
shutil.copy(get_file_path(file_equipos), path_equipos)
# Preparar lista de archivos para run_pipeline
archivos = [path_ordenes, path_pasos, path_analistas, path_equipos]
# Agregar plan previo si existe
if file_previous:
path_previous = os.path.join(temp_dir, 'previous_plan.xlsx')
shutil.copy(get_file_path(file_previous), path_previous)
archivos.append(path_previous)
# Cambiar directorio de trabajo
original_dir = os.getcwd()
os.chdir(temp_dir)
try:
# Crear diccionario de configuración de turnos
turnos_config = {
'manana_ini': manana_ini,
'manana_fin': manana_fin,
'tarde_ini': tarde_ini,
'tarde_fin': tarde_fin,
'noche_ini': noche_ini,
'noche_fin': noche_fin
}
# Ejecutar pipeline completo (con ETL interno)
resultado = run_pipeline(
archivos=archivos,
hora_actual=hora_actual,
turnos_config=turnos_config
)
finally:
# Restaurar directorio original
os.chdir(original_dir)
# run_pipeline retorna 3 nombres de archivos (strings)
# Buscar SOLO los 4 archivos de salida finales
archivos_generados = []
for file in os.listdir(temp_dir):
if file.endswith('.xlsx') and not file.startswith('datos_laboratorio'):
archivos_generados.append(os.path.join(temp_dir, file))
# Identificar los 6 archivos específicos
archivos_finales = {}
print(f"\n🔍 DEBUG: Archivos generados encontrados: {len(archivos_generados)}")
for file in archivos_generados:
basename = os.path.basename(file)
print(f" 📄 {basename}")
if 'Detalle_Ejecución' in basename or 'Detalle_Ejecucion' in basename:
archivos_finales['detalle'] = file
print(f" ✅ Identificado como: detalle")
elif 'Utilización' in basename or 'Utilizacion' in basename:
archivos_finales['utilizacion'] = file
print(f" ✅ Identificado como: utilizacion")
elif 'Métricas' in basename or 'Metricas' in basename:
archivos_finales['metricas'] = file
print(f" ✅ Identificado como: metricas")
elif 'Cronograma_por_Analista' in basename:
archivos_finales['cronograma'] = file
print(f" ✅ Identificado como: cronograma")
elif 'Gantt_por_Analista' in basename:
archivos_finales['gantt_analista'] = file
print(f" ✅ Identificado como: gantt_analista")
elif 'Gantt_por_Orden' in basename:
archivos_finales['gantt_orden'] = file
print(f" ✅ Identificado como: gantt_orden")
print(f"\n📋 Archivos finales identificados: {list(archivos_finales.keys())}")
# Leer métricas del archivo Métricas para mostrar estadísticas
metricas_file = archivos_finales.get('metricas')
makespan_horas = 0
tardanza_total_ordenes = 0
tardanza_total_esquemas = 0
tardanza_maxima = 0
tat_promedio_ordenes = 0
tat_promedio_esquemas = 0
total_ordenes = 0
ordenes_cumplidas = 0
total_esquemas = 0
esquemas_cumplidos = 0
print(f"\n📊 Leyendo métricas de: {metricas_file}")
print(f" Archivo existe: {os.path.exists(metricas_file) if metricas_file else 'N/A'}")
if metricas_file and os.path.exists(metricas_file):
try:
print(f" 📖 Intentando leer sheets del archivo...")
# ✅ FIX: skiprows=2 porque datos empiezan en fila 3 (título en fila 1)
df_ordenes = pd.read_excel(metricas_file, sheet_name='POR_ORDEN', skiprows=2)
df_esquemas = pd.read_excel(metricas_file, sheet_name='POR_ORDEN_ESQUEMA', skiprows=2)
print(f" ✅ Sheets leídos correctamente")
print(f" 📦 Órdenes: {len(df_ordenes)} filas")
print(f" 📋 Esquemas: {len(df_esquemas)} filas")
total_ordenes = len(df_ordenes)
# ✅ FIX: Cumple_TAT en Excel es "SÍ"/"NO" (string), convertir a booleano
ordenes_cumplidas = (df_ordenes['Cumple_TAT'] == 'SÍ').sum() if 'Cumple_TAT' in df_ordenes.columns else 0
# ✅ FIX: Usar nombre correcto de columna
tat_promedio_ordenes = df_ordenes['TAT_Real_horas'].mean() if 'TAT_Real_horas' in df_ordenes.columns else 0
total_esquemas = len(df_esquemas)
# ✅ FIX: Usar nombre correcto de columna
tat_promedio_esquemas = df_esquemas['TAT_Real_horas'].mean() if 'TAT_Real_horas' in df_esquemas.columns else 0
# ✅ FIX: Cumple_TAT en Excel es "SÍ"/"NO" (string), convertir a booleano
esquemas_cumplidos = (df_esquemas['Cumple_TAT'] == 'SÍ').sum() if 'Cumple_TAT' in df_esquemas.columns else 0
print(f" 📈 Métricas calculadas:")
print(f" - Total órdenes: {total_ordenes}")
print(f" - Órdenes cumplidas: {ordenes_cumplidas}")
print(f" - TAT promedio órdenes: {tat_promedio_ordenes:.2f} horas")
print(f" - Total esquemas: {total_esquemas}")
print(f" - Esquemas cumplidos: {esquemas_cumplidos}")
print(f" - TAT promedio esquemas: {tat_promedio_esquemas:.2f} horas")
# Calcular tardanzas
# ✅ FIX: Usar nombres correctos de columnas
if 'TAT_Real_horas' in df_ordenes.columns and 'TAT_Prometido_horas' in df_ordenes.columns:
tardanzas_ord = (df_ordenes['TAT_Real_horas'] - df_ordenes['TAT_Prometido_horas']).clip(lower=0)
tardanza_total_ordenes = tardanzas_ord.sum()
tardanza_maxima = tardanzas_ord.max()
print(f" - Tardanza total: {tardanza_total_ordenes:.2f} horas")
print(f" - Tardanza máxima: {tardanza_maxima:.2f} horas")
# ✅ Calcular tardanza de esquemas también
if 'TAT_Real_horas' in df_esquemas.columns and 'TAT_Prometido_horas' in df_esquemas.columns:
tardanzas_esq = (df_esquemas['TAT_Real_horas'] - df_esquemas['TAT_Prometido_horas']).clip(lower=0)
tardanza_total_esquemas = tardanzas_esq.sum()
print(f" - Tardanza total esquemas: {tardanza_total_esquemas:.2f} horas")
except Exception as e:
print(f"⚠️ Error leyendo métricas: {e}")
import traceback
traceback.print_exc()
# Leer makespan del Detalle_Ejecución
detalle_file = archivos_finales.get('detalle')
if detalle_file and os.path.exists(detalle_file):
try:
df_detalle = pd.read_excel(detalle_file)
if 'FECHA_FIN_PLAN' in df_detalle.columns and 'FECHA_INICIO_PLAN' in df_detalle.columns:
fecha_fin = pd.to_datetime(df_detalle['FECHA_FIN_PLAN']).max()
fecha_inicio = pd.to_datetime(df_detalle['FECHA_INICIO_PLAN']).min()
makespan_horas = (fecha_fin - fecha_inicio).total_seconds() / 3600
except Exception as e:
print(f"⚠️ Error leyendo makespan: {e}")
import traceback
traceback.print_exc()
# Formatear resultados con TODAS las métricas del ganador
resumen = f"""
╔══════════════════════════════════════════════════════════════╗
║ 📊 MÉTRICAS DEL CROMOSOMA GANADOR ║
╚══════════════════════════════════════════════════════════════╝
✅ OPTIMIZACIÓN COMPLETADA EXITOSAMENTE
⏰ TIEMPO TOTAL DEL CRONOGRAMA (MAKESPAN):
{makespan_horas:.2f} horas ({makespan_horas/24:.1f} días)
⏱️ TARDANZAS:
• Tardanza Total Órdenes: {tardanza_total_ordenes:.2f} horas
• Tardanza Total Esquemas: {tardanza_total_esquemas:.2f} horas
• 🔴 Tardanza Pico (Máxima): {tardanza_maxima:.2f} horas
⚡ TAT REAL PROMEDIO:
• Por Orden: {tat_promedio_ordenes:.2f} horas
• Por Esquema: {tat_promedio_esquemas:.2f} horas
📦 ÓRDENES ({total_ordenes} total):
• Cumplidas (TAT): {ordenes_cumplidas}
• Incumplidas: {total_ordenes - ordenes_cumplidas}
• Tasa de Cumplimiento: {(ordenes_cumplidas/total_ordenes*100) if total_ordenes > 0 else 0:.1f}%
📋 ESQUEMAS ({total_esquemas} total):
• Cumplidos (TAT): {esquemas_cumplidos}
• Incumplidos: {total_esquemas - esquemas_cumplidos}
• Tasa de Cumplimiento: {(esquemas_cumplidos/total_esquemas*100) if total_esquemas > 0 else 0:.1f}%
📁 ARCHIVOS GENERADOS: {len(archivos_finales)}
1. Detalle_Ejecución (cronograma completo)
2. Utilización (equipos + analistas)
3. Métricas (órdenes + esquemas)
4. Cronograma_por_Analista (agenda por analista)
5. 🎨 Gantt_por_Analista (visualización timeline)
6. 🎨 Gantt_por_Orden (visualización timeline)
🔄 SIGUIENTE PASO:
- Usa Detalle_Ejecución como plan previo en la próxima corrida
- Completa columnas amarillas (fechas reales, estado)
"""
# Crear ZIP con los 6 outputs finales
zip_path = os.path.join(temp_dir, 'resultados.zip')
with zipfile.ZipFile(zip_path, 'w') as zipf:
for key, file_path in archivos_finales.items():
if os.path.exists(file_path):
zipf.write(file_path, os.path.basename(file_path))
# Generar visualizaciones HTML
cronograma_visual_html = generar_cronograma_visual(archivos_finales.get('detalle'))
gantt_html_inicial = generar_gantt_por_analista(archivos_finales.get('detalle'), analista_filtro=None)
# Extraer lista de analistas para el dropdown
analistas_disponibles = ["Todos"]
try:
if archivos_finales.get('detalle') and os.path.exists(archivos_finales.get('detalle')):
df_detalle = pd.read_excel(archivos_finales.get('detalle'))
analista_col = None
for col in df_detalle.columns:
if 'ANALISTA' in str(col).upper():
analista_col = col
break
if analista_col:
analistas_unicos = df_detalle[analista_col].dropna().unique().tolist()
analistas_disponibles.extend(sorted([str(a) for a in analistas_unicos]))
except:
pass
return (resumen, zip_path,
archivos_finales.get('detalle'),
archivos_finales.get('utilizacion'),
archivos_finales.get('metricas'),
archivos_finales.get('cronograma'),
archivos_finales.get('gantt_analista'),
archivos_finales.get('gantt_orden'),
cronograma_visual_html,
gantt_html_inicial,
gr.update(choices=analistas_disponibles, value=analistas_disponibles[0] if analistas_disponibles else "Todos"))
except Exception as e:
import traceback
error_msg = f"ERROR durante la optimización:\n\n{str(e)}\n\n{traceback.format_exc()}"
return error_msg, None, None, None, None, None, None, None, "<div>Error</div>", "<div>Error</div>", gr.update(choices=["Todos"], value="Todos")
def generar_cronograma_visual(archivo_detalle, fecha_inicio_filtro=None, fecha_fin_filtro=None):
"""Genera una visualización HTML del cronograma optimizado"""
if not archivo_detalle:
return "<div style='padding: 2rem; text-align: center; color: #64748b;'>⏳ Ejecuta optimización primero</div>"
try:
import pandas as pd
from datetime import datetime, timedelta
df = pd.read_excel(archivo_detalle)
print(f"\n🔍 [CRONOGRAMA_VISUAL] Primera lectura - Primera columna: '{df.columns[0]}'")
print(f" Primeras 3 columnas: {list(df.columns[:3])}")
# Detectar y saltar headers
if 'IMPORTANTE' in str(df.columns[0]).upper() or 'UNNAMED' in str(df.columns[0]).upper() or 'DETALLE' in str(df.columns[0]).upper():
print(f" 🔄 Detectado header/título, buscando fila correcta...")
for skip_rows in [2, 3, 4, 1]:
try:
df = pd.read_excel(archivo_detalle, header=skip_rows)
valid_cols = [col for col in df.columns if 'UNNAMED' not in str(col).upper()]
has_fecha = any('FECHA' in str(col).upper() for col in df.columns)
print(f" Probando header={skip_rows}: valid_cols={len(valid_cols)}, has_fecha={has_fecha}")
if has_fecha and len(valid_cols) > 5:
print(f" ✅ Encontrado en header={skip_rows}")
break
except Exception as e:
print(f" Error en header={skip_rows}: {e}")
continue
# Buscar columnas
print(f"\n🔍 [CRONOGRAMA_VISUAL] Columnas encontradas en archivo: {list(df.columns)}")
fecha_col = None
analista_col = None
paso_col = None
orden_col = None
for col in df.columns:
col_upper = str(col).upper().strip()
if 'FECHA' in col_upper and 'INICIO' in col_upper and not fecha_col:
fecha_col = col
print(f" ✅ fecha_col = '{col}'")
elif 'ANALISTA' in col_upper and not analista_col:
analista_col = col
print(f" ✅ analista_col = '{col}'")
elif 'PASO' in col_upper and not paso_col:
paso_col = col
print(f" ✅ paso_col = '{col}'")
elif col_upper == 'ORDEN' and not orden_col:
orden_col = col
print(f" ✅ orden_col = '{col}'")
print(f" 📋 Resultado: fecha_col={fecha_col}, analista_col={analista_col}, paso_col={paso_col}")
if not all([fecha_col, analista_col]):
error_html = f"""<div style='padding: 2rem; background: #fee; border-radius: 8px; color: #991b1b;'>
❌ No se encontraron columnas necesarias<br><br>
<small>Columnas disponibles: {', '.join([str(c) for c in df.columns[:10]])}</small><br>
<small>fecha_col: {fecha_col}, analista_col: {analista_col}</small>
</div>"""
return error_html
# Convertir fechas
df[fecha_col] = pd.to_datetime(df[fecha_col], errors='coerce')
df = df.dropna(subset=[fecha_col])
if df.empty:
return "<div>No hay datos para mostrar</div>"
# Aplicar filtros de fecha si existen
if fecha_inicio_filtro:
df = df[df[fecha_col] >= pd.to_datetime(fecha_inicio_filtro)]
if fecha_fin_filtro:
df = df[df[fecha_col] <= pd.to_datetime(fecha_fin_filtro)]
# Agregar columna de fecha (solo día) y turno
df['DIA'] = df[fecha_col].dt.date
def clasificar_turno(fecha):
hora = fecha.hour
if 7 <= hora < 15:
return 'Mañana'
elif 15 <= hora < 23:
return 'Tarde'
else:
return 'Noche'
df['TURNO'] = df[fecha_col].apply(clasificar_turno)
# Agrupar por día y turno
dias_unicos = sorted(df['DIA'].unique())
# Generar HTML con vista de turnos
html = """
<style>
.cronograma-container {
background: white;
border-radius: 12px;
overflow-x: auto;
}
.cronograma-grid {
display: grid;
grid-template-columns: 150px 120px repeat(3, 1fr);
gap: 1px;
background: #e2e8f0;
border: 1px solid #cbd5e1;
}
.cronograma-header {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
padding: 1rem;
font-weight: 700;
text-align: center;
border-bottom: 2px solid #047857;
}
.cronograma-turno-header {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
color: white;
padding: 0.75rem;
font-weight: 600;
text-align: center;
font-size: 0.9rem;
}
.cronograma-dia {
background: #f0fdf4;
padding: 0.75rem;
font-weight: 600;
color: #065f46;
display: flex;
align-items: center;
justify-content: center;
border-right: 2px solid #10b981;
}
.cronograma-cell {
background: white;
padding: 0.5rem;
min-height: 80px;
font-size: 0.8rem;
}
.cronograma-tarea {
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
border-left: 3px solid #3b82f6;
padding: 0.4rem;
margin-bottom: 0.3rem;
border-radius: 4px;
font-size: 0.75rem;
}
.cronograma-tarea:hover {
background: linear-gradient(135deg, #bfdbfe 0%, #93c5fd 100%);
transform: translateX(2px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.tarea-info {
font-weight: 600;
color: #1e40af;
}
.tarea-analista {
color: #4b5563;
font-size: 0.7rem;
}
</style>
<div class="cronograma-container">
<div class="cronograma-grid">
<div class="cronograma-header">Fecha</div>
<div class="cronograma-header">📦 Orden</div>
<div class="cronograma-turno-header">🌅 Mañana<br>(07:00-15:00)</div>
<div class="cronograma-turno-header">🌆 Tarde<br>(15:00-23:00)</div>
<div class="cronograma-turno-header">🌙 Noche<br>(23:00-07:00)</div>
"""
for dia in dias_unicos:
df_dia = df[df['DIA'] == dia]
# Agrupar por orden dentro del día
if orden_col:
ordenes_dia = sorted(df_dia[orden_col].dropna().unique())
if not ordenes_dia:
ordenes_dia = ['Sin orden']
else:
ordenes_dia = ['Sin orden']
for idx_orden, orden in enumerate(ordenes_dia):
# Filtrar tareas de esta orden
if orden_col and orden != 'Sin orden':
df_dia_orden = df_dia[df_dia[orden_col] == orden]
else:
df_dia_orden = df_dia
# Solo mostrar fecha en la primera fila de orden del día
if idx_orden == 0:
html += f'<div class="cronograma-dia">{dia.strftime("%d/%m/%Y")}<br><small>{["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"][dia.weekday()]}</small></div>'
else:
html += '<div class="cronograma-dia" style="background: #f8fafc;"></div>'
# Columna de ORDEN
html += f'<div class="cronograma-dia" style="background: #dbeafe; color: #1e40af; font-weight: 600; font-size: 0.85rem;">{orden}</div>'
for turno in ['Mañana', 'Tarde', 'Noche']:
df_turno = df_dia_orden[df_dia_orden['TURNO'] == turno]
html += '<div class="cronograma-cell">'
if not df_turno.empty:
for _, tarea in df_turno.iterrows():
paso_val = str(tarea.get(paso_col, 'Tarea')) if paso_col and pd.notna(tarea.get(paso_col)) else 'Tarea'
analista_val = str(tarea.get(analista_col, '')) if pd.notna(tarea.get(analista_col)) else 'Sin asignar'
hora = tarea[fecha_col].strftime('%H:%M')
html += f'''
<div class="cronograma-tarea">
<div class="tarea-info">{hora} - {paso_val}</div>
<div class="tarea-analista">👤 {analista_val}</div>
</div>
'''
else:
html += '<div style="color: #9ca3af; font-style: italic; text-align: center; padding: 1rem;">Sin tareas</div>'
html += '</div>'
html += """
</div>
</div>
"""
return html
except Exception as e:
import traceback
return f"<div style='padding: 2rem; background: #fee; border-radius: 8px; color: #991b1b;'><h3>❌ Error</h3><pre>{str(e)}</pre></div>"
def generar_gantt_por_analista(archivo_detalle, analista_filtro=None):
"""Genera un diagrama de Gantt visual por analista"""
# Normalizar analista_filtro: convertir string 'None' a None
if analista_filtro == 'None' or analista_filtro == '':
analista_filtro = None
print(f"\n🎯 [GANTT] Función llamada con analista_filtro: '{analista_filtro}'")
if not archivo_detalle:
return "<div style='padding: 2rem; text-align: center; color: #64748b;'>⏳ Ejecuta optimización primero</div>"
try:
import pandas as pd
from datetime import datetime, timedelta
import html as html_module
df = pd.read_excel(archivo_detalle)
print(f"\n🔍 [GANTT] Primera lectura - Primera columna: '{df.columns[0]}'")
print(f" Primeras 3 columnas: {list(df.columns[:3])}")
# Detectar y saltar headers
if 'IMPORTANTE' in str(df.columns[0]).upper() or 'UNNAMED' in str(df.columns[0]).upper() or 'DETALLE' in str(df.columns[0]).upper():
print(f" 🔄 Detectado header/título, buscando fila correcta...")
for skip_rows in [2, 3, 4, 1]:
try:
df = pd.read_excel(archivo_detalle, header=skip_rows)
valid_cols = [col for col in df.columns if 'UNNAMED' not in str(col).upper()]
has_fecha = any('FECHA' in str(col).upper() for col in df.columns)
print(f" Probando header={skip_rows}: valid_cols={len(valid_cols)}, has_fecha={has_fecha}")
if has_fecha and len(valid_cols) > 5:
print(f" ✅ Encontrado en header={skip_rows}")
break
except Exception as e:
print(f" Error en header={skip_rows}: {e}")
continue
# Buscar columnas necesarias
print(f"\n🔍 [GANTT] Columnas encontradas en archivo: {list(df.columns)}")
fecha_inicio_col = None
fecha_fin_col = None
analista_col = None
paso_col = None
esquema_col = None
orden_col = None
for col in df.columns:
col_upper = str(col).upper().strip()
if col_upper == 'FECHA_INICIO_REAL' and not fecha_inicio_col:
fecha_inicio_col = col
elif col_upper == 'FECHA_INICIO' and not fecha_inicio_col:
fecha_inicio_col = col
elif col_upper == 'FECHA_FIN_REAL' and not fecha_fin_col:
fecha_fin_col = col
elif col_upper == 'FECHA_FIN' and not fecha_fin_col:
fecha_fin_col = col
elif 'ANALISTA' in col_upper and 'ASIGNADO' in col_upper and not analista_col:
analista_col = col
elif col_upper == 'PASO' and not paso_col:
paso_col = col
elif col_upper == 'ESQUEMA' and not esquema_col:
esquema_col = col
elif col_upper == 'ORDEN' and not orden_col:
orden_col = col
print(f" 📋 Resultado GANTT: fecha_inicio={fecha_inicio_col}, fecha_fin={fecha_fin_col}, analista={analista_col}")
print(f" 📋 Opcionales: paso={paso_col}, esquema={esquema_col}, orden={orden_col}")
if not all([fecha_inicio_col, fecha_fin_col, analista_col]):
error_html = f"""<div style='padding: 2rem; background: #fee; border-radius: 8px; color: #991b1b;'>
❌ Faltan columnas necesarias<br><br>
<small>Columnas disponibles: {', '.join([str(c) for c in df.columns[:10]])}</small><br>
<small>fecha_inicio: {fecha_inicio_col}, fecha_fin: {fecha_fin_col}, analista: {analista_col}</small>
</div>"""
return error_html
# Convertir fechas
df[fecha_inicio_col] = pd.to_datetime(df[fecha_inicio_col], errors='coerce')
df[fecha_fin_col] = pd.to_datetime(df[fecha_fin_col], errors='coerce')
df = df.dropna(subset=[fecha_inicio_col, fecha_fin_col])
df['FECHA_INICIO'] = df[fecha_inicio_col]
df['FECHA_FIN'] = df[fecha_fin_col]
# Filtrar tareas sin analista
df = df[df[analista_col].notna()]
if df.empty:
return "<div>No hay datos</div>"
# Obtener lista de analistas
analistas = sorted([str(a) for a in df[analista_col].unique() if pd.notna(a)])
# Seleccionar analista
if analista_filtro and analista_filtro != "Todos":
analista_seleccionado = analista_filtro
else:
analista_seleccionado = analistas[0] if analistas else None
if not analista_seleccionado:
return "<div>No hay analistas disponibles</div>"
# Filtrar tareas del analista
df_analista = df[df[analista_col].astype(str) == str(analista_seleccionado)].copy()
df_analista = df_analista.sort_values('FECHA_INICIO')
if df_analista.empty:
return f"<div>No hay tareas para '{analista_seleccionado}'</div>"
# Calcular rango de fechas
fecha_min = df_analista['FECHA_INICIO'].min().replace(minute=0, second=0, microsecond=0)
fecha_max = (df_analista['FECHA_FIN'].max().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1))
total_horas = int((fecha_max - fecha_min).total_seconds() / 3600) + 1
total_minutos = int((fecha_max - fecha_min).total_seconds() / 60)
# Píxeles por minuto para proporcionalidad
PIXELES_POR_HORA = 300
pixeles_por_minuto = PIXELES_POR_HORA / 60
# Generar HTML
analista_escaped = html_module.escape(str(analista_seleccionado))
html = f"""
<style>
.gantt-container {{
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
overflow: hidden;
}}
.gantt-header {{
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}}
.gantt-body {{
overflow-x: auto;
background: #f8fafc;
}}
.gantt-timeline {{
display: flex;
flex-direction: column;
min-width: 1000px;
}}
.gantt-timeline-header {{
display: flex;
background: #1e293b;
color: white;
border-bottom: 3px solid #8b5cf6;
position: sticky;
top: 0;
z-index: 10;
}}
.gantt-task-label-header {{
width: 250px;
min-width: 250px;
padding: 0.5rem;
font-weight: 700;
background: #0f172a;
border-right: 2px solid #334155;
text-align: center;
color: #FFFFFF !important;
}}
.gantt-dates-header {{
flex: 1;
display: flex;
}}
.gantt-hour-header {{
width: 300px;
flex-shrink: 0;
padding: 0.5rem 0.25rem;
text-align: center;
border-right: 1px solid #334155;
font-size: 1rem;
font-weight: 700;
background: #1e293b;
color: #FFFFFF !important;
}}
.gantt-day-start-hour {{
width: 400px !important;
flex-shrink: 0;
background: #7c3aed !important;
border-right: 3px solid #fbbf24 !important;
padding: 0.5rem 0.25rem !important;
color: #FFFFFF !important;
}}
.gantt-hour-time {{
font-weight: 700;
font-size: 1rem;
color: #FFFFFF !important;
}}
.gantt-day-name {{
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 0.25rem;
color: #FFFFFF !important;
}}
.gantt-day-date {{
font-size: 0.9rem;
color: #FFFFFF !important;
}}
.gantt-row {{
display: flex;
border-bottom: 1px solid #e2e8f0;
background: white;
min-height: 70px;
}}
.gantt-row:hover {{
background: #faf5ff;
}}
.gantt-task-label {{
width: 250px;
min-width: 250px;
padding: 0.75rem;
border-right: 2px solid #cbd5e1;
display: flex;
flex-direction: column;
justify-content: center;
background: #f8fafc;
}}
.gantt-task-name {{
font-weight: 600;
font-size: 0.875rem;
color: #1e293b;
}}
.gantt-task-info {{
font-size: 0.75rem;
color: #64748b;
}}
.gantt-task-timeline {{
position: relative;
display: flex;
flex-shrink: 0;
}}
.gantt-hour-col {{
width: 300px;
flex-shrink: 0;
border-right: 1px solid #e5e7eb;
}}
.gantt-day-start-col {{
width: 400px !important;
flex-shrink: 0;
background: rgba(124, 58, 237, 0.03);
border-right: 2px solid #8b5cf6 !important;
}}
.gantt-task-bar {{
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 50px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
border: 2px solid rgba(255,255,255,0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
font-weight: 700;
color: white;
padding: 0 0.75rem;
overflow: hidden;
white-space: nowrap;
z-index: 5;
min-width: 40px;
}}
.gantt-task-bar.small-bar {{
font-size: 0;
}}
.gantt-task-bar.small-bar:hover {{
font-size: 0.75rem;
}}
.gantt-task-bar:hover {{
transform: translateY(-50%) scale(1.03);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10;
}}
.gantt-bar-1 {{ background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); }}
.gantt-bar-2 {{ background: linear-gradient(135deg, #10b981 0%, #047857 100%); }}
.gantt-bar-3 {{ background: linear-gradient(135deg, #f59e0b 0%, #b45309 100%); }}
.gantt-bar-4 {{ background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); }}
.gantt-bar-5 {{ background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); }}
.gantt-bar-6 {{ background: linear-gradient(135deg, #ec4899 0%, #be185d 100%); }}
</style>
<div class="gantt-container">
<div class="gantt-header">
<h3 style="margin:0;">📊 Diagrama de Gantt - {analista_escaped}</h3>
<span>{len(df_analista)} tareas</span>
</div>
<div class="gantt-body">
<div class="gantt-timeline">
<div class="gantt-timeline-header">
<div class="gantt-task-label-header">TAREA / PASO</div>
<div class="gantt-dates-header">
"""
# Generar headers de horas
current_date = None
for hora_idx in range(total_horas):
fecha_hora = fecha_min + timedelta(hours=hora_idx)
fecha_actual = fecha_hora.date()
hora_str = fecha_hora.strftime('%H:%M')
es_nuevo_dia = (fecha_actual != current_date)
if es_nuevo_dia:
current_date = fecha_actual
dia_nombre = fecha_hora.strftime('%a')
dia_fecha = fecha_hora.strftime('%d/%m')
html += f'<div class="gantt-hour-header gantt-day-start-hour"><div class="gantt-day-name">{dia_nombre}</div><div class="gantt-day-date">{dia_fecha}</div><div class="gantt-hour-time">{hora_str}</div></div>'
else:
html += f'<div class="gantt-hour-header"><div class="gantt-hour-time">{hora_str}</div></div>'
html += "</div></div>"
# Generar filas de tareas
for tarea_idx, (_, tarea) in enumerate(df_analista.iterrows()):
paso_val = str(tarea.get(paso_col, 'Tarea')) if paso_col and pd.notna(tarea.get(paso_col)) else 'Tarea'
esquema_val = str(tarea.get(esquema_col, '')) if esquema_col and pd.notna(tarea.get(esquema_col)) else ''
orden_val = str(tarea.get(orden_col, '')) if orden_col and pd.notna(tarea.get(orden_col)) else ''
# ✅ Extraer TODA la información disponible
nombre_paso = str(tarea.get('NOMBRE_PASO', '')) if pd.notna(tarea.get('NOMBRE_PASO')) else ''
muestra = str(tarea.get('MUESTRA', '')) if pd.notna(tarea.get('MUESTRA')) else ''
equipo = str(tarea.get('EQUIPO_ASIGNADO', '')) if pd.notna(tarea.get('EQUIPO_ASIGNADO')) else ''
tipo_equipo = str(tarea.get('TIPO_EQUIPO', '')) if pd.notna(tarea.get('TIPO_EQUIPO')) else ''
modo_equipo = str(tarea.get('MODO_EQUIPO', '')) if pd.notna(tarea.get('MODO_EQUIPO')) else ''
batch_id = str(tarea.get('BATCH_ID', '')) if pd.notna(tarea.get('BATCH_ID')) else ''
duracion_real = float(tarea.get('DURACION_REAL', 0)) if pd.notna(tarea.get('DURACION_REAL')) else 0
# Extraer información de interacción
modo_interaccion = int(tarea.get('MODO_INTERACCION', 0)) if pd.notna(tarea.get('MODO_INTERACCION')) else 0
tiempo_interaccion = float(tarea.get('TIEMPO_INTERACCION', 0)) if pd.notna(tarea.get('TIEMPO_INTERACCION')) else 0
frecuencia_interaccion = int(tarea.get('FRECUENCIA_INTERACCION', 0)) if pd.notna(tarea.get('FRECUENCIA_INTERACCION')) else 0
inicio = tarea['FECHA_INICIO']
fin = tarea['FECHA_FIN']
if pd.isna(inicio) or pd.isna(fin):
continue
# Calcular posición en píxeles
minutos_desde_inicio = (inicio - fecha_min).total_seconds() / 60
duracion_minutos = (fin - inicio).total_seconds() / 60
left_px = minutos_desde_inicio * pixeles_por_minuto
width_px = duracion_minutos * pixeles_por_minuto
color_class = f"gantt-bar-{(tarea_idx % 6) + 1}"
small_class = "small-bar" if duracion_minutos < 10 else ""
# ✅ Mostrar nombre del paso si está disponible, sino ID
paso_display = nombre_paso if nombre_paso else paso_val
paso_escaped = html_module.escape(paso_display)
# ✅ Info completa en la etiqueta
task_info = ""
if orden_val:
task_info = f"Orden: {html_module.escape(orden_val)}"
if esquema_val:
task_info += f" | Esquema: {html_module.escape(esquema_val)}" if task_info else f"Esquema: {html_module.escape(esquema_val)}"
if muestra:
task_info += f" | Muestra: {html_module.escape(muestra)}"
inicio_str = inicio.strftime('%H:%M')
fin_str = fin.strftime('%H:%M')
inicio_fecha = inicio.strftime('%Y-%m-%d')
fin_fecha = fin.strftime('%Y-%m-%d')
# Generar descripción del modo de interacción para tooltip
modos_descrip = {
0: "Automático (sin analista)",
1: "Inicio",
2: "Fin",
3: "Inicio+Fin",
4: "Periódico",
5: "Continuo"
}
modo_desc = modos_descrip.get(modo_interaccion, "Desconocido")
modo_emoji = ["🤖", "▶️", "⏹️", "⏯️", "🔄", "👤"][modo_interaccion] if 0 <= modo_interaccion <= 5 else "❓"
html += f'''
<div class="gantt-row">
<div class="gantt-task-label">
<div class="gantt-task-name">{paso_escaped}</div>
<div class="gantt-task-info">{task_info}</div>
</div>
<div class="gantt-task-timeline">
'''
# Generar columnas vacías
current_date_row = None
for hora_idx in range(total_horas):
fecha_hora = fecha_min + timedelta(hours=hora_idx)
es_nuevo_dia = (fecha_hora.date() != current_date_row)
if es_nuevo_dia:
current_date_row = fecha_hora.date()
css_class = "gantt-hour-col gantt-day-start-col" if es_nuevo_dia else "gantt-hour-col"
html += f'<div class="{css_class}"></div>'
# ✅ TOOLTIP COMPLETO con toda la información
tooltip_lines = []
tooltip_lines.append(f"📋 {paso_escaped}")
if nombre_paso and paso_val != nombre_paso:
tooltip_lines.append(f" ID: {html_module.escape(paso_val)}")
tooltip_lines.append(f"") # Línea vacía
tooltip_lines.append(f"📦 Orden: {html_module.escape(orden_val)}")
tooltip_lines.append(f"🧪 Esquema: {html_module.escape(esquema_val)}")
if muestra:
tooltip_lines.append(f"🔬 Muestra: {html_module.escape(muestra)}")
tooltip_lines.append(f"") # Línea vacía
tooltip_lines.append(f"⏰ Inicio: {inicio_fecha} {inicio_str}")
tooltip_lines.append(f"⏰ Fin: {fin_fecha} {fin_str}")
tooltip_lines.append(f"⏱️ Duración: {duracion_minutos:.1f} min ({duracion_real:.1f} min real)")
tooltip_lines.append(f"") # Línea vacía
tooltip_lines.append(f"👤 Modo: {modo_emoji} {modo_desc}")
if modo_interaccion > 0 and tiempo_interaccion > 0:
tooltip_lines.append(f" Tiempo interacción: {tiempo_interaccion:.0f} min")
if modo_interaccion == 4 and frecuencia_interaccion > 0:
tooltip_lines.append(f" Frecuencia: {frecuencia_interaccion} revisiones")
if equipo:
tooltip_lines.append(f"") # Línea vacía
tooltip_lines.append(f"🔧 Equipo: {html_module.escape(equipo)}")
if tipo_equipo:
tooltip_lines.append(f" Tipo: {html_module.escape(tipo_equipo)}")
if modo_equipo:
tooltip_lines.append(f" Modo: {html_module.escape(modo_equipo)}")
if batch_id:
tooltip_lines.append(f" Batch: {html_module.escape(batch_id)}")
tooltip_text = "&#10;".join(tooltip_lines)
barra_texto = f"{modo_emoji} {inicio_str} - {fin_str}"
html += f'''
<div class="gantt-task-bar {color_class} {small_class}"
style="left: {left_px:.1f}px; width: {width_px:.1f}px;"
title="{tooltip_text}">
{barra_texto}
</div>
</div>
</div>
'''
html += "</div></div></div>"
return html
except Exception as e:
import traceback
return f"<div style='padding: 2rem; background: #fee; border-radius: 8px; color: #991b1b;'><h3>❌ Error</h3><pre>{str(e)}\n{traceback.format_exc()}</pre></div>"
def detener_optimizacion():
"""Detiene la optimización en curso"""
global stop_optimization
stop_optimization = True
return "Optimización detenida por el usuario"
# ============================================================================
# CREAR INTERFAZ GRADIO CON 4 TABS
# ============================================================================
print("\n" + "="*80)
print("🧬 INICIANDO INTERFAZ GRADIO")
print("="*80)
# Tema personalizado profesional
custom_theme = gr.themes.Soft(
primary_hue="blue",
secondary_hue="slate",
neutral_hue="slate",
).set(
body_background_fill="*neutral_50",
body_background_fill_dark="*neutral_900",
button_primary_background_fill="*primary_600",
button_primary_background_fill_hover="*primary_700",
block_label_text_weight="600",
block_title_text_weight="700",
)
with gr.Blocks(
title="Sistema de Cronograma de Laboratorio | Optimización con IA",
theme=custom_theme,
css="""
/* Contenedor principal */
.gradio-container {
max-width: 1400px !important;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Header principal */
.header-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem;
border-radius: 12px;
color: white;
margin-bottom: 2rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.header-box:hover {
transform: translateY(-2px);
box-shadow: 0 15px 40px rgba(102, 126, 234, 0.3);
}
/* Cards de sección */
.section-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
margin-bottom: 1rem;
border-left: 4px solid #667eea;
transition: box-shadow 0.3s ease, transform 0.2s ease;
}
.section-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
transform: translateX(2px);
}
/* Badges informativos */
.info-badge {
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
color: #4338ca;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
display: inline-block;
margin: 0.25rem;
}
/* Mejorar apariencia de tabs */
.tabs {
border-radius: 8px;
overflow: hidden;
}
/* Botones personalizados */
button {
transition: all 0.3s ease !important;
font-weight: 500 !important;
}
button:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
}
/* Archivos de entrada */
.file-preview {
border-radius: 8px !important;
border: 2px dashed #cbd5e1 !important;
transition: border-color 0.3s ease !important;
}
.file-preview:hover {
border-color: #667eea !important;
}
/* Accordion mejorado */
.accordion {
border-radius: 8px !important;
overflow: hidden !important;
}
/* Textbox y Number inputs */
input[type="text"], input[type="number"], textarea {
border-radius: 6px !important;
border: 1px solid #e2e8f0 !important;
transition: border-color 0.3s ease, box-shadow 0.3s ease !important;
}
input[type="text"]:focus, input[type="number"]:focus, textarea:focus {
border-color: #667eea !important;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1) !important;
}
/* Mejorar scroll */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
"""
) as app:
# Header profesional
gr.HTML("""
<div class="header-box">
<h1 style="margin:0; font-size: 2.5rem; font-weight: 800;">
🧬 Sistema de Cronograma de Laboratorio
</h1>
<p style="margin: 0.5rem 0 0 0; font-size: 1.125rem; opacity: 0.95;">
Optimización Inteligente mediante Algoritmos Genéticos
</p>
<p style="margin: 0.5rem 0 0 0; opacity: 0.8; font-size: 0.875rem;">
Planificación automática de recursos | Maximización de eficiencia | Cumplimiento de TAT
</p>
</div>
""")
with gr.Tabs():
# ========================================================================
# TAB 1: CONFIGURACIÓN Y EJECUCIÓN
# ========================================================================
with gr.Tab("⚙️ Configuración y Ejecución"):
# Sección: Configuración Avanzada (colapsable)
with gr.Accordion("🔧 Parámetros del Algoritmo Genético", open=False):
gr.HTML('<div class="section-card">')
gr.Markdown("Ajusta los hiperparámetros del algoritmo evolutivo. Los valores predeterminados funcionan bien en la mayoría de casos.")
with gr.Row():
tamano_poblacion = gr.Number(
label="Tamaño de Población",
value=50,
info="Número de soluciones por generación (recomendado: 30-100)"
)
num_generaciones = gr.Number(
label="Número de Generaciones",
value=100,
info="Iteraciones evolutivas (recomendado: 50-200)"
)
with gr.Row():
tasa_mutacion = gr.Number(
label="Tasa de Mutación",
value=0.1,
info="Probabilidad de variación aleatoria (0.0-1.0)"
)
tasa_crossover = gr.Number(
label="Tasa de Crossover",
value=0.7,
info="Probabilidad de recombinación (0.0-1.0)"
)
gr.HTML('</div>')
# Sección: Hora de Inicio
with gr.Accordion("📅 Configuración de Fecha y Hora", open=True):
gr.HTML('<div class="section-card">')
hora_actual = gr.Textbox(
label="Fecha y Hora de Inicio de Planificación",
placeholder="2026-02-01 14:00",
info="Formato: YYYY-MM-DD HH:MM | Dejar vacío para usar la hora actual del sistema",
scale=2
)
gr.HTML('<p style="color: #64748b; font-size: 0.875rem; margin-top: 0.5rem;">💡 Esta será la hora de referencia para el inicio del cronograma optimizado</p></div>')
# Sección: Turnos de Trabajo
with gr.Accordion("⏰ Configuración de Turnos de Trabajo", open=False):
gr.HTML('<div class="section-card">')
gr.Markdown("Define los horarios de los turnos que se aplicarán automáticamente a todos los días de la semana.")
gr.HTML('<div style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); color: white; padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0;"><strong>🌅 Turno Mañana</strong></div>')
with gr.Row():
manana_ini = gr.Textbox(
label="Hora Inicio",
value="07:00",
info="Formato: HH:MM"
)
manana_fin = gr.Textbox(
label="Hora Fin",
value="14:00",
info="Formato: HH:MM"
)
gr.HTML('<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0;"><strong>🌆 Turno Tarde</strong></div>')
with gr.Row():
tarde_ini = gr.Textbox(
label="Hora Inicio",
value="14:00",
info="Formato: HH:MM"
)
tarde_fin = gr.Textbox(
label="Hora Fin",
value="22:00",
info="Formato: HH:MM"
)
gr.HTML('<div style="background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%); color: white; padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0;"><strong>🌙 Turno Noche</strong></div>')
with gr.Row():
noche_ini = gr.Textbox(
label="Hora Inicio",
value="22:00",
info="Formato: HH:MM"
)
noche_fin = gr.Textbox(
label="Hora Fin",
value="07:00",
info="Formato: HH:MM"
)
gr.HTML('</div>')
# Sección: Pesos de la Función Objetivo
with gr.Accordion("⚖️ Pesos de la Función Objetivo", open=False):
gr.HTML('<div class="section-card">')
gr.Markdown("Ajusta la importancia relativa de cada métrica en la optimización. Valores más altos = mayor prioridad.")
gr.HTML('<p style="color: #dc2626; font-weight: 600; margin-top: 1rem;">🚨 Penalizaciones Críticas (Restricciones Duras)</p>')
with gr.Row():
peso_incump_esq = gr.Number(
label="Incumplimiento Esquemas",
value=10000.0,
info="Penalización por esquemas sin completar"
)
peso_incump_ord = gr.Number(
label="Incumplimiento Órdenes",
value=10000.0,
info="Penalización por órdenes sin completar"
)
gr.HTML('<p style="color: #ea580c; font-weight: 600; margin-top: 1rem;">⏱️ Tardanzas y Tiempos (Restricciones Medias)</p>')
with gr.Row():
peso_tard_esq = gr.Number(
label="Tardanza Total Esquemas",
value=500.0,
info="Penalización por retrasos en esquemas"
)
peso_tard_ord = gr.Number(
label="Tardanza Total Órdenes",
value=500.0,
info="Penalización por retrasos en órdenes"
)
gr.HTML('<p style="color: #0891b2; font-weight: 600; margin-top: 1rem;">📊 Métricas de Optimización (Objetivos Suaves)</p>')
with gr.Row():
peso_tat_esq = gr.Number(
label="TAT Real Esquemas",
value=10.0,
info="Minimizar tiempo total de esquemas"
)
peso_tat_ord = gr.Number(
label="TAT Real Órdenes",
value=10.0,
info="Minimizar tiempo total de órdenes"
)
with gr.Row():
peso_makespan = gr.Number(
label="Makespan",
value=1.0,
info="Minimizar tiempo total de ejecución"
)
peso_tard_max = gr.Number(
label="Tardanza Máxima",
value=5.0,
info="Minimizar peor retraso individual"
)
gr.HTML('</div>')
# Sección: Carga de Archivos
gr.HTML('<div style="margin-top: 2rem;"></div>')
gr.HTML('<div class="section-card">')
gr.Markdown("## 📁 Cargar Archivos de Entrada")
gr.HTML("""
<div style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%); color: white; padding: 1rem; border-radius: 8px; margin: 1rem 0;">
<p style="margin: 0; font-size: 0.875rem;">
<strong>ℹ️ Información importante:</strong><br>
• Los archivos pueden tener cualquier nombre - se procesarán según su contenido<br>
• <strong>ESQUEMAS</strong> y <strong>DEPENDENCIAS</strong> se generan automáticamente desde PASOS<br>
• Solo necesitas preparar <strong>4 archivos obligatorios</strong> (o 5 si usas plan previo)
</p>
</div>
""")
gr.HTML('<p style="color: #0f172a; font-weight: 600; font-size: 1rem; margin-top: 1.5rem;">📋 Archivos Obligatorios (4)</p>')
with gr.Row():
with gr.Column():
file_ordenes = gr.File(
label="📦 ORDENES",
file_types=[".xlsx"],
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Órdenes de trabajo del laboratorio</p>')
with gr.Column():
file_pasos = gr.File(
label="👣 PASOS",
file_types=[".xlsx"],
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Catálogo de pasos de cada esquema</p>')
with gr.Row():
with gr.Column():
file_analistas = gr.File(
label="👥 ANALISTAS",
file_types=[".xlsx"],
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Equipo de analistas con turnos</p>')
with gr.Column():
file_equipos = gr.File(
label="⚙️ EQUIPOS",
file_types=[".xlsx"],
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Equipos de laboratorio disponibles</p>')
gr.HTML('<p style="color: #0f172a; font-weight: 600; font-size: 1rem; margin-top: 1.5rem;">🔄 Plan Previo (Opcional)</p>')
gr.HTML("""
<div style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); border: 1px solid #60a5fa; padding: 1rem; border-radius: 8px; margin: 0.5rem 0;">
<p style="margin: 0; color: #1e40af; font-size: 0.875rem;">
<strong>💡 ¿Qué es el Plan Previo?</strong><br>
El Plan Previo es el archivo <strong>Detalle_Ejecución</strong> de una corrida anterior que ya contiene tareas:<br>
• ✅ <strong>COMPLETADAS:</strong> Tareas finalizadas con fechas reales<br>
• ⏳ <strong>EN_PROCESO:</strong> Tareas que ya comenzaron<br><br>
<strong>¿Para qué sirve?</strong><br>
• Permite re-optimizar considerando trabajo ya realizado<br>
• El sistema NO reprogramará las tareas completadas/en proceso<br>
• Solo planificará las tareas nuevas o pendientes<br><br>
<em>Si es tu primera vez, déjalo vacío</em>
</p>
</div>
""")
file_previous = gr.File(
label="📂 Plan Previo - Detalle_Ejecución anterior",
file_types=[".xlsx"],
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Sube el Detalle_Ejecución de una corrida anterior (opcional)</p>')
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Usa este archivo si deseas continuar desde un cronograma anterior con tareas COMPLETADO/EN_PROCESO</p>')
gr.HTML('</div>')
# Sección: Ejecutar Optimización
gr.HTML('<div style="margin-top: 2rem;"></div>')
gr.HTML('<div class="section-card" style="border-left: 4px solid #10b981;">')
gr.Markdown("## 🚀 Ejecutar Optimización")
gr.HTML("""
<div style="background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); border: 1px solid #86efac; padding: 1rem; border-radius: 8px; margin: 1rem 0;">
<p style="margin: 0; color: #166534; font-size: 0.875rem;">
<strong>✅ Checklist antes de ejecutar:</strong><br>
• Todos los archivos obligatorios cargados<br>
• Parámetros del algoritmo configurados (o usar valores por defecto)<br>
• Turnos de trabajo definidos correctamente<br>
• Fecha y hora de inicio configurada
</p>
</div>
""")
with gr.Row():
btn_run = gr.Button(
"▶️ EJECUTAR OPTIMIZACIÓN",
variant="primary",
size="lg",
scale=3
)
btn_stop = gr.Button(
"⏸️ DETENER",
variant="stop",
size="lg",
scale=1
)
status_output = gr.Textbox(
label="📊 Estado de Ejecución",
lines=3,
interactive=False,
placeholder="Esperando inicio de optimización..."
)
gr.HTML('</div>')
# ========================================================================
# TAB 2: RESULTADOS
# ========================================================================
with gr.Tab("📊 Resultados"):
gr.HTML("""
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; padding: 1.5rem; border-radius: 12px; margin-bottom: 1.5rem; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);">
<h2 style="margin: 0; font-size: 1.75rem; font-weight: 700;">🏆 Resultados de la Optimización</h2>
<p style="margin: 0.5rem 0 0 0; opacity: 0.95; font-size: 0.875rem;">
Métricas del mejor cronograma encontrado y archivos de salida
</p>
</div>
""")
gr.HTML('<div class="section-card">')
gr.Markdown("### 📈 Métricas del Cromosoma Ganador")
resultados_text = gr.Textbox(
label="Detalle de Métricas",
lines=35,
interactive=False,
show_copy_button=True,
placeholder="Los resultados aparecerán aquí después de ejecutar la optimización..."
)
gr.HTML('</div>')
gr.HTML('<div style="margin-top: 1.5rem;"></div>')
gr.HTML('<div class="section-card" style="border-left: 4px solid #8b5cf6;">')
gr.Markdown("### 📦 Descargar Archivos de Salida")
gr.HTML("""
<div style="background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%); border: 1px solid #c4b5fd; padding: 1rem; border-radius: 8px; margin: 1rem 0;">
<p style="margin: 0; color: #5b21b6; font-size: 0.875rem;">
<strong>💡 Opciones de descarga:</strong><br>
• <strong>Opción 1:</strong> Descarga el ZIP completo con los 4 archivos Excel<br>
• <strong>Opción 2:</strong> Descarga archivos individuales según tus necesidades
</p>
</div>
""")
gr.HTML('<p style="color: #7c3aed; font-weight: 600; font-size: 1rem; margin-top: 1rem;">📦 Opción 1: Descarga Completa</p>')
btn_descargar_zip = gr.File(
label="📦 ZIP Completo (4 archivos Excel)",
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Descarga todos los resultados en un único archivo ZIP</p>')
gr.HTML('<p style="color: #7c3aed; font-weight: 600; font-size: 1rem; margin-top: 1.5rem;">📄 Opción 2: Descargas Individuales</p>')
with gr.Row():
with gr.Column():
btn_detalle = gr.File(
label="📄 Detalle_Ejecución",
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Cronograma completo paso a paso</p>')
with gr.Column():
btn_utilizacion = gr.File(
label="⚡ Utilización",
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Uso de equipos y analistas</p>')
with gr.Row():
with gr.Column():
btn_metricas = gr.File(
label="📊 Métricas",
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Indicadores de desempeño</p>')
with gr.Column():
btn_cronograma = gr.File(
label="📅 Cronograma_por_Analista",
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Agenda por analista</p>')
gr.HTML('<p style="color: #6366f1; font-size: 1rem; font-weight: 600; margin: 1rem 0 0.5rem 0;">🎨 Gantt Visuales</p>')
with gr.Row():
with gr.Column():
btn_gantt_analista = gr.File(
label="📊 Gantt por Analista",
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Visualización timeline por analista</p>')
with gr.Column():
btn_gantt_orden = gr.File(
label="📦 Gantt por Orden",
file_count="single"
)
gr.HTML('<p style="color: #64748b; font-size: 0.75rem; margin-top: 0.25rem;">Visualización timeline por orden</p>')
gr.HTML('</div>')
# ========================================================================
# TAB 3: INSTRUCCIONES COMPLETAS
# ========================================================================
with gr.Tab("📖 Instrucciones"):
gr.HTML("""
<div style="background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); color: white; padding: 1.5rem; border-radius: 12px; margin-bottom: 1.5rem; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);">
<h2 style="margin: 0; font-size: 1.75rem; font-weight: 700;">📖 Guía Completa del Sistema</h2>
<p style="margin: 0.5rem 0 0 0; opacity: 0.95; font-size: 0.875rem;">
Documentación detallada sobre archivos de entrada, configuración y uso del sistema
</p>
</div>
""")
gr.Markdown("""
# 📖 GUÍA COMPLETA - SISTEMA PARA CRONOGRAMA DE LABORATORIO
## 🚀 INICIO RÁPIDO (3 PASOS)
### 🆕 PRIMERA VEZ (SIN plan previo):
1️⃣ **Descarga plantillas** (Tab 4 "Generar Plantillas")
- Click en botón "Generar y Descargar Plantillas"
- Descarga ZIP con 4 archivos Excel de ejemplo
2️⃣ **Edita con tus datos**
- ORDENES.xlsx → Tus órdenes del laboratorio con esquemas
- PASOS.xlsx → Los pasos de cada esquema de análisis
- ANALISTAS.xlsx → Tu equipo de analistas con habilidades y turnos
- EQUIPOS.xlsx → Tus equipos de laboratorio con capacidades
3️⃣ **Ejecuta** (Tab 1 "Configuración y Ejecución")
- Sube los 4 archivos
- Click "⚡ EJECUTAR OPTIMIZACIÓN"
- Espera resultados (puede tomar 1-5 minutos)
- Tab 2 "Resultados" → Descarga ZIP con 4 Excel
### 🔄 SEGUNDA VEZ (CON plan previo):
1️⃣ **Completa plan anterior**
- Abre Detalle_Ejecución_TIMESTAMP.xlsx anterior
- Completa columnas amarillas:
* FECHA_INICIO_REAL (para tareas que ya empezaron)
* FECHA_FIN_REAL (para tareas que ya terminaron)
* ESTADO → COMPLETADO / EN_PROCESO / PENDIENTE
2️⃣ **Actualiza archivos de entrada**
- ORDENES.xlsx → Solo órdenes con tareas PENDIENTE + nuevas órdenes
- PASOS.xlsx → Solo pasos de esas órdenes
- ANALISTAS.xlsx → Actualiza si cambió tu equipo
- EQUIPOS.xlsx → Actualiza si cambió equipamiento
3️⃣ **Ejecuta con plan previo** (Tab 1)
- Sube plan previo (Detalle_Ejecución anterior)
- Sube 4 archivos actualizados
- Click "⚡ EJECUTAR OPTIMIZACIÓN"
- Descarga nuevos resultados
---
# 📁 ARCHIVOS DE ENTRADA DETALLADOS
## ⚠️ IMPORTANTE: SOLO 4 ARCHIVOS OBLIGATORIOS
ESQUEMAS y DEPENDENCIAS se generan automáticamente desde PASOS.
Solo necesitas preparar **4 archivos**.
---
## 1️⃣ ORDENES.xlsx
**Descripción:** Órdenes de trabajo del laboratorio con sus esquemas/determinaciones.
### Columnas OBLIGATORIAS:
| Columna | Tipo | Descripción | Ejemplo |
|---------|------|-------------|---------|
| **Orden** | Texto | ID único de la orden | ORD001 |
| **Esquema** | Texto | ID del esquema/protocolo | ESQ_A |
| **Determinacion** | Texto | Nombre del análisis | Análisis Microbiológico |
| **Cant.** | Entero | Cantidad de muestras | 1 |
| **TAT** | Número | Turnaround Time en **DÍAS** | 2 |
| **URGENCIA_ESQUEMA** | Entero/Texto | 1=BAJA, 2=MEDIA, 3=ALTA | 3 |
| **PRIORIDAD_ORDEN** | Entero | Prioridad 1-3 (3=máxima) | 3 |
| **Entregado x PMO** | Fecha/Hora | Fecha de recepción | 2026-02-01 08:00:00 |
### Ejemplo completo:
```
Orden | Esquema | Determinacion | Cant. | TAT | URGENCIA_ESQUEMA | PRIORIDAD_ORDEN | Entregado x PMO
-------|---------|----------------|-------|-----|------------------|-----------------|--------------------
ORD001 | ESQ_A | Análisis A | 1 | 2 | ALTA | 3 | 2026-02-01 08:00:00
ORD002 | ESQ_B | Análisis B | 1 | 3 | MEDIA | 2 | 2026-02-01 09:00:00
ORD003 | ESQ_C | Análisis C | 1 | 1 | BAJA | 1 | 2026-02-01 10:00:00
```
### ⚠️ Con plan previo:
- ✅ **INCLUIR:** Órdenes que tienen tareas PENDIENTE
- ✅ **INCLUIR:** Nuevas órdenes que llegaron al laboratorio
- ❌ **EXCLUIR:** Órdenes 100% COMPLETADO (ya terminadas)
### 💡 Prioridad vs Urgencia:
- **PRIORIDAD (1-3):** 1=baja, 2=normal, 3=alta - Define orden general de la orden
- **URGENCIA (1-3):** 1=BAJA, 2=MEDIA, 3=ALTA - Urgencia del esquema específico
- Valores fuera del rango 1-3 se ajustan automáticamente
- El algoritmo ordena por: PRIORIDAD → URGENCIA → FECHA_RECEPCION
---
## 2️⃣ PASOS.xlsx
**Descripción:** Catálogo de pasos/tareas que componen cada esquema de análisis.
### Columnas OBLIGATORIAS:
| Columna | Tipo | Descripción | Ejemplo |
|---------|------|-------------|---------|
| **ESQUEMA_ID** | Texto | ID del esquema al que pertenece | ESQ_A |
| **SECUENCIA** | Entero | Orden de ejecución (1, 2, 3...) | 1 |
| **NOMBRE** | Texto | Nombre descriptivo del paso | Preparación |
| **TIPO_EQUIPO** | Texto | Tipo de equipo requerido | TIPO_A |
| **UNIFORMIDAD** | Entero | 1=UNIFORME, 0=NO_UNIFORME | 1 |
| **DURACION** | Número | Duración en **MINUTOS** | 30 |
| **TIEMPO_MIN** | Número | Duración mínima en minutos | 25 |
| **TIEMPO_MAX** | Número | Duración máxima en minutos | 35 |
| **MODO_INTERACCION** | Entero | 0-5 (ver tabla abajo) | 1 |
| **TIEMPO_INTERACCION** | Número | Tiempo de interacción (min) | 5 |
| **FRECUENCIA_INTERACCION** | Entero | Solo para MODO 4: cada N minutos | 30 |
### Columnas OPCIONALES (se auto-generan):
| Columna | Descripción | Generación automática |
|---------|-------------|-----------------------|
| **ID** | ID único del paso | Se crea como {ESQUEMA_ID}-P{SECUENCIA} |
| **EQUIPOS_POSIBLES** | IDs de equipos compatibles | Se buscan equipos con mismo TIPO_EQUIPO |
| **PASOS_PREDECESORES** | ID del paso anterior | Se toma automáticamente SECUENCIA-1 |
| **ANALISTA_PREFERIDO** | ID de analista preferido | Se deja vacío "" |
### 🔢 MODO_INTERACCION - Valores y Significados:
| Modo | Descripción | Cuándo usar | Ejemplo |
|------|-------------|-------------|---------|
| **0** | Sin analista | Tarea completamente automatizada | Incubación |
| **1** | Analista al INICIO | Configuración inicial, luego automático | Cargar muestras |
| **2** | Analista al FINAL | Proceso automático, lectura final | Leer resultados |
| **3** | Analista INICIO + FINAL | Cargar muestras y leer resultados | PCR manual |
| **4** | Analista PERIÓDICO | Supervisión en N momentos equidistantes | Cultivo (4 revisiones) |
| **5** | Analista CONTINUO | Analista presente todo el tiempo | Titulación manual |
- **TIEMPO_INTERACCION:** Duración de cada interacción/revisión en minutos
- **FRECUENCIA_INTERACCION:** Solo para MODO 4 - NÚMERO de revisiones (no minutos)
- Ejemplo: duracion=120min, frecuencia=4 → 4 revisiones equidistantes en min 0, 40, 80, 120
### Ejemplo completo:
```
ESQUEMA_ID | SECUENCIA | NOMBRE | TIPO_EQUIPO | UNIFORMIDAD | DURACION | TIEMPO_MIN | TIEMPO_MAX | MODO_INTERACCION | TIEMPO_INTERACCION | FRECUENCIA_INTERACCION
-----------|-----------|--------------|-------------|-------------|----------|------------|------------|------------------|--------------------|-----------------------
ESQ_A | 1 | Preparación | TIPO_A | 1 | 30 | 25 | 35 | 5 | 30 | 1
ESQ_A | 2 | Análisis | TIPO_B | 1 | 45 | 40 | 50 | 1 | 5 | 1
ESQ_B | 1 | Cultivo | TIPO_A | 0 | 120 | 100 | 140 | 4 | 10 | 4
```
**Explicación del ejemplo MODO 4:**
- Cultivo dura 120 minutos total
- FRECUENCIA=4 → Hacer 4 revisiones
- Revisiones en: min 0, 40, 80, 120 (equidistantes)
- Cada revisión dura 10 minutos (TIEMPO_INTERACCION=10)
- Analista reservado: 0-10, 40-50, 80-90, 120-130 min
- Entre revisiones, analista LIBRE para otras tareas
### 💡 Notas:
- **ESQUEMA_ID:** Debe existir en ORDENES.xlsx (columna "Esquema")
- **TIPO_EQUIPO:** Debe tener equipos correspondientes en EQUIPOS.xlsx (columna "TIPO")
- **SECUENCIA:** Define el orden estricto de ejecución (1, 2, 3...)
- **UNIFORMIDAD:** 1=duración fija, 0=duración variable (entre TIEMPO_MIN y TIEMPO_MAX)
- **MODO_INTERACCION:** Define cuándo el analista debe interactuar con la tarea (ver tabla arriba)
---
## 3️⃣ ANALISTAS.xlsx
**Descripción:** Equipo de analistas con sus habilidades y turnos.
### Columnas OBLIGATORIAS:
| Columna | Tipo | Descripción | Ejemplo |
|---------|------|-------------|---------|
| **NOMBRE** | Texto | Nombre completo | Juan Pérez |
| **HABILIDADES** | Texto | Esquemas que puede ejecutar (separados por comas) | ESQ_A,ESQ_B |
| **TURNO** | Texto | Turno de trabajo (ver formatos abajo) | mañana |
| **ACTIVO** | Entero | 1 = activo, 0 = inactivo | 1 |
### Columnas OPCIONALES (se auto-generan):
| Columna | Descripción | Generación automática |
|---------|-------------|-----------------------|
| **ID** | ID único del analista | Se genera como ANA{slug_nombre}{001} |
### Ejemplo completo:
```
NOMBRE | HABILIDADES | TURNO | ACTIVO
---------------|-------------|---------|--------
Juan Pérez | ESQ_A,ESQ_B | mañana | 1
María García | ESQ_B,ESQ_C | tarde | 1
Carlos López | ESQ_A,ESQ_C | noche | 1
```
### 🕐 Formatos de TURNO:
**Opción 1: Palabras clave (se expanden automáticamente):**
- `mañana` → Se expande con horarios configurados en interfaz (default: 07:00-14:00 todos los días)
- `tarde` → Default: 14:00-22:00 todos los días
- `noche` → Default: 22:00-07:00 todos los días
**Opción 2: Formato completo (control preciso):**
- Formato: `dia:HH:MM-HH:MM,dia:HH:MM-HH:MM,...`
- Días: 0=Lunes, 1=Martes, 2=Miércoles, 3=Jueves, 4=Viernes, 5=Sábado, 6=Domingo
- Ejemplo: `0:07:00-15:00,1:07:00-15:00,2:07:00-15:00` (Lunes-Miércoles de 7am a 3pm)
### 💡 Notas:
- **HABILIDADES:** Los esquemas deben existir en PASOS.xlsx
- **ACTIVO:** Solo analistas con ACTIVO=1 serán considerados en la optimización
- El sistema NO asigna tareas fuera del turno del analista
---
## 4️⃣ EQUIPOS.xlsx
**Descripción:** Equipos de laboratorio disponibles con sus características y capacidades.
### Columnas OBLIGATORIAS:
| Columna | Tipo | Descripción | Ejemplo |
|---------|------|-------------|---------|
| **ID** | Texto | ID único del equipo (ID real del laboratorio) | EQ001 |
| **TIPO** | Texto | Tipo de equipo (debe coincidir con TIPO_EQUIPO en PASOS) | TIPO_A |
| **MODO** | Texto | BATCH, ROLLING o BATCH_SCALING | BATCH |
| **CAPACIDAD** | Entero | Cantidad de muestras simultáneas | 1 |
| **ACTIVO** | Entero | 1 = activo, 0 = inactivo | 1 |
| **TIEMPO_SETUP** | Número | Tiempo de preparación en minutos | 0 |
### Ejemplo completo:
```
ID | TIPO | MODO | CAPACIDAD | ACTIVO | TIEMPO_SETUP
-------|--------|--------|-----------|--------|-------------
EQ001 | TIPO_A | BATCH | 1 | 1 | 0
EQ002 | TIPO_B | BATCH | 2 | 1 | 5
EQ003 | TIPO_C | ROLLING| 1 | 1 | 0
```
### 💡 Notas:
- **ID:** ID real del equipo en el laboratorio (OBLIGATORIO)
- **TIPO:** Debe coincidir con TIPO_EQUIPO en PASOS.xlsx
- **MODO:**
- BATCH = Procesa muestras por lotes
- ROLLING = Procesa de forma continua
- BATCH_SCALING = Lotes con escalamiento
- **CAPACIDAD:** Cantidad de muestras que puede procesar simultáneamente
- **TIEMPO_SETUP:** Tiempo de preparación antes de cada uso (minutos)
---
## 5️⃣ PLAN_PREVIO.xlsx (OPCIONAL - solo para re-optimización)
### ¿Qué es?
Es el archivo **Detalle_Ejecución_TIMESTAMP.xlsx** de una corrida anterior.
### ¿Cuándo usarlo?
- Cuando ya ejecutaste el algoritmo antes
- Algunas tareas ya están COMPLETADO o EN_PROCESO
- Quieres re-optimizar solo las tareas PENDIENTE + nuevas órdenes
### ¿Cómo prepararlo?
1. Abre el archivo Detalle_Ejecución_TIMESTAMP.xlsx anterior
2. Completa las columnas amarillas:
| Columna | Cuándo completar | Ejemplo |
|---------|------------------|---------|
| **FECHA_INICIO_REAL** | Si la tarea ya empezó | "2026-02-01 14:30:00" |
| **FECHA_FIN_REAL** | Si la tarea ya terminó | "2026-02-01 15:00:00" |
| **ESTADO** | Para todas las tareas | COMPLETADO / EN_PROCESO / PENDIENTE |
3. Guarda el archivo
### Estados explicados:
| Estado | Significado | Fechas reales | ¿Se re-optimiza? |
|--------|-------------|---------------|------------------|
| **COMPLETADO** | Tarea terminada | FECHA_INICIO_REAL y FECHA_FIN_REAL llenas | ❌ NO - Se copia tal cual |
| **EN_PROCESO** | Tarea en ejecución | FECHA_INICIO_REAL llena | ❌ NO - Se copia tal cual |
| **PENDIENTE** | Tarea sin empezar | Vacías o ignoradas | ✅ SÍ - Se re-crea desde ORDENES |
### Ejemplo de plan previo con 100 tareas:
```
Total: 100 tareas
- 60 COMPLETADO (con fechas reales)
- 15 EN_PROCESO (con fecha inicio real)
- 25 PENDIENTE (sin fechas o se ignoran)
Resultado:
→ Se copian 75 tareas (60 + 15)
→ Se re-optimizan 25 tareas PENDIENTE + nuevas órdenes
```
### ⚠️ Reglas importantes:
1. **Tareas COMPLETADO y EN_PROCESO:**
- Se copian exactamente como están
- NO se re-optimizan
- Mantienen analista, equipo, fechas asignadas
2. **Tareas PENDIENTE:**
- Se descartan del plan previo
- Se re-crean desde ORDENES.xlsx y PASOS.xlsx
- Se re-optimizan junto con nuevas órdenes
3. **Archivos de entrada con plan previo:**
- ORDENES.xlsx → Solo incluir órdenes con tareas PENDIENTE + nuevas
- PASOS.xlsx → Solo incluir pasos de esas órdenes
- NO incluir órdenes 100% COMPLETADO
---
# 🤖 GENERACIÓN AUTOMÁTICA (no hacer nada)
El sistema genera automáticamente 2 archivos:
## ESQUEMAS.xlsx (extraído de PASOS)
**Origen:** Extrae combinaciones únicas de (ESQUEMA_ID, ORDEN_ID) desde PASOS.xlsx
**Columnas generadas:**
- ESQUEMA_ID → Extraído de PASOS
- ORDEN_ID → Extraído de PASOS
- URGENCIA → 'MEDIA' (por defecto)
- CANTIDAD_MUESTRAS → 1 (por defecto)
- TAT_ESPECIFICO → None (opcional)
**Ejemplo:**
Si PASOS tiene:
```
P1 | ESQ_A | ORD001
P2 | ESQ_A | ORD001
P3 | ESQ_B | ORD002
```
Se genera ESQUEMAS:
```
ESQUEMA_ID | ORDEN_ID | URGENCIA | CANTIDAD_MUESTRAS | TAT_ESPECIFICO
-----------|----------|----------|-------------------|---------------
ESQ_A | ORD001 | MEDIA | 1 | None
ESQ_B | ORD002 | MEDIA | 1 | None
```
## DEPENDENCIAS.xlsx (vacío)
**Origen:** Se genera vacío (sin filas, solo columnas)
**Razón:** La SECUENCIA en PASOS.xlsx es suficiente para ordenar pasos
**Columnas:** ESQUEMA_ID, PASO_POSTERIOR, PASO_PREVIO
**¿Necesito dependencias cruzadas entre esquemas?**
- La mayoría de casos NO
- Si sí, codifícalas en las secuencias de PASOS
---
# ⚙️ PARÁMETROS DEL ALGORITMO GENÉTICO
## Parámetros disponibles:
| Parámetro | Default | Rango | Qué hace | Cuándo ajustar |
|-----------|---------|-------|----------|----------------|
| **Tamaño Población** | 50 | 30-100 | Cuántas soluciones probar en paralelo | ↑ para problemas complejos |
| **Num Generaciones** | 100 | 50-200 | Cuántas iteraciones de mejora | ↑ para mejor calidad |
| **Tasa Mutación** | 0.1 | 0.05-0.2 | Cuánto explorar soluciones nuevas | ↑ si se queda en óptimos locales |
| **Tasa Crossover** | 0.7 | 0.6-0.9 | Cuánto combinar buenas soluciones | ↑ para convergencia rápida |
## Recomendaciones por tamaño:
### Problema pequeño (<50 tareas):
- Tamaño Población: **30**
- Num Generaciones: **50**
- Tiempo estimado: 30-60 segundos
### Problema mediano (50-200 tareas):
- Tamaño Población: **50** (default)
- Num Generaciones: **100** (default)
- Tiempo estimado: 1-3 minutos
### Problema grande (>200 tareas):
- Tamaño Población: **80**
- Num Generaciones: **150**
- Tiempo estimado: 3-10 minutos
## 8 Pesos de Función Objetivo:
| Peso | Default | Qué penaliza | Cuándo aumentar |
|------|---------|--------------|-----------------|
| **Incumplimiento Esquema** | 1000 | Esquemas que superan TAT | Cumplir TAT es crítico |
| **Incumplimiento Orden** | 500 | Órdenes que superan TAT | Cumplir TAT de orden |
| **Tardanza Esquema** | 100 | Horas de retraso de esquemas | Minimizar retrasos |
| **Tardanza Orden** | 100 | Horas de retraso de órdenes | Minimizar retrasos |
| **TAT Esquema** | 50 | Tiempo total de esquemas | Terminar esquemas rápido |
| **TAT Orden** | 50 | Tiempo total de órdenes | Terminar órdenes rápido |
| **Makespan** | 10 | Tiempo total del plan | Terminar todo rápido |
| **Tardanza Máxima** | 10 | Peor retraso individual | Evitar retrasos extremos |
### 💡 Ajuste de pesos:
- **Defaults funcionan bien en 90% de casos**
- Solo ajustar si necesitas priorizar algo específico
- Ejemplo: Si TAT es CRÍTICO → aumenta Incumplimiento a 2000
---
# 📊 ARCHIVOS DE SALIDA (4 Excel)
Al terminar, descarga ZIP con 4 archivos Excel profesionales:
## 1️⃣ Detalle_Ejecución_TIMESTAMP.xlsx
**Contenido:** Cronograma completo paso por paso
**Columnas (12):**
- PASO_ID
- ESQUEMA_ID
- ORDEN_ID
- ANALISTA_ASIGNADO
- EQUIPO_ASIGNADO
- FECHA_INICIO_PLAN (calculada por algoritmo)
- FECHA_FIN_PLAN (calculada por algoritmo)
- FECHA_INICIO_REAL (vacía - para que completes)
- FECHA_FIN_REAL (vacía - para que completes)
- ESTADO (PENDIENTE - para que actualices)
- DURACION
- SECUENCIA
**Formato:**
- Encabezados en azul oscuro
- Columnas "REAL" y "ESTADO" en amarillo (para completar)
- Filtros automáticos
- Anchos de columna ajustados
**⭐ Usar como plan previo:**
- Completa columnas amarillas
- Sube en próxima corrida como PLAN_PREVIO
## 2️⃣ Utilizacion_TIMESTAMP.xlsx
**Hoja 1: POR_EQUIPO**
- Columnas: EQUIPO_ID, MINUTOS_TRABAJADOS, MAKESPAN_TOTAL, UTILIZACION
- Fórmula: UTILIZACION = MINUTOS_TRABAJADOS / MAKESPAN_TOTAL
- Gráfico de barras de utilización
- Formato condicional: rojo <50%, amarillo 50-80%, verde >80%
**Hoja 2: POR_ANALISTA**
- Columnas: ANALISTA_ID, MINUTOS_TRABAJADOS, MAKESPAN_TOTAL, UTILIZACION
- Fórmula: UTILIZACION = MINUTOS_TRABAJADOS / MAKESPAN_TOTAL
- Gráfico de barras de utilización
- Formato condicional: rojo <50%, amarillo 50-80%, verde >80%
## 3️⃣ Metricas_TIMESTAMP.xlsx
**Hoja 1: POR_ORDEN**
- Columnas: ORDEN_ID, Fecha_Recepción, Fecha_Fin, TAT_Real, TAT_Prometido, Cumple_TAT
- TAT_Real = Fecha_Fin - Fecha_Recepción (en horas)
- Cumple_TAT: SÍ si TAT_Real <= TAT_Prometido
- Gráfico: TAT Real vs TAT Prometido
**Hoja 2: POR_ORDEN_ESQUEMA**
- Columnas: ESQUEMA_ID, ORDEN_ID, Fecha_Inicio, Fecha_Fin, TAT_Esquema
- TAT_Esquema = Fecha_Fin - Fecha_Inicio (en horas)
- Gráfico de barras de TAT por esquema
## 4️⃣ Cronograma_por_Analista_TIMESTAMP.xlsx
**Contenido:** 1 hoja por analista con sus tareas asignadas
**Ejemplo:** Si tienes 5 analistas → 5 hojas
- Hoja "ANA001"
- Hoja "ANA002"
- ...
**Columnas por hoja:**
- PASO_ID
- ESQUEMA_ID
- ORDEN_ID
- FECHA_INICIO
- FECHA_FIN
- DURACION
- EQUIPO
**Formato:**
- Ordenado por FECHA_INICIO
- Ideal para imprimir y dar a cada analista
---
# ❓ PREGUNTAS FRECUENTES (FAQ)
## Sobre archivos de entrada:
**¿Por qué solo 4 archivos si antes eran 6?**
Porque tu ETL genera ESQUEMAS y DEPENDENCIAS desde PASOS. El sistema los extrae automáticamente.
**¿Los archivos deben tener nombres específicos?**
NO. Puedes nombrarlos como quieras. Se procesan por contenido, no por nombre.
**¿PASOS.xlsx SIEMPRE debe tener ORDEN_ID?**
SÍ. Es OBLIGATORIO para que el sistema extraiga ESQUEMAS correctamente.
**¿Qué pasa si falta una columna obligatoria?**
ERROR. El sistema te dirá exactamente qué falta y en qué archivo.
## Sobre plan previo:
**¿Puedo agregar nuevas órdenes al plan previo?**
SÍ. Inclúyelas en ORDENES.xlsx y se optimizarán junto con tareas PENDIENTE.
**¿Qué incluir en ORDENES.xlsx cuando uso plan previo?**
- ✅ Órdenes con tareas PENDIENTE
- ✅ Nuevas órdenes que llegaron
- ❌ Órdenes 100% COMPLETADO
**¿Qué incluir en PLAN_PREVIO.xlsx?**
- ✅ Tareas COMPLETADO (con fechas reales)
- ✅ Tareas EN_PROCESO (con fecha inicio real)
- ⚠️ Tareas PENDIENTE (se ignoran, se re-crean desde ORDENES)
**¿Puedo cambiar el analista de una tarea COMPLETADO?**
NO. Las tareas COMPLETADO y EN_PROCESO se copian tal cual del plan previo.
## Sobre el algoritmo:
**¿El algoritmo respeta las secuencias de pasos?**
SÍ. Las secuencias están BLINDADAS (garantizadas al 100%). Paso 2 NUNCA empieza antes que Paso 1.
**¿Respeta las habilidades de analistas?**
SÍ. Solo asigna analistas capacitados para cada esquema.
**¿Respeta la capacidad de equipos?**
SÍ. Un equipo solo puede hacer 1 tarea a la vez.
**¿Qué pasa si ningún analista tiene la habilidad necesaria?**
ERROR. El sistema te dirá: "Esquema XXX no tiene analistas capacitados".
## Sobre parámetros:
**¿Debo cambiar los parámetros del GA?**
OPCIONAL. Los valores por defecto funcionan bien en 90% de casos.
**¿Más generaciones = mejor resultado?**
SÍ, pero hay punto de rendimiento decreciente. Default (100) es buen balance calidad/tiempo.
**¿Qué pasa si tengo poco tiempo?**
Reduce generaciones a 50 y población a 30. Resultado será bueno (no óptimo).
**¿Puedo detener la ejecución?**
SÍ. Click en botón "🛑 DETENER" (pero perderás el progreso).
## Sobre resultados:
**¿Cuántos archivos genera?**
4 archivos Excel en un ZIP.
**¿Cuál usar como plan previo?**
Detalle_Ejecución_TIMESTAMP.xlsx
**¿Puedo editar los archivos de salida?**
SÍ, pero solo edita columnas amarillas (FECHA_INICIO_REAL, FECHA_FIN_REAL, ESTADO).
**¿Los gráficos son editables?**
SÍ. Son gráficos Excel normales, puedes modificarlos.
## Sobre el sistema:
**¿Necesito instalar algo?**
NO (si usas URL pública). SÍ (si ejecutas localmente: Python + librerías).
**¿Funciona offline?**
SÍ, si ejecutas localmente. NO, si usas URL pública.
**¿Cuánto tiempo toma?**
- Problema pequeño (<50 tareas): 30-60 segundos
- Problema mediano (50-200 tareas): 1-3 minutos
- Problema grande (>200 tareas): 3-10 minutos
**¿Puedo usar esto en producción?**
SÍ. El algoritmo es robusto y las restricciones están blindadas.
---
# 🎯 CASOS DE USO COMUNES
## Caso 1: Primera optimización del mes
**Situación:** Es lunes, tienes 50 órdenes nuevas del fin de semana.
**Pasos:**
1. Tab 4 → Descarga plantillas (si no las tienes)
2. Tu ETL genera ORDENES.xlsx y PASOS.xlsx con las 50 órdenes
3. Usas ANALISTAS.xlsx y EQUIPOS.xlsx del mes anterior (si no cambió equipo)
4. Tab 1 → Subes 4 archivos → EJECUTAR
5. Tab 2 → Descargas ZIP
6. Distribuyes Cronograma_por_Analista a cada analista
## Caso 2: Re-optimización a mitad de semana
**Situación:** Es miércoles, 30 tareas terminaron, 20 en proceso, 10 pendientes + 15 nuevas órdenes.
**Pasos:**
1. Abres Detalle_Ejecución del lunes
2. Completas:
- 30 tareas → COMPLETADO (con fechas reales)
- 20 tareas → EN_PROCESO (con fecha inicio real)
- 10 tareas → PENDIENTE
3. Tu ETL genera ORDENES.xlsx con:
- Las 10 órdenes que tienen tareas PENDIENTE
- Las 15 nuevas órdenes
- Total: 25 órdenes
4. Tu ETL genera PASOS.xlsx con pasos de esas 25 órdenes
5. Tab 1 → Subes plan previo + 4 archivos → EJECUTAR
6. Resultado: 50 tareas copiadas + 25 órdenes re-optimizadas
## Caso 3: Urgencia inesperada
**Situación:** Llega una orden URGENTE a mitad del día.
**Pasos:**
1. Agregas la orden urgente a ORDENES.xlsx con PRIORIDAD = 3
2. Tu ETL genera PASOS.xlsx para esa orden
3. Completas plan previo actual (marca tareas terminadas como COMPLETADO)
4. Re-ejecutas con plan previo + archivos actualizados
5. El algoritmo re-optimiza PENDIENTE + orden urgente
6. La orden urgente se priorizará automáticamente (PRIORIDAD 3)
## Caso 4: Cambio de equipo (analista se enferma)
**Situación:** Un analista se ausenta inesperadamente.
**Pasos:**
1. Abres ANALISTAS.xlsx
2. Cambias ACTIVO del analista enfermo a 0
3. O eliminas la fila del analista
4. Completas plan previo (tareas de ese analista que estaban EN_PROCESO → COMPLETADO si terminaron, PENDIENTE si no)
5. Re-ejecutas
6. El algoritmo redistribuye tareas PENDIENTE a otros analistas capacitados
## Caso 5: Nuevo equipo llega al laboratorio
**Situación:** Llega un nuevo analizador.
**Pasos:**
1. Abres EQUIPOS.xlsx
2. Agregas fila: EQUIPO_5 | Analizador
3. Actualizas PASOS.xlsx para permitir usar EQUIPO_5
4. Re-ejecutas
5. El algoritmo puede usar el nuevo equipo
---
# 📞 SOPORTE
Si encuentras errores o tienes dudas:
1. **Revisa esta pestaña completa** (toda la info está aquí)
2. **Descarga plantillas** (Tab 4) y compara con tus archivos
3. **Lee mensaje de error** (te dice exactamente qué falta)
---
## 🎉 ¡TODO LISTO!
**RECUERDA:**
- Solo 4 archivos de entrada
- PASOS debe tener ORDEN_ID
- Plantillas en Tab 4
- Resultados en Tab 2
- TODO está en esta interfaz
**EMPIEZA AHORA:**
→ Tab 4: Descarga plantillas
→ Edita con tus datos
→ Tab 1: Sube y ejecuta
→ Tab 2: Descarga resultados
¡Éxito! 🚀
""")
# ========================================================================
# TAB 4: GENERAR PLANTILLAS
# ========================================================================
with gr.Tab("📝 Generar Plantillas"):
gr.HTML("""
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; padding: 1.5rem; border-radius: 12px; margin-bottom: 1.5rem; box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2);">
<h2 style="margin: 0; font-size: 1.75rem; font-weight: 700;">📋 Plantillas de Ejemplo</h2>
<p style="margin: 0.5rem 0 0 0; opacity: 0.95; font-size: 0.875rem;">
Descarga archivos Excel pre-configurados con ejemplos de datos para comenzar rápidamente
</p>
</div>
""")
gr.HTML("""
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #fbbf24; padding: 1rem; border-radius: 8px; margin: 1rem 0;">
<p style="margin: 0; color: #92400e; font-size: 0.875rem;">
<strong>💡 ¿Cómo usar las plantillas?</strong><br>
1. Descarga las plantillas (ZIP completo o individuales)<br>
2. Ábrelas en Excel y reemplaza los datos de ejemplo con tus datos reales<br>
3. Asegúrate de mantener los nombres de las columnas exactamente iguales<br>
4. Sube los archivos editados en la pestaña "⚙️ Configuración y Ejecución"
</p>
</div>
""")
# Botón para ZIP completo
gr.HTML('<div class="section-card" style="border-left: 4px solid #f59e0b;">')
gr.Markdown("### 📦 Descarga Rápida: Todas las Plantillas")
gr.HTML('<p style="color: #64748b; font-size: 0.875rem; margin-bottom: 1rem;">Obtén los 4 archivos Excel de ejemplo en un único ZIP</p>')
btn_plantillas_zip = gr.Button(
"📥 Generar y Descargar ZIP con Todas las Plantillas",
variant="primary",
size="lg"
)
file_plantillas_zip = gr.File(label="📦 ZIP Completo (4 archivos)")
gr.HTML('</div>')
gr.HTML('<div style="margin-top: 1.5rem;"></div>')
gr.HTML('<div class="section-card">')
gr.Markdown("### 📄 Descargas Individuales")
gr.HTML('<p style="color: #64748b; font-size: 0.875rem; margin-bottom: 1rem;">Descarga solo las plantillas que necesites</p>')
# Fila 1: Ordenes y Pasos
with gr.Row():
with gr.Column():
gr.HTML("""
<div style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white; padding: 1rem; border-radius: 8px; margin-bottom: 0.75rem;">
<h3 style="margin: 0; font-size: 1rem; font-weight: 600;">📦 1. ORDENES.xlsx</h3>
<p style="margin: 0.25rem 0 0 0; font-size: 0.75rem; opacity: 0.9;">Órdenes de trabajo del laboratorio</p>
</div>
""")
btn_ordenes = gr.Button("📥 Generar Ordenes.xlsx", variant="secondary")
file_ordenes_plantilla = gr.File(label="Archivo Descargado")
with gr.Column():
gr.HTML("""
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; padding: 1rem; border-radius: 8px; margin-bottom: 0.75rem;">
<h3 style="margin: 0; font-size: 1rem; font-weight: 600;">👣 2. PASOS.xlsx</h3>
<p style="margin: 0.25rem 0 0 0; font-size: 0.75rem; opacity: 0.9;">Pasos/tareas de cada esquema</p>
</div>
""")
btn_pasos = gr.Button("📥 Generar Pasos.xlsx", variant="secondary")
file_pasos_plantilla = gr.File(label="Archivo Descargado")
# Fila 2: Analistas y Equipos
with gr.Row():
with gr.Column():
gr.HTML("""
<div style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white; padding: 1rem; border-radius: 8px; margin-bottom: 0.75rem;">
<h3 style="margin: 0; font-size: 1rem; font-weight: 600;">👥 3. ANALISTAS.xlsx</h3>
<p style="margin: 0.25rem 0 0 0; font-size: 0.75rem; opacity: 0.9;">Equipo de analistas con habilidades</p>
</div>
""")
btn_analistas = gr.Button("📥 Generar Analistas.xlsx", variant="secondary")
file_analistas_plantilla = gr.File(label="Archivo Descargado")
with gr.Column():
gr.HTML("""
<div style="background: linear-gradient(135deg, #ec4899 0%, #db2777 100%); color: white; padding: 1rem; border-radius: 8px; margin-bottom: 0.75rem;">
<h3 style="margin: 0; font-size: 1rem; font-weight: 600;">⚙️ 4. EQUIPOS.xlsx</h3>
<p style="margin: 0.25rem 0 0 0; font-size: 0.75rem; opacity: 0.9;">Equipos de laboratorio disponibles</p>
</div>
""")
btn_equipos = gr.Button("📥 Generar Equipos.xlsx", variant="secondary")
file_equipos_plantilla = gr.File(label="Archivo Descargado")
gr.HTML('</div>')
# Conectar botones
btn_plantillas_zip.click(
fn=crear_plantillas_ejemplo,
inputs=[],
outputs=[file_plantillas_zip]
)
btn_ordenes.click(
fn=crear_plantilla_ordenes,
inputs=[],
outputs=[file_ordenes_plantilla]
)
btn_pasos.click(
fn=crear_plantilla_pasos,
inputs=[],
outputs=[file_pasos_plantilla]
)
btn_analistas.click(
fn=crear_plantilla_analistas,
inputs=[],
outputs=[file_analistas_plantilla]
)
btn_equipos.click(
fn=crear_plantilla_equipos,
inputs=[],
outputs=[file_equipos_plantilla]
)
# ========================================================================
# TAB 5: CRONOGRAMA VISUAL
# ========================================================================
with gr.Tab("📅 Cronograma Visual"):
gr.HTML("""
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; padding: 1.5rem; border-radius: 12px; margin-bottom: 1.5rem;">
<h2 style="margin: 0;">📅 Visualización de Cronograma por Turnos</h2>
<p style="margin: 0.5rem 0 0 0; opacity: 0.95;">Vista diaria organizada por Mañana/Tarde/Noche</p>
</div>
""")
gr.Markdown("### 🎛️ Filtros de Fecha (Opcional)")
with gr.Row():
filtro_fecha_inicio = gr.Textbox(
label="📅 Fecha Inicio",
placeholder="YYYY-MM-DD (ej: 2025-01-15)",
info="Filtra desde esta fecha (dejar vacío para ver todo)",
scale=1
)
filtro_fecha_fin = gr.Textbox(
label="📅 Fecha Fin",
placeholder="YYYY-MM-DD (ej: 2025-01-20)",
info="Filtra hasta esta fecha (dejar vacío para ver todo)",
scale=1
)
btn_filtrar_cronograma = gr.Button(
"🔍 Aplicar Filtros",
variant="secondary",
scale=1
)
cronograma_html = gr.HTML(
value="<div style='padding: 3rem; text-align: center; color: #94a3b8;'>⏳ Ejecuta optimización primero</div>"
)
# Conectar botones
filtro_analista_gantt = gr.State(value="Todos") # Variable dummy para compatibilidad
gantt_html = gr.State(value="") # Variable dummy para compatibilidad
btn_run.click(
fn=ejecutar_optimizacion,
inputs=[
tamano_poblacion, num_generaciones, tasa_mutacion, tasa_crossover,
hora_actual,
peso_incump_esq, peso_incump_ord, peso_tard_esq, peso_tard_ord,
peso_tat_esq, peso_tat_ord, peso_makespan, peso_tard_max,
file_ordenes, file_pasos, file_analistas, file_equipos, file_previous,
manana_ini, manana_fin, tarde_ini, tarde_fin, noche_ini, noche_fin
],
outputs=[resultados_text, btn_descargar_zip, btn_detalle, btn_utilizacion, btn_metricas, btn_cronograma, btn_gantt_analista, btn_gantt_orden, cronograma_html, gantt_html, filtro_analista_gantt]
)
# Conectar filtros de Cronograma Visual
btn_filtrar_cronograma.click(
fn=generar_cronograma_visual,
inputs=[btn_detalle, filtro_fecha_inicio, filtro_fecha_fin],
outputs=[cronograma_html]
)
btn_stop.click(
fn=detener_optimizacion,
inputs=[],
outputs=[status_output]
)
# ============================================================================
# LANZAR APLICACIÓN
# ============================================================================
if __name__ == "__main__":
print("\n" + "="*80)
print("🚀 LANZANDO INTERFAZ GRADIO")
print("="*80)
print("\n🌐 GENERANDO URL PÚBLICA...")
print(" ⏰ Válida por 72 horas")
print(" 🔗 Copia el link y compártelo")
print("\n⏳ Espera unos segundos...")
print("="*80 + "\n")
app.launch(
share=True, # ✅ URL PÚBLICA (válida 72 horas)
inbrowser=False # ❌ No abre navegador automáticamente
)