| |
| |
|
|
| |
| |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| 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 copy |
| import sys |
|
|
| |
| if sys.platform == 'win32': |
| sys.stdout.reconfigure(encoding='utf-8') |
| sys.stderr.reconfigure(encoding='utf-8') |
|
|
| |
| |
| |
| |
| |
| DEBUG_VERBOSE = False |
|
|
| |
| |
| |
| |
| DEBUG_MODE_4_OVERLAPS = False |
| DEBUG_MODE_4 = { |
| 'registrations': [], |
| 'overlaps': [], |
| 'other_mode_registrations': 0, |
| 'verification_logs': [], |
| } |
|
|
| |
| |
| |
|
|
| 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" |
|
|
| |
| |
| |
|
|
| @dataclass |
| class Turno: |
| """Representa un turno de trabajo""" |
| dia_semana: int |
| hora_inicio: str |
| hora_fin: str |
|
|
| 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 |
| 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") |
| |
| |
| |
|
|
| @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 |
| 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 |
| inicio_programado: Optional[datetime] = None |
| fin_programado: Optional[datetime] = None |
| esquema_tipo: Optional[str] = None |
| 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 |
| metadata: Dict[str, Any] = field(default_factory=dict) |
|
|
| def __post_init__(self): |
| """Asignar fecha actual si no se proporciona""" |
| if self.fecha_inicio_planificacion is None: |
| self.fecha_inicio_planificacion = datetime.now() |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| |
|
|
| 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 |
|
|
| |
| |
| |
| 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 "" |
|
|
| |
| |
| |
|
|
| 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', ...} |
| """ |
| |
| 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)]) |
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
|
|
| |
| print("\nValidando columnas de archivos...") |
|
|
| |
| 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)}") |
|
|
| |
| 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") |
|
|
| |
| |
| 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) |
| }) |
|
|
| |
| |
| df_esquemas["ID_UNICO"] = (df_esquemas["ORDEN_ID"].astype(str) + |
| "-" + |
| df_esquemas["ESQUEMA_ID"].astype(str)) |
| df_esquemas["ID"] = df_esquemas["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") |
|
|
| |
| |
| |
| 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") |
|
|
|
|
| |
| |
| |
| 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() |
|
|
| |
| 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() |
|
|
| |
| 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") |
|
|
| |
| |
| if "ID" not in df_pasos.columns and "PASO_ID" not in df_pasos.columns: |
| |
| 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"}) |
|
|
| |
| |
| |
| 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"] = "" |
|
|
|
|
| |
| |
| |
| 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() |
|
|
| |
| df_ana["HABILIDADES"] = df_ana["HABILIDADES"].apply( |
| lambda s: ", ".join([norm_upper(x) for x in list_from_comma(s)]) |
| ) |
|
|
| |
| |
| 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', |
| 'ACTIVO': 'first', |
| }) |
|
|
| if DEBUG_VERBOSE: |
| print(f" 📋 Analistas únicos después de agrupar: {len(df_ana_grouped)}") |
|
|
| df_ana = df_ana_grouped |
|
|
| |
| if "ID" not in df_ana.columns: |
| ids = [] |
| for nombre in df_ana["NOMBRE"]: |
| nombre_str = str(nombre).strip() |
| |
| partes = nombre_str.upper().split() |
| if len(partes) >= 2: |
| |
| slug = partes[0][:2] + partes[1][:2] |
| elif len(partes) == 1: |
| |
| slug = partes[0][:4].ljust(4, 'X') |
| else: |
| slug = "ANON" |
|
|
| |
| id_analista = slug + "01" |
| ids.append(id_analista) |
|
|
| df_ana["ID"] = ids |
|
|
| |
| df_ana["TURNOS"] = df_ana["TURNO"].apply(lambda x: build_turnos_string(x, turnos_config)) |
| print(f" ✅ {len(df_ana)} analistas procesados") |
|
|
| |
| |
| |
| 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" |
| ]] |
|
|
| |
| 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") |
|
|
|
|
| |
| |
| |
| 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)") |
|
|
| |
| |
| |
| 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") |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| 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: |
| |
| prioridad_raw = row.get('PRIORIDAD', 1) |
| try: |
| prioridad_val = int(prioridad_raw) |
| |
| 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: |
| |
| 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': 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', '')), |
| 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: |
| |
| |
| |
| 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')) |
|
|
| |
| 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 |
|
|
| |
| modo_int_raw = row.get('MODO_INTERACCION') |
| if pd.isna(modo_int_raw) or modo_int_raw == '': |
| modo_int = 0 |
| else: |
| |
| if isinstance(modo_int_raw, str) and not modo_int_raw.isdigit(): |
| |
| 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)) |
| |
| modo_int = max(0, min(5, modo_int)) |
| except: |
| modo_int = 0 |
|
|
| |
| 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 |
| else: |
| tiempo_interaccion = float(duracion_raw) |
| elif modo_int > 0: |
| |
| tiempo_interaccion = 5.0 |
| else: |
| tiempo_interaccion = 0.0 |
| else: |
| tiempo_interaccion = float(tiempo_interaccion_raw) |
|
|
| |
| frecuencia_raw = row.get('FRECUENCIA_INTERACCION') |
| if pd.isna(frecuencia_raw) or frecuencia_raw == '': |
| frecuencia_interaccion = 1 |
| else: |
| frecuencia_interaccion = int(float(frecuencia_raw)) |
|
|
| |
| uniformidad_raw = row.get('UNIFORMIDAD', 1) |
| if pd.isna(uniformidad_raw) or uniformidad_raw == '': |
| uniformidad = True |
| else: |
| |
| 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) |
|
|
| |
| 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, |
| 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...") |
|
|
| |
| for idx, row in df.iterrows(): |
| try: |
| |
| skill_raw = row.get('HABILIDAD') |
| if pd.notna(skill_raw) and str(skill_raw).strip(): |
| |
| habilidades = [str(skill_raw).strip().upper()] |
| else: |
| |
| habilidades = [s.strip().upper() for s in parsear_lista(row.get('HABILIDADES', '')) if s and str(s).strip()] |
|
|
| turnos = [] |
| |
| turnos_raw = row.get('TURNOS') or row.get('TURNO') |
|
|
| if DEBUG_VERBOSE and idx < 2: |
| 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() |
|
|
| |
| turno_map = { |
| 'MAÑANA': ('07:00', '14:00'), |
| 'MANANA': ('07:00', '14:00'), |
| 'TARDE': ('14:00', '22:00'), |
| 'NOCHE': ('22:00', '07:00'), |
| } |
|
|
| |
| if turnos_str in turno_map: |
| hora_inicio, hora_fin = turno_map[turnos_str] |
| for dia in range(5): |
| 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: |
| |
| |
| for turno_item in turnos_str.split(','): |
| turno_item = turno_item.strip() |
| if not turno_item: |
| continue |
|
|
| |
| if ':' in turno_item: |
| try: |
| |
| primer_colon = turno_item.index(':') |
| dia_str = turno_item[:primer_colon] |
| rango_horario = turno_item[primer_colon+1:] |
|
|
| dia = int(dia_str) - 1 |
|
|
| |
| 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: |
| |
| tipo_str = str(row.get('TIPO', 'OTRO')).upper().strip() |
|
|
| |
| 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, |
| 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, |
| **kwargs |
| ) -> SistemaPlanificacion: |
| """Crea un SistemaPlanificacion completo desde un archivo Excel""" |
| |
| ordenes, esquemas, pasos, analistas, equipos = cargar_datos_excel(ruta_archivo, **kwargs) |
|
|
| |
| sistema = SistemaPlanificacion( |
| fecha_inicio_planificacion=fecha_inicio |
| ) |
|
|
| |
| for analista in analistas: |
| sistema.agregar_analista(analista) |
|
|
| for equipo in equipos: |
| sistema.agregar_equipo(equipo) |
|
|
| |
|
|
| |
| 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: |
| |
| |
| |
| |
| |
| for esquema in esquemas_dict[paso.esquema_id]: |
| |
| cloned_paso = copy.deepcopy(paso) |
| try: |
| |
| cloned_paso.metadata = dict(cloned_paso.metadata or {}) |
| cloned_paso.metadata['original_id'] = paso.id |
| except Exception: |
| |
| cloned_paso.metadata = {'original_id': paso.id} |
| |
| 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}") |
|
|
|
|
| |
| |
| |
| print("\n🔍 Validando cobertura de habilidades...") |
| esquemas_sin_analista = [] |
| |
| for orden in sistema.ordenes: |
| for esquema in orden.esquemas: |
| |
| esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.id) |
| esquema_id_upper = str(esquema_id_original).strip().upper() |
| |
| |
| 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]: |
| 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 |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| @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...") |
|
|
| |
| 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') |
|
|
| |
| 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}') |
|
|
| |
| if not esquema.pasos: |
| reporte.agregar_warning( |
| 'SIN_PASOS', |
| 'ESQUEMA', |
| esquema.id, |
| f'Esquema en orden {orden.id} sin pasos asociados en hoja PASOS' |
| ) |
| |
| 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') |
|
|
| |
| ids_esquema = set() |
| for orden in sistema.ordenes: |
| for esq in orden.esquemas: |
| |
| 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' |
| ) |
|
|
| |
| 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: |
| |
| 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 |
|
|
| |
| |
| |
|
|
| @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) |
| |
| batch_queues: Dict[str, List[Dict]] = field(default_factory=dict) |
| ultimo_equipo_usado: Dict[str, Dict] = field(default_factory=dict) |
| |
| batch_manual_buffers: Dict[str, Dict] = field(default_factory=dict) |
| |
| |
|
|
| 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): |
| |
|
|
| |
| 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 |
| }) |
|
|
| |
| if contexto_batch: |
| esquema_id = contexto_batch.get('esquema_id') |
|
|
| |
| |
| |
| |
| equipo_nuevo = contexto_batch.get('equipo') |
| if (equipo_nuevo and esquema_id and |
| equipo_nuevo.modo in [ModoEquipo.BATCH, ModoEquipo.BATCH_SCALING]): |
| |
| equipo_existente_id = None |
| |
| 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: |
| |
| 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 |
|
|
| |
| 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): |
| |
| 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: |
| |
| if DEBUG_MODE_4_OVERLAPS: |
| DEBUG_MODE_4['verification_logs'][-1]['resultado'] = 'ALLOWED_BATCH_LEGITIMO' |
| continue |
| |
| |
| |
| |
|
|
| |
| |
| orden_id_contexto = contexto_batch.get('orden_id') |
| paso_id_contexto = contexto_batch.get('paso_id') |
|
|
| |
| 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 |
| abs((asig.inicio - inicio).total_seconds()) < 60): |
| |
| |
| if DEBUG_MODE_4_OVERLAPS: |
| DEBUG_MODE_4['verification_logs'][-1]['resultado'] = 'ALLOWED_BATCH_MANUAL' |
| continue |
|
|
| |
| if DEBUG_MODE_4_OVERLAPS: |
| DEBUG_MODE_4['verification_logs'][-1]['resultado'] = 'REJECTED_NOT_AVAILABLE' |
| return False |
|
|
| |
| if DEBUG_MODE_4_OVERLAPS and len(self.asignaciones_analistas) > 0: |
| |
| 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. |
| """ |
| |
| asignaciones_actuales = [] |
|
|
| for asig in self.asignaciones_analistas: |
| if asig.recurso_id == analista_id: |
| |
| if asig.inicio <= desde < asig.fin: |
| |
| asignaciones_actuales.append(asig.fin) |
|
|
| |
| if not asignaciones_actuales: |
| return None |
|
|
| |
| 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. |
| """ |
| |
| asignaciones_actuales = [] |
|
|
| for asig in self.asignaciones_equipos: |
| if asig.recurso_id == equipo_id: |
| |
| if asig.inicio <= desde < asig.fin: |
| |
| asignaciones_actuales.append(asig.fin) |
|
|
| |
| if not asignaciones_actuales: |
| return None |
|
|
| |
| 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 |
|
|
| |
| |
| |
|
|
| 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'] |
|
|
| |
| tiempo_transcurrido = (timestamp_actual - primera_muestra_time).total_seconds() / 60 |
|
|
| if tiempo_transcurrido < batch_manual_wait: |
| |
| return buffer_info |
| else: |
| |
| 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: |
| |
| inicio_batch = timestamp_actual + timedelta(minutes=batch_manual_wait) |
| self.batch_manual_buffers[buffer_key] = { |
| 'primera_muestra_timestamp': timestamp_actual, |
| 'muestras': [muestra_id], |
| 'inicio_batch': inicio_batch, |
| 'orden_id': orden_id, |
| 'esquema_id': esquema_id, |
| 'paso_id': paso_id |
| } |
| else: |
| |
| 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 |
| batch_id: Optional[str] = None |
| handoff_count: int = 0 |
| modo_equipo: Optional[str] = None |
|
|
|
|
| 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 '', |
| 'MODO_EQUIPO': self.modo_equipo or '', |
| 'FECHA_INICIO': self.fecha_inicio, |
| 'FECHA_FIN': self.fecha_fin, |
| 'FECHA_INICIO_REAL': None, |
| 'FECHA_FIN_REAL': None, |
| '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)) |
|
|
| |
| 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 |
|
|
| |
| return duracion_total |
|
|
| |
| |
| |
|
|
| 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 |
| """ |
| |
| if modo > 0 and tiempo_interaccion <= 0: |
| tiempo_interaccion = 5.0 |
|
|
| momentos = [] |
| fin_tarea = inicio + timedelta(minutes=duracion) |
|
|
| if modo == 0: |
| |
| return [] |
|
|
| elif modo == 1: |
| |
| fin_interaccion = inicio + timedelta(minutes=tiempo_interaccion) |
| momentos.append((inicio, fin_interaccion)) |
|
|
| elif modo == 2: |
| |
| inicio_interaccion = fin_tarea - timedelta(minutes=tiempo_interaccion) |
| momentos.append((inicio_interaccion, fin_tarea)) |
|
|
| elif modo == 3: |
| |
| 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: |
| |
| |
| if frecuencia_interaccion <= 0: |
| frecuencia_interaccion = 1 |
|
|
| |
| if frecuencia_interaccion == 1: |
| |
| fin_interaccion = inicio + timedelta(minutes=tiempo_interaccion) |
| momentos.append((inicio, fin_interaccion)) |
| else: |
| |
| intervalo_minutos = duracion / (frecuencia_interaccion - 1) |
|
|
| for i in range(frecuencia_interaccion): |
| |
| minuto_revision = i * intervalo_minutos |
| momento_inicio = inicio + timedelta(minutes=minuto_revision) |
| momento_fin = momento_inicio + timedelta(minutes=tiempo_interaccion) |
|
|
| |
| if momento_fin > fin_tarea: |
| momento_fin = fin_tarea |
|
|
| |
| if momento_inicio < fin_tarea: |
| momentos.append((momento_inicio, momento_fin)) |
|
|
| else: |
| |
| 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() |
|
|
| |
| 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]: |
| 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')}") |
|
|
| |
| |
| 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) |
|
|
| |
| 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')}" |
| |
| 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}") |
|
|
| |
| 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 |
|
|
| |
| 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: |
| |
| if verbose: |
| print(f" Momento {idx+1}: ❌ NO hay analista disponible") |
| return None |
|
|
| analistas_asignados.append(analista_encontrado) |
|
|
| |
| 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: |
| |
| |
|
|
| h_i_parts = [int(x) for x in turno.hora_inicio.split(':')] |
| h_f_parts = [int(x) for x in turno.hora_fin.split(':')] |
|
|
| |
| 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]) |
|
|
| |
| 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 |
|
|
| |
| if es_turno_nocturno: |
| dia_siguiente = (turno.dia_semana + 1) % 7 |
| if inicio_momento.weekday() == dia_siguiente: |
| |
| 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) |
| |
| esquema_id_original = esquema.metadata.get('esquema_id_original', esquema.id) |
| esquema_id_up = str(esquema_id_original).strip().upper() |
|
|
| |
| 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 [])} |
| ): |
| |
| dentro_turno = True |
| if analista_pref.turnos: |
| dentro_turno = False |
| for turno in analista_pref.turnos: |
| try: |
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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]}") |
|
|
| |
| dentro_turno = 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) |
|
|
| |
| 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) |
|
|
| |
| equipos_compatibles = [ |
| e for e in equipos |
| if e.activo and |
| e.tipo.upper() == paso.tipo_equipo.upper() and |
| (not paso.equipos_posibles or e.id in paso.equipos_posibles) |
| ] |
|
|
| for equipo in equipos_compatibles: |
| |
| if not estado.equipo_disponible(equipo.id, inicio, fin): |
| continue |
| |
| ocupadas = 0 |
| for asign in estado.asignaciones_equipos: |
| if asign.recurso_id == equipo.id and asign.esta_ocupado(inicio, fin): |
| ocupadas += 1 |
| |
| 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 |
|
|
| |
| 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: |
| |
| 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 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, |
| |
| 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() |
|
|
| |
| |
| |
| if horizonte_dinamico: |
| |
| 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, |
| 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") |
|
|
| |
| horizonte_global = fecha_inicio + timedelta(days=horizonte_inicial_calculado) |
| horizonte_absoluto = fecha_inicio + timedelta(days=horizonte_maximo_dias) |
| ultima_tarea_programada_fin = fecha_inicio |
|
|
| |
| 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 = [] |
| for _, row in previous_plan.iterrows(): |
| |
| |
| fecha_inicio_real = row.get('FECHA_INICIO_REAL') |
| fecha_fin_real = row.get('FECHA_FIN_REAL') |
|
|
| |
| 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 |
|
|
| |
| 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, |
| fin=fecha_fin, |
| paso_id=str(row['PASO']), |
| orden_id=str(row['ORDEN']), |
| esquema_id=str(row['ESQUEMA']), |
| muestra_id=str(row.get('MUESTRA', '1')) |
| ) |
| |
| |
| 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, |
| fin=fecha_fin, |
| paso_id=str(row['PASO']), |
| orden_id=str(row['ORDEN']), |
| esquema_id=str(row['ESQUEMA']), |
| muestra_id=str(row['MUESTRA']) |
| ) |
| |
| estado.registrar_asignacion_equipo(asig_equipo) |
|
|
| |
| paso_id = f"{row['ORDEN']}_{row['ESQUEMA']}_{row['PASO']}_M{row['MUESTRA']}" |
| estado.marcar_paso_completado(paso_id, fecha_fin) |
|
|
| |
| if row.get('ESTADO') == 'EN_PROCESO': |
| pasos_en_proceso_cargados.append(paso_id) |
|
|
| |
| 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]: |
| print(f" - {paso_id_debug}") |
| if len(pasos_en_proceso_cargados) > 5: |
| print(f" ... y {len(pasos_en_proceso_cargados) - 5} más") |
|
|
| |
| if cromosoma is None: |
| |
| |
| |
| print("🧬 Generando cromosoma segmentado por prioridad (1-3) y urgencia (1-3)...") |
| cromosoma = [] |
|
|
| |
| ordenes_ordenadas = sorted( |
| sistema.ordenes, |
| key=lambda o: ( |
| -getattr(o, 'prioridad_global', 1), |
| getattr(o, 'fecha_recepcion', datetime.min) |
| ) |
| ) |
|
|
| for orden in ordenes_ordenadas: |
| |
| esquemas_ordenados = sorted( |
| orden.esquemas, |
| key=lambda esq: ( |
| -getattr(esq.urgencia, 'value', 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...") |
|
|
| |
| |
| |
| |
| 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}" |
|
|
| |
| if grupo_key not in grupos_tareas: |
| grupos_tareas[grupo_key] = [] |
|
|
| |
| orden = sistema.obtener_orden(orden_id) |
| prioridad = getattr(orden, 'prioridad_global', 1) if orden else 1 |
|
|
| |
| urgencia = 1 |
| 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 |
|
|
| |
| prioridades_grupos[grupo_key] = (prioridad, urgencia, idx) |
|
|
| grupos_tareas[grupo_key].append(tarea_id) |
| except: |
| |
| if 'default' not in grupos_tareas: |
| grupos_tareas['default'] = [] |
| prioridades_grupos['default'] = (0, 0, 999999) |
| grupos_tareas['default'].append(tarea_id) |
|
|
| |
| |
| grupos_ordenados = sorted( |
| grupos_tareas.keys(), |
| key=lambda gk: ( |
| -prioridades_grupos.get(gk, (0, 0, 999999))[0], |
| -prioridades_grupos.get(gk, (0, 0, 999999))[1], |
| prioridades_grupos.get(gk, (0, 0, 999999))[2] |
| ) |
| ) |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| paso_completo_id = f"{orden_id}_{esquema_id}_{paso_id}_M{muestra_id}" |
|
|
| |
| 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}") |
|
|
| |
| 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}") |
| |
| 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}") |
|
|
| |
| |
| |
| inicio = calcular_inicio_mas_temprano(paso, esquema, muestra_id, estado, fecha_inicio, orden_id, esquema_id) |
|
|
| |
| if reintentos_adaptativos: |
| |
| tiene_analista_capacitado = False |
| tiene_equipo_compatible = False |
|
|
| |
| |
| 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() |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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_analista_capacitado = True |
| break |
| else: |
| tiene_analista_capacitado = True |
|
|
| |
| 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 |
|
|
| |
| |
| 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}" |
| ) |
| |
| 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 |
|
|
| 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 |
|
|
| |
| if tiene_analista_capacitado and tiene_equipo_compatible: |
| max_reintentos = max_reintentos_base * 3 |
| else: |
| max_reintentos = max_reintentos_base |
| else: |
| max_reintentos = max_reintentos_base |
|
|
| |
| if horizonte_dinamico: |
| |
| horizonte_tarea = max( |
| horizonte_global, |
| ultima_tarea_programada_fin + timedelta(days=1) |
| ) |
| |
| horizonte_tarea = min(horizonte_tarea, horizonte_absoluto) |
| else: |
| |
| horizonte_tarea = fecha_inicio + timedelta(days=horizonte_inicial_calculado) |
|
|
| reintento = 0 |
| tarea_programada = False |
| contador_fallos_analista = 0 |
| MAX_FALLOS_ANALISTA = 50 |
|
|
| while not tarea_programada and reintento < max_reintentos and inicio < horizonte_tarea: |
| reintento += 1 |
| verbose_local = False |
|
|
| |
| 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 |
|
|
| |
| |
|
|
| |
| |
| |
| 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) |
|
|
| if equipo: |
| |
| equipo_asignado = equipo.id |
|
|
| |
| 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] |
| |
| 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'})") |
|
|
| |
| estado.ultimo_equipo_usado[paso_key] = { |
| 'equipo_id': equipo.id, |
| 'esquema_id': esquema_id, |
| 'tiempo': inicio |
| } |
|
|
| |
| duracion = calcular_duracion_paso(paso, 1, equipo, semilla, incluir_setup=tiempo_setup) |
| else: |
| |
|
|
| |
| 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: |
| |
| print(f"[Debug] No se encontraron equipos candidatos para el paso '{paso.id}' que requiere el tipo '{tipo_equipo_upper}'.") |
| break |
|
|
| |
| proxima_disponibilidad = None |
|
|
| for eq_candidato in equipos_candidatos: |
| cuando_libre = estado.cuando_se_libera_equipo(eq_candidato.id, inicio) |
|
|
| if cuando_libre: |
| |
| if proxima_disponibilidad is None or cuando_libre < proxima_disponibilidad: |
| proxima_disponibilidad = cuando_libre |
|
|
| if proxima_disponibilidad: |
| |
| inicio = proxima_disponibilidad |
| if verbose: |
| print(f" ⏭️ Equipo ocupado. Saltando a {inicio.strftime('%Y-%m-%d %H:%M')}") |
| continue |
| else: |
| |
| |
| inicio = inicio + timedelta(minutes=5) |
| if verbose: |
| print(f" ⏭️ Avanzando 5 min a {inicio.strftime('%Y-%m-%d %H:%M')}") |
| continue |
| else: |
| |
| duracion = calcular_duracion_paso(paso, 1, None, semilla, incluir_setup=0) |
|
|
| |
| |
| |
| es_batch_manual = False |
| batch_manual_info = None |
|
|
| if not requiere_equipo and batch_manual_wait > 0: |
| |
| batch_manual_info = estado.verificar_batch_manual(orden_id, esquema_id, paso.id, inicio, batch_manual_wait) |
|
|
| if batch_manual_info: |
| |
| es_batch_manual = True |
| inicio = batch_manual_info['inicio_batch'] |
| estado.agregar_a_batch_manual(orden_id, esquema_id, paso.id, muestra_id, inicio, batch_manual_wait) |
| else: |
| |
| es_batch_manual = True |
| |
| 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 |
|
|
| |
| |
| |
| analista_asignado = None |
| momentos_interaccion = [] |
| |
| 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: |
| |
| |
| |
| |
| |
|
|
| |
| 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)") |
|
|
| |
| 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: |
| |
| contexto_batch = {'batch_manual': True, 'esquema_id': esquema_id, 'orden_id': orden_id, 'paso_id': paso.id} |
|
|
| |
| verbose_local = False |
| analistas_ids = obtener_analista_para_momentos(paso, esquema, momentos_interaccion, estado, sistema.analistas, verbose_local, contexto_batch) |
|
|
| if analistas_ids: |
| |
| |
| analista_asignado = analistas_ids[0] if isinstance(analistas_ids, list) else analistas_ids |
| contador_fallos_analista = 0 |
| else: |
| |
| contador_fallos_analista += 1 |
|
|
| |
| 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 |
| |
| if _verificar_turno(an, inicio, fin_momento_prueba, verbose_debug=False): |
| todos_fuera_turno = False |
| break |
|
|
| |
| if todos_fuera_turno: |
| |
| proximo_turno_encontrado = False |
|
|
| |
| for dia_offset in range(0, 8): |
| 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 |
| |
| 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: |
| |
| |
| 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 |
|
|
| |
| |
|
|
| proxima_liberacion = None |
| analista_proximo = None |
|
|
| for an in analistas_capacitados: |
| if not an.turnos: |
| continue |
|
|
| |
| if _verificar_turno(an, inicio, fin_momento_prueba, verbose_debug=False): |
| |
| cuando_libre = estado.cuando_se_libera_analista(an.id, inicio) |
|
|
| if cuando_libre: |
| |
| momento_fin_tarea = cuando_libre + timedelta(minutes=duracion) |
|
|
| if _verificar_turno(an, cuando_libre, momento_fin_tarea, verbose_debug=False): |
| |
| if proxima_liberacion is None or cuando_libre < proxima_liberacion: |
| proxima_liberacion = cuando_libre |
| analista_proximo = an.nombre |
| |
|
|
| if proxima_liberacion: |
| |
| 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: |
| |
| |
| if verbose_local: |
| print(f" ⚠️ Ningún analista se libera a tiempo hoy. Buscando siguiente día con turno...") |
|
|
| |
| proximo_turno_encontrado = False |
| for dia_offset in range(1, 8): |
| 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 |
| |
| 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: |
| |
| 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 |
|
|
| |
| |
| |
| fecha_fin = inicio + timedelta(minutes=duracion) |
|
|
| |
| 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 |
|
|
| |
| |
| |
| 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: |
| |
| |
| batch_id_generado = f"MANUAL_{orden_id}_{esquema_id}_{paso.id}_{inicio.strftime('%Y%m%d_%H%M')}" |
| modo_equipo_str = "Batch Manual" |
|
|
| |
| 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, |
| batch_id=batch_id_generado |
| ) |
|
|
| tareas_programadas.append(tarea) |
| tareas_procesadas += 1 |
|
|
| |
| if horizonte_dinamico and fecha_fin > ultima_tarea_programada_fin: |
| ultima_tarea_programada_fin = fecha_fin |
| nuevo_horizonte = min( |
| fecha_fin + timedelta(days=1), |
| 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')}") |
|
|
| |
| if analista_asignado: |
| |
| if momentos_interaccion and analistas_ids: |
| if DEBUG_VERBOSE: |
| print(f" 🔍 Registrando analista para {paso_id}: modo={modo_interaccion_val}") |
|
|
| |
| for idx, (momento_inicio, momento_fin) in enumerate(momentos_interaccion): |
| |
| 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}", |
| orden_id=orden_id, |
| esquema_id=esquema_id, |
| muestra_id=muestra_id |
| ) |
| estado.registrar_asignacion_analista(asig) |
|
|
| |
| 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: |
| |
| 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 |
|
|
| 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") |
|
|
| |
| 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) |
|
|
| |
| 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))}") |
|
|
| |
| if not df_cronograma.empty and 'ANALISTA_ASIGNADO' in df_cronograma.columns: |
| |
| analista_id_to_nombre = {str(a.id): a.nombre for a in sistema.analistas} |
| |
| df_cronograma['ANALISTA_NOMBRE'] = df_cronograma['ANALISTA_ASIGNADO'].map(analista_id_to_nombre) |
| |
| 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") |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| @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 |
| 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 |
| tardanza_total_ordenes_horas: float |
| tat_real_total_esquemas_horas: float |
| tat_real_total_ordenes_horas: float |
| 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""" |
| |
| 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) |
| ] |
|
|
| 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 |
|
|
| |
| tareas_dict = {} |
| for _, tarea in df_cronograma.iterrows(): |
| clave = f"{tarea['PASO']}_M{tarea['MUESTRA']}" |
| tareas_dict[clave] = tarea |
|
|
| |
| 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: |
| |
| 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] |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 = { |
| |
| 'incumplimiento_esquemas': 10000.0, |
| 'incumplimiento_ordenes': 10000.0, |
|
|
| |
| 'tardanza_total_esquemas': 500.0, |
| 'tardanza_total_ordenes': 500.0, |
|
|
| |
| 'tat_real_total_esquemas': 10.0, |
| 'tat_real_total_ordenes': 10.0, |
|
|
| |
| 'makespan': 1.0, |
| 'tardanza_maxima': 5.0, |
| } |
|
|
| |
| |
| |
| |
| |
|
|
| fitness = 0.0 |
|
|
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| fitness += pesos['tardanza_total_esquemas'] * metricas.tardanza_total_esquemas_horas |
| fitness += pesos['tardanza_total_ordenes'] * metricas.tardanza_total_ordenes_horas |
|
|
| |
| |
| |
| fitness += pesos['tat_real_total_esquemas'] * metricas.tat_real_total_esquemas_horas |
| fitness += pesos['tat_real_total_ordenes'] * metricas.tat_real_total_ordenes_horas |
|
|
| |
| |
| |
| 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: |
| |
| 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]: |
| 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]: |
| 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, |
| |
| 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, |
| |
| 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 |
|
|
| |
| 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)) |
|
|
| |
| 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 |
| 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") |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| 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 |
| 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: |
| |
| 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) |
|
|
| |
| requiere_maquina = bool(paso.equipos_posibles) or paso.tipo_equipo.upper() != 'OTRO' |
|
|
| |
| 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 |
|
|
| |
| 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, |
| 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) |
|
|
| |
|
|
| 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) |
|
|
| |
|
|
| for gen in todos_genes: |
| cromosoma.agregar_gen(gen) |
|
|
| |
|
|
| es_valido, errores = cromosoma.validar_secuencias() |
| if not es_valido: |
| cromosoma.reparar_secuencias() |
| |
|
|
| 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) |
|
|
| |
| |
| |
|
|
| 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""" |
| |
| 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) |
|
|
| |
| if n == 0: |
| return Cromosoma(), Cromosoma() |
| if n == 1: |
| return padre1.copy(), padre2.copy() |
|
|
| |
| punto1 = random.randint(0, n - 1) |
| punto2 = random.randint(punto1 + 1, n + 1) |
|
|
| |
| 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} |
|
|
| |
| ids_p1 = set(genes_p1_dict.keys()) |
| ids_p2 = set(genes_p2_dict.keys()) |
|
|
| if ids_p1 != ids_p2: |
| |
| logging.warning(f"Cruce omitido: padres con genes diferentes") |
| return padre1.copy(), padre2.copy() |
|
|
| |
| genes_hijo1 = [None] * n |
| genes_hijo2 = [None] * n |
|
|
| |
| genes_hijo1[punto1:punto2] = padre1.genes[punto1:punto2] |
| genes_hijo2[punto1:punto2] = padre2.genes[punto1:punto2] |
|
|
| |
| 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]} |
|
|
| |
| idx_hijo1 = 0 |
| for gen in padre2.genes: |
| gen_id = gen.get_id_completo() |
| if gen_id not in ids_en_hijo1: |
| |
| 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) |
|
|
| |
| idx_hijo2 = 0 |
| for gen in padre1.genes: |
| gen_id = gen.get_id_completo() |
| if gen_id not in ids_en_hijo2: |
| |
| 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) |
|
|
| |
| 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() |
|
|
| |
| hijo1 = Cromosoma() |
| hijo2 = Cromosoma() |
| hijo1.genes = genes_hijo1 |
| hijo2.genes = genes_hijo2 |
|
|
| hijo1._construir_indices() |
| hijo2._construir_indices() |
|
|
| |
| 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""" |
| |
| 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() |
|
|
| |
| esquemas_p1 = set(padre1._indice_por_esquema.keys()) |
| esquemas_p2 = set(padre2._indice_por_esquema.keys()) |
|
|
| |
| if esquemas_p1 != esquemas_p2: |
| logging.warning(f"Cruce omitido: padres con esquemas diferentes") |
| return padre1.copy(), padre2.copy() |
|
|
| hijo1 = Cromosoma() |
| hijo2 = Cromosoma() |
|
|
| |
| for esquema_muestra_id in esquemas_p1: |
| if random.random() < 0.5: |
| |
| 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: |
| |
| 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) |
|
|
| |
| 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() |
|
|
| |
| 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 |
|
|
| |
| esq1, esq2 = random.sample(esquemas, 2) |
|
|
| |
| 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])] |
|
|
| |
| 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 |
|
|
| |
| 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]: |
| |
| 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]: |
| |
| 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: |
| |
| 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 |
|
|
| |
| 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] |
|
|
| |
| 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])] |
|
|
| |
| esquemas_invertidos = list(reversed(esquemas_a_invertir)) |
|
|
| |
| 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: |
| |
| mapeo[gen.get_id_completo()] = gen |
|
|
| |
| |
| 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: |
| |
| |
| 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: |
| |
| nuevos_genes.extend([mutado.genes[i] for i in sorted(mutado._indice_por_esquema[esq_id])]) |
| esquemas_procesados.add(esq_id) |
| |
|
|
| 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") |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| @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 |
|
|
| @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, |
| |
| 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})") |
|
|
| |
| 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") |
|
|
| |
| 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, |
| |
| 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") |
|
|
| |
| historial = [] |
| mejor_global = min(poblacion, key=lambda c: c.fitness) |
| generaciones_sin_mejora = 0 |
|
|
| |
| 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 |
|
|
| |
| if config.verbose: |
| |
| 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}") |
|
|
| |
| 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 |
|
|
| |
| num_elites = int(config.tamanio_poblacion * config.tasa_elitismo) |
| nueva_poblacion = poblacion[:num_elites] |
|
|
| |
| while len(nueva_poblacion) < config.tamanio_poblacion: |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|
| |
| 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() |
|
|
| |
| 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() |
|
|
| |
| 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, |
| |
| 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})" |
|
|
| |
| 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...") |
|
|
| |
| 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, |
| |
| 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 |
| ) |
|
|
| |
| print(f"\n📅 Analizando solapamientos en cronograma final...") |
| analisis_solapamientos = detectar_solapamientos_cronograma(resultado_final.cronograma, sistema) |
| |
| mostrar_todos = analisis_solapamientos['total_solapamientos'] > 0 |
| imprimir_reporte_solapamientos(analisis_solapamientos, mostrar_todos=mostrar_todos) |
|
|
| |
| 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) |
|
|
| |
| 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'])}") |
|
|
| |
| 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)}") |
|
|
| |
| 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 |
|
|
| |
| 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}") |
|
|
| |
| 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] |
| |
| 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 |
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
|
|
| |
| equipos_dict = {} |
| if sistema: |
| for eq in sistema.equipos: |
| equipos_dict[eq.id] = eq |
|
|
| |
| analistas_unicos = df_con_analista['ANALISTA_ASIGNADO'].unique() |
|
|
| for analista in analistas_unicos: |
| |
| tareas_analista = df_con_analista[df_con_analista['ANALISTA_ASIGNADO'] == analista].sort_values('FECHA_INICIO') |
|
|
| |
| tareas_list = tareas_analista.to_dict('records') |
|
|
| |
| tareas_con_momentos = [] |
| for tarea in tareas_list: |
| momentos = calcular_momentos_interaccion_desde_tarea(tarea) |
| tareas_con_momentos.append({ |
| 'tarea': tarea, |
| 'momentos': 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'] |
|
|
| |
| hay_solapamiento = False |
| tiempo_solapado_total = 0 |
|
|
| for momento1_inicio, momento1_fin in momentos1: |
| for momento2_inicio, momento2_fin in momentos2: |
| |
| if momento1_inicio < momento2_fin and momento2_inicio < momento1_fin: |
| hay_solapamiento = True |
| |
| 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: |
| |
| 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 |
|
|
| |
| |
| |
| |
| |
| |
| 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]: |
| |
| 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: |
| es_batch_legitimo = True |
| break |
| if es_batch_legitimo: |
| break |
|
|
| |
| |
| |
| |
| |
| |
| 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): |
| |
| 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: |
| es_batch_legitimo = True |
| break |
| if es_batch_legitimo: |
| break |
|
|
| if es_batch_legitimo: |
| |
| batches_legitimos_excluidos += 1 |
| continue |
|
|
| |
| 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 |
| } |
|
|
|
|
| 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 {} |
|
|
| |
| 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 |
|
|
| |
| 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)") |
|
|
| |
| 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() |
|
|
| |
| 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}") |
|
|
| |
| momentos1 = solap['tarea1'].get('momentos', []) |
| if momentos1: |
| print(f" Momentos: {len(momentos1)} momento(s)") |
| for idx, (mi, mf) in enumerate(momentos1[:3], 1): |
| 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() |
|
|
| |
| 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}") |
|
|
| |
| momentos2 = solap['tarea2'].get('momentos', []) |
| if momentos2: |
| print(f" Momentos: {len(momentos2)} momento(s)") |
| for idx, (mi, mf) in enumerate(momentos2[:3], 1): |
| 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() |
|
|
| |
| 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") |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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' |
| ]) |
|
|
| |
| inicio_global = df_final['FECHA_INICIO'].min() |
| fin_global = df_final['FECHA_FIN'].max() |
| makespan_horas = (fin_global - inicio_global).total_seconds() / 3600 |
|
|
| |
| equipos_en_uso = df_final[df_final['EQUIPO_ASIGNADO'].notna()].copy() |
| uso_por_equipo = equipos_en_uso.groupby('EQUIPO_ASIGNADO').agg({ |
| 'DURACION_REAL': 'sum', |
| 'EQUIPO_ASIGNADO': 'count' |
| }).rename(columns={'DURACION_REAL': 'HORAS_OCUPADAS', 'EQUIPO_ASIGNADO': 'NUM_TAREAS'}) |
|
|
| |
| equipos_dict = {str(eq.id): eq.tipo for eq in sistema.equipos} |
|
|
| |
| 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 |
| num_tareas = int(uso_por_equipo.loc[equipo_id, 'NUM_TAREAS']) |
| else: |
| horas_ocupadas = 0.0 |
| num_tareas = 0 |
|
|
| |
| 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: |
| |
| 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) |
| ] |
|
|
| if tareas_esquema.empty: |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| |
|
|
| 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 = [ |
| '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() |
|
|
| |
| 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 |
|
|
| |
| 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' |
| }) |
|
|
| |
| df_limpio.to_excel(writer, sheet_name='CRONOGRAMA', index=False, startrow=2) |
| worksheet = writer.sheets['CRONOGRAMA'] |
|
|
| |
| worksheet.merge_range(0, 0, 0, len(columnas_esenciales)-1, |
| '📋 DETALLE DE EJECUCIÓN - PLAN DE TRABAJO', title_format) |
|
|
| |
| 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) |
|
|
| |
| for col_num, col_name in enumerate(columnas_esenciales): |
| worksheet.write(2, col_num, col_name, header_format) |
|
|
| |
| 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) |
|
|
| |
| for row_num in range(len(df_limpio)): |
| |
| 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) |
|
|
| |
| worksheet.write(row_num + 3, 6, 'Completar aquí', vacio_format) |
| worksheet.write(row_num + 3, 7, 'Completar aquí', vacio_format) |
|
|
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| worksheet.freeze_panes(3, 0) |
| worksheet.autofilter(2, 0, len(df_limpio) + 2, len(columnas_esenciales) - 1) |
|
|
| |
| 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 |
|
|
| |
| 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' |
| }) |
|
|
| |
| |
| |
| 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'] |
|
|
| |
| ws_equipo.merge_range(0, 0, 0, 3, '⚙️ UTILIZACIÓN POR EQUIPO', title_format) |
|
|
| |
| for col_num, col_name in enumerate(df_equipo_stats.columns): |
| ws_equipo.write(2, col_num, col_name, header_format) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| |
| |
| df_analista = df_cronograma[ |
| df_cronograma['ANALISTA_ASIGNADO'].notna() & |
| (df_cronograma['ANALISTA_ASIGNADO'] != '') |
| ].copy() |
|
|
| if not df_analista.empty: |
| |
| 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', |
| 'ESTADO': lambda x: (x == 'COMPLETADO').sum() |
| }).reset_index() |
| df_analista_stats.columns = ['ANALISTA', 'Total_Tareas', 'Minutos_Trabajados', 'Tareas_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'] |
|
|
| |
| ws_analista.merge_range(0, 0, 0, 4, '👥 UTILIZACIÓN POR ANALISTA', title_format) |
|
|
| |
| for col_num, col_name in enumerate(df_analista_stats.columns): |
| ws_analista.write(2, col_num, col_name, header_format) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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' |
| }) |
|
|
| |
| |
| |
| 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'] |
|
|
| |
| |
| 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_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) |
|
|
| |
| 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 |
| max_tat_hrs = max(max_tat_hrs, tat_hrs) |
|
|
| tat_prometido_list.append(max_tat_hrs) |
| else: |
| |
| 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'] |
|
|
| |
| ws_orden.merge_range(0, 0, 0, 9, '📦 MÉTRICAS POR ORDEN', title_format) |
|
|
| |
| for col_num, col_name in enumerate(df_por_orden.columns): |
| ws_orden.write(2, col_num, col_name, header_format) |
|
|
| |
| ws_orden.set_column('A:A', 15) |
| ws_orden.set_column('B:B', 13) |
| ws_orden.set_column('C:C', 18) |
| ws_orden.set_column('D:D', 18) |
| ws_orden.set_column('E:F', 14) |
| ws_orden.set_column('G:G', 18) |
| ws_orden.set_column('H:I', 16) |
| ws_orden.set_column('J:J', 12) |
|
|
| |
| for row_num in range(len(df_por_orden)): |
| |
| 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 = 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) |
|
|
| |
| 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], |
| '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], |
| '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) |
|
|
| |
| |
| |
| 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'] |
|
|
| |
| 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'] |
|
|
| |
| tat_hrs = 48 |
| fecha_recepcion_esq = row['Fecha_Fin'] |
|
|
| 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: |
| |
| 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'] |
|
|
| |
| ws_esq.merge_range(0, 0, 0, 9, '📋 MÉTRICAS POR ORDEN-ESQUEMA', title_format) |
|
|
| |
| for col_num, col_name in enumerate(df_orden_esquema.columns): |
| ws_esq.write(2, col_num, col_name, header_format) |
|
|
| |
| ws_esq.set_column('A:A', 15) |
| ws_esq.set_column('B:B', 18) |
| ws_esq.set_column('C:C', 13) |
| ws_esq.set_column('D:D', 18) |
| ws_esq.set_column('E:E', 14) |
| ws_esq.set_column('F:F', 18) |
| ws_esq.set_column('G:H', 16) |
| ws_esq.set_column('I:I', 12) |
|
|
| |
| for row_num in range(len(df_orden_esquema)): |
| |
| 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 = 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) |
|
|
| |
| df_tat_por_esquema = df_orden_esquema.groupby('ESQUEMA')['TAT_Real_horas'].mean().reset_index() |
| df_tat_por_esquema.columns = ['ESQUEMA', 'TAT_Promedio'] |
|
|
| |
| tat_prometido_por_esquema = [] |
| for esquema_id in df_tat_por_esquema['ESQUEMA']: |
| |
| tat_hrs = 48 |
| 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 |
|
|
| |
| chart_esq = workbook.add_chart({'type': 'column'}) |
|
|
| |
| if not df_tat_por_esquema.empty: |
| |
| df_tat_por_esquema.to_excel(writer, sheet_name='_TEMP_GRAFICO', index=False) |
|
|
| |
| 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'} |
| }) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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' |
| }) |
|
|
| |
| for analista_id in analistas: |
| df_analista = df_con_analista[ |
| df_con_analista['ANALISTA_ASIGNADO'] == analista_id |
| ].sort_values('FECHA_INICIO').copy() |
|
|
| |
| analista_nombre = analista_id |
| 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() |
|
|
| |
| sheet_name = str(analista_nombre)[:31].replace('/', '-').replace('\\', '-').replace('*', '').replace('[', '').replace(']', '').replace(':', '').replace('?', '') |
| if not sheet_name: |
| sheet_name = str(analista_id)[:31] |
|
|
| |
| 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' |
| ] |
|
|
| |
| columnas_disponibles = [col for col in columnas if col in df_analista.columns] |
| df_export = df_analista[columnas_disponibles].copy() |
|
|
| |
| df_export.to_excel(writer, sheet_name=sheet_name, index=False, startrow=5) |
|
|
| worksheet = writer.sheets[sheet_name] |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| for col_num, col_name in enumerate(columnas_disponibles): |
| worksheet.write(5, col_num, col_name, header_format) |
|
|
| |
| |
| |
| worksheet.set_column('A:A', 12) |
| worksheet.set_column('B:B', 14) |
| worksheet.set_column('C:C', 10) |
| worksheet.set_column('D:D', 12) |
| worksheet.set_column('E:E', 25) |
| worksheet.set_column('F:G', 18) |
| worksheet.set_column('H:H', 14) |
| worksheet.set_column('I:I', 16) |
| worksheet.set_column('J:J', 18) |
| worksheet.set_column('K:K', 20) |
| worksheet.set_column('L:L', 16) |
| worksheet.set_column('M:M', 14) |
| worksheet.set_column('N:N', 16) |
| worksheet.set_column('O:O', 30) |
| worksheet.set_column('P:P', 14) |
|
|
| |
| for row_num in range(len(df_export)): |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| worksheet.freeze_panes(6, 0) |
|
|
| print(f" 👥 Cronograma por Analista: {ruta} ({len(analistas)} analistas)") |
| return ruta |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| 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: |
| return [] |
| elif modo == 1: |
| bloques.append((fecha_inicio, fecha_inicio + timedelta(minutes=tiempo_int))) |
| elif modo == 2: |
| bloques.append((fecha_fin - timedelta(minutes=tiempo_int), fecha_fin)) |
| elif modo == 3: |
| bloques.append((fecha_inicio, fecha_inicio + timedelta(minutes=tiempo_int))) |
| bloques.append((fecha_fin - timedelta(minutes=tiempo_int), fecha_fin)) |
| elif modo == 4: |
| 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: |
| 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" |
|
|
| |
| 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()) |
|
|
| |
| 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 |
|
|
| |
| 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 |
| }) |
|
|
| |
| for analista_id in analistas: |
| df_analista = df_con_analista[ |
| df_con_analista['ANALISTA_ASIGNADO'] == analista_id |
| ].sort_values('FECHA_INICIO').copy() |
|
|
| |
| analista_nombre = analista_id |
| 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() |
|
|
| |
| 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 |
|
|
| |
| fecha_min = df_analista['FECHA_INICIO'].min() |
| fecha_max = df_analista['FECHA_FIN'].max() |
|
|
| |
| minutos_totales = int((fecha_max - fecha_min).total_seconds() / 60) + 1 |
| timeline = [fecha_min + timedelta(minutes=i) for i in range(minutos_totales)] |
|
|
| |
| 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)} |
|
|
| |
| worksheet = workbook.add_worksheet(sheet_name) |
|
|
| |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| |
| 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) |
|
|
| |
| for col_idx, col_name in enumerate(col_fijas): |
| worksheet.write(4, col_idx, col_name, tarea_label_format) |
|
|
| |
| |
| 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) |
|
|
| |
| worksheet.set_column(0, 0, 25) |
| worksheet.set_column(1, 1, 12) |
| worksheet.set_column(2, 2, 12) |
| worksheet.set_column(3, 3, 14) |
| worksheet.set_column(4, 4, 14) |
| worksheet.set_column(5, 5, 10) |
| worksheet.set_column(6, 6, 8) |
| worksheet.set_column(7, 7, 8) |
| worksheet.set_column(8, 8, 12) |
| worksheet.set_column(9, 9, 12) |
| worksheet.set_column(10, 10, 12) |
| worksheet.set_column(11, 11, 30) |
| worksheet.set_column(num_cols_fijas, col_offset + len(timeline) - 1, 2.5) |
|
|
| |
| 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) |
|
|
| |
| 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) |
| |
| 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) |
|
|
| |
| 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 |
| ) |
|
|
| |
| orden_esquema_combo = f"{orden}_{esquema}" |
| color_idx = orden_esquema_to_color.get(orden_esquema_combo, 0) |
| gantt_format = gantt_formats[color_idx] |
|
|
| |
| paso_corto = str(tarea['PASO'])[:4] |
|
|
| for min_idx, momento in enumerate(timeline): |
| col_idx = col_offset + min_idx |
|
|
| |
| esta_trabajando = False |
| for bloque_inicio, bloque_fin in bloques_trabajo: |
| if bloque_inicio <= momento < bloque_fin: |
| esta_trabajando = True |
| break |
|
|
| if esta_trabajando: |
| |
| 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 |
|
|
| |
| 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()) |
|
|
| |
| 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 |
|
|
| |
| 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 |
| }) |
|
|
| |
| 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 |
|
|
| |
| fecha_min = df_orden['FECHA_INICIO'].min() |
| fecha_max = df_orden['FECHA_FIN'].max() |
|
|
| |
| minutos_totales = int((fecha_max - fecha_min).total_seconds() / 60) + 1 |
| timeline = [fecha_min + timedelta(minutes=i) for i in range(minutos_totales)] |
|
|
| |
| 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)} |
|
|
| |
| sheet_name = str(orden_id)[:31] |
| worksheet = workbook.add_worksheet(sheet_name) |
|
|
| |
| worksheet.merge_range(0, 0, 1, 7, |
| f'📦 GANTT - ORDEN: {orden_id}', |
| title_format) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| for col_idx, col_name in enumerate(col_fijas): |
| worksheet.write(4, col_idx, col_name, tarea_label_format) |
|
|
| |
| 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) |
|
|
| |
| worksheet.set_column(0, 0, 14) |
| worksheet.set_column(1, 1, 10) |
| worksheet.set_column(2, 2, 12) |
| worksheet.set_column(3, 3, 14) |
| worksheet.set_column(4, 4, 14) |
| worksheet.set_column(5, 5, 14) |
| worksheet.set_column(6, 6, 10) |
| worksheet.set_column(7, 7, 8) |
| worksheet.set_column(8, 8, 8) |
| worksheet.set_column(9, 9, 12) |
| worksheet.set_column(10, 10, 12) |
| worksheet.set_column(11, 11, 12) |
| worksheet.set_column(12, 12, 30) |
| worksheet.set_column(num_cols_fijas, col_offset + len(timeline) - 1, 2.5) |
|
|
| |
| 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) |
|
|
| |
| 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) |
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| worksheet.freeze_panes(6, num_cols_fijas) |
|
|
| print(f" 📦 Gantt por Orden: {ruta} ({len(ordenes)} órdenes)") |
| return ruta |
|
|
| |
| |
| |
| 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 |
| ) -> ResultadoGA: |
| """Ejecuta el flujo completo de optimización""" |
|
|
| print("\n" + "█"*80) |
| print("█" + " "*15 + "SISTEMA DE OPTIMIZACIÓN DE LABORATORIO" + " "*24 + "█") |
| print("█"*80 + "\n") |
|
|
| |
| print("🔹 FASE 1: CARGA DE DATOS") |
| print("─"*80) |
| sistema = crear_sistema_desde_excel( |
| ruta_excel, |
| fecha_inicio=fecha_inicio_sistema, |
| **(nombre_hojas or {}) |
| ) |
|
|
| |
| 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)}") |
|
|
| |
| 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 |
|
|
| |
| print("\n🔹 FASE 3: OPTIMIZACIÓN GENÉTICA") |
| print("─"*80) |
| resultado_ga = algoritmo_genetico(sistema, config_ga, pesos_fitness, previous_plan) |
|
|
| |
| print("\n🔹 FASE 4: REPORTE") |
| print("─"*80) |
| reporte_texto = generar_reporte_completo(resultado_ga, sistema) |
| print(reporte_texto) |
|
|
| |
| 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 |
| |
| |
| |
|
|
| 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 ║ |
| ╚════════════════════════════════════════════════════════════════════════════╝ |
| """) |
|
|
| |
| 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") |
|
|
| |
| |
| 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. |
| """ |
|
|
| |
| previous_plan = None |
|
|
| |
| requeridos = ["Equipos.xlsx", "Pasos.xlsx", "Analistas.xlsx", "Ordenes.xlsx"] |
|
|
|
|
| if archivos is not None: |
| |
| if hasattr(archivos[0], "name"): |
| |
| mapping = {os.path.basename(f.name): f.name for f in archivos} |
| else: |
| |
| mapping = {os.path.basename(f): f for f in archivos} |
| else: |
| |
| mapping = {req: req for req in requeridos} |
|
|
|
|
| |
| RUTA_PLAN_ANTERIOR = "previous_plan.xlsx" |
|
|
| |
| 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 |
| ) |
|
|
|
|
| |
| for req in requeridos: |
| if req not in mapping: |
| raise ValueError(f"Falta el archivo requerido: {req}") |
|
|
| |
| 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"]) |
|
|
| |
| transformed_data_file = "datos_laboratorio.xlsx" |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| if hora_actual is None: |
| HORA_ACTUAL = datetime.now() |
| else: |
| HORA_ACTUAL = hora_actual |
|
|
| |
| |
| |
|
|
| if RUTA_PLAN_ANTERIOR in mapping and os.path.exists(mapping[RUTA_PLAN_ANTERIOR]): |
| print(f"\n📂 Detectado plan anterior: {RUTA_PLAN_ANTERIOR}") |
|
|
| |
| 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']) |
|
|
|
|
| |
| |
| columna_estado_real = None |
|
|
| |
| if 'BATCH_ID' in df_plan_completo.columns: |
| |
| 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: |
| |
| 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: |
| |
| estados_unicos = df_plan_completo[columna_estado_real].dropna().unique() |
| print(f" 🔍 DEBUG: Estados únicos en {columna_estado_real}: {list(estados_unicos)}") |
|
|
| |
| 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: |
| |
| 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") |
|
|
| |
| |
| |
|
|
| print(f"\n📂 Cargando sistema desde {transformed_data_file}...") |
|
|
| |
| |
| |
| |
| |
| |
|
|
| sistema = crear_sistema_desde_excel(transformed_data_file, fecha_inicio=HORA_ACTUAL) |
|
|
| if False: |
| |
| previous_plan_disabled = previous_plan |
| print(f" 🔍 Filtrando pasos ya completados/en proceso del transformed_data...") |
|
|
| |
| 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()) |
|
|
| |
| 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)}") |
|
|
| |
| |
| 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}") |
|
|
| |
| print(f" 🔍 DEBUG: Primeras columnas en transformed_data: {list(df_transformed.columns[:10])}") |
|
|
| |
| 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}") |
|
|
| |
| 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) |
| ) |
|
|
| |
| 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]}") |
|
|
| |
| 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)}") |
|
|
| |
| 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) |
| |
| 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 |
|
|
| sistema = crear_sistema_desde_excel(tmp_path, fecha_inicio=HORA_ACTUAL) |
|
|
| |
| os.unlink(tmp_path) |
| else: |
| sistema = crear_sistema_desde_excel(transformed_data_file, fecha_inicio=HORA_ACTUAL) |
|
|
| |
| |
| |
|
|
| 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") |
|
|
| |
| |
| |
|
|
| print(f"\n🧬 Ejecutando algoritmo genético...") |
|
|
| |
| 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) |
|
|
| |
| |
| |
|
|
| print(f"\n📅 Generando cronograma...") |
|
|
| df_cronograma_nuevo = scheduler_builder( |
| sistema=sistema, |
| cromosoma=resultado.mejor_cromosoma.to_sequence(), |
| previous_plan=previous_plan, |
| fecha_inicio=HORA_ACTUAL, |
| verbose=False |
| ) |
|
|
| |
| |
| |
|
|
| 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)}") |
|
|
| |
| |
| |
|
|
| |
| ts = timestamp_peru() |
|
|
| |
| |
| |
| 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, '.') |
|
|
| |
| 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" |
|
|
|
|
| |
| |
| |
| 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) |
|
|
| |
| archivos = { |
| 'ordenes': path_ordenes, |
| 'esquemas': path_esquemas, |
| 'pasos': path_pasos, |
| 'dependencias': path_dependencias, |
| 'analistas': path_analistas, |
| 'equipos': path_equipos |
| } |
|
|
| |
| 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) |
|
|
| |
| 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") |
|
|
| |
| print("\n🔨 Construyendo sistema...") |
| sistema = construir_sistema_completo( |
| df_ordenes, df_esquemas, df_pasos, df_dependencias, |
| df_analistas, df_equipos |
| ) |
|
|
| |
| 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") |
|
|
| |
| 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 |
| ) |
|
|
| |
| 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 |
|
|
| |
| ts = timestamp_peru() |
|
|
| |
| 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) |
|
|
| |
| 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 |
| } |
|
|
|
|
| |
| |
| |
|
|
| import gradio as gr |
| import tempfile |
| import zipfile |
| import shutil |
|
|
| |
| stop_optimization = False |
|
|
| |
| |
| |
|
|
| 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], |
| '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], |
| 'TIEMPO_INTERACCION': [5, 5, 10, 5, 10, 5], |
| 'FRECUENCIA_INTERACCION': [1, 1, 4, 1, 2, 1] |
| }) |
| 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 = [] |
|
|
| |
| 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) |
|
|
| |
| 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( |
| |
| tamano_poblacion, num_generaciones, tasa_mutacion, tasa_crossover, |
| |
| hora_actual_str, |
| |
| 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 |
| ): |
| """Ejecuta la optimización con los parámetros dados""" |
|
|
| global stop_optimization |
| stop_optimization = False |
|
|
| try: |
| |
| 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")) |
|
|
| |
| 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() |
|
|
| |
| 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) |
| } |
|
|
| |
| temp_dir = tempfile.mkdtemp() |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| archivos = [path_ordenes, path_pasos, path_analistas, path_equipos] |
|
|
| |
| 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) |
|
|
| |
| original_dir = os.getcwd() |
| os.chdir(temp_dir) |
|
|
| try: |
| |
| 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 |
| } |
|
|
| |
| resultado = run_pipeline( |
| archivos=archivos, |
| hora_actual=hora_actual, |
| turnos_config=turnos_config |
| ) |
| finally: |
| |
| os.chdir(original_dir) |
|
|
| |
| |
| 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)) |
|
|
| |
| 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())}") |
|
|
| |
| 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...") |
| |
| 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) |
| |
| ordenes_cumplidas = (df_ordenes['Cumple_TAT'] == 'SÍ').sum() if 'Cumple_TAT' in df_ordenes.columns else 0 |
| |
| tat_promedio_ordenes = df_ordenes['TAT_Real_horas'].mean() if 'TAT_Real_horas' in df_ordenes.columns else 0 |
|
|
| total_esquemas = len(df_esquemas) |
| |
| tat_promedio_esquemas = df_esquemas['TAT_Real_horas'].mean() if 'TAT_Real_horas' in df_esquemas.columns else 0 |
| |
| 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") |
|
|
| |
| |
| 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") |
|
|
| |
| 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() |
|
|
| |
| 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() |
|
|
| |
| 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) |
| """ |
|
|
| |
| 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)) |
|
|
| |
| cronograma_visual_html = generar_cronograma_visual(archivos_finales.get('detalle')) |
| gantt_html_inicial = generar_gantt_por_analista(archivos_finales.get('detalle'), analista_filtro=None) |
|
|
| |
| 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])}") |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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>" |
|
|
| |
| 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)] |
|
|
| |
| 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) |
|
|
| |
| dias_unicos = sorted(df['DIA'].unique()) |
|
|
| |
| 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] |
|
|
| |
| 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): |
| |
| if orden_col and orden != 'Sin orden': |
| df_dia_orden = df_dia[df_dia[orden_col] == orden] |
| else: |
| df_dia_orden = df_dia |
|
|
| |
| 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>' |
|
|
| |
| 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""" |
| |
| 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])}") |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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] |
|
|
| |
| df = df[df[analista_col].notna()] |
|
|
| if df.empty: |
| return "<div>No hay datos</div>" |
|
|
| |
| analistas = sorted([str(a) for a in df[analista_col].unique() if pd.notna(a)]) |
|
|
| |
| 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>" |
|
|
| |
| 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>" |
|
|
| |
| 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) |
|
|
| |
| PIXELES_POR_HORA = 300 |
| pixeles_por_minuto = PIXELES_POR_HORA / 60 |
|
|
| |
| 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"> |
| """ |
|
|
| |
| 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>" |
|
|
| |
| 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 '' |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 "" |
|
|
| |
| paso_display = nombre_paso if nombre_paso else paso_val |
| paso_escaped = html_module.escape(paso_display) |
|
|
| |
| 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') |
|
|
| |
| 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"> |
| ''' |
|
|
| |
| 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_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"") |
| 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"") |
| 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"") |
| 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"") |
| 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 = " ".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" |
|
|
|
|
| |
| |
| |
|
|
| print("\n" + "="*80) |
| print("🧬 INICIANDO INTERFAZ GRADIO") |
| print("="*80) |
|
|
| |
| 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: |
|
|
| |
| 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(): |
|
|
| |
| |
| |
| with gr.Tab("⚙️ Configuración y Ejecución"): |
|
|
| |
| 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>') |
|
|
| |
| 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>') |
|
|
| |
| 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>') |
|
|
| |
| 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>') |
|
|
| |
| 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>') |
|
|
| |
| 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>') |
|
|
| |
| |
| |
| 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>') |
|
|
| |
| |
| |
| 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! 🚀 |
| """) |
|
|
| |
| |
| |
| 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> |
| """) |
|
|
| |
| 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>') |
|
|
| |
| 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") |
|
|
| |
| 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>') |
|
|
| |
| 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] |
| ) |
|
|
| |
| |
| |
| 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>" |
| ) |
|
|
| |
| filtro_analista_gantt = gr.State(value="Todos") |
| gantt_html = gr.State(value="") |
| 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] |
| ) |
|
|
| |
| 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] |
| ) |
|
|
| |
| |
| |
|
|
| 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, |
| inbrowser=False |
| ) |