""" models.py — Capa cognitiva (NLP zero-shot + Random Forest termodinámico). Diferencia con el original: los modelos NO se cargan al importar el módulo (eso obligaba a tener transformers/torch presentes solo para arrancar). Aquí se cargan de forma PEREZOSA (lazy) la primera vez que se necesitan, vía get_nlp_model() y get_tr_model(). La lógica de inferencia (calcular_ic_ia, calcular_tr_y_ic_dinamico) es idéntica a app_tesis_final_V3.py. """ import warnings import numpy as np from .constants import ENFORCE_TR0_RESILIENTES warnings.filterwarnings("ignore") # ------------------------------------------------------------------ # Disponibilidad de librerías de IA (independientes entre sí). # El Random Forest solo necesita sklearn; el NLP necesita transformers. # ------------------------------------------------------------------ try: from sklearn.ensemble import RandomForestRegressor # noqa: F401 ML_DISPONIBLE = True except ImportError: ML_DISPONIBLE = False try: from transformers import pipeline as _hf_pipeline # noqa: F401 NLP_DISPONIBLE = True except ImportError: NLP_DISPONIBLE = False # Compatibilidad: "hay IA" si al menos uno de los dos modelos está disponible. IA_DISPONIBLE = ML_DISPONIBLE or NLP_DISPONIBLE # Singletons perezosos (se rellenan al primer uso). _nlp_classifier = None _nlp_cargado = False _ml_tr_model = None _ml_cargado = False # Caché de predicciones del Random Forest (clave: entradas redondeadas). _TR_CACHE: dict = {} def get_nlp_model(): """Carga perezosa del clasificador zero-shot. Devuelve None si no se puede.""" global _nlp_classifier, _nlp_cargado if _nlp_cargado: return _nlp_classifier _nlp_cargado = True if not NLP_DISPONIBLE: _nlp_classifier = None return None try: from transformers import pipeline _nlp_classifier = pipeline( "zero-shot-classification", model="Recognai/zeroshot_selectra_medium" ) except Exception: _nlp_classifier = None return _nlp_classifier def get_tr_model(): """Carga/entrena perezosamente el Random Forest termodinámico (Tr en horas).""" global _ml_tr_model, _ml_cargado if _ml_cargado: return _ml_tr_model _ml_cargado = True _ml_tr_model = _entrenar_modelo_termodinamico() return _ml_tr_model def _entrenar_modelo_termodinamico(): """ Generador de datos sintéticos basados en física real (idéntico al original). Entrena al Random Forest para que devuelva HORAS de secado respondiendo correctamente a la temperatura y humedad. """ if not ML_DISPONIBLE: return None from sklearn.ensemble import RandomForestRegressor np.random.seed(42) X_train = [] y_train = [] # Creamos 2000 permutaciones físicas para entrenar a la IA for _ in range(2000): lluvia = np.random.uniform(0.1, 100.0) temp = np.random.uniform(15.0, 45.0) hum = np.random.uniform(30.0, 100.0) suelo = np.random.choice([1, 2, 3]) # Fórmula física real de evaporación evap_rate = max(0.1, (temp / 15.0) * ((100.0 - hum) / 40.0)) factor_suelo = {1: 1.5, 2: 1.0, 3: 0.5}[suelo] # 1 retiene más agua tr_horas = (lluvia / evap_rate) * factor_suelo X_train.append([lluvia, temp, hum, suelo]) y_train.append(min(96.0, tr_horas)) # Cap máximo de 96 horas modelo = RandomForestRegressor(n_estimators=50, random_state=42) modelo.fit(X_train, y_train) return modelo # ============================================================================== # INFERENCIA SEMÁNTICA (Ic) — con caché por nombre y clasificación en lote. # Los valores son idénticos a clasificar tarea por tarea; solo se evita repetir # la inferencia para nombres iguales (clave para proyectos viales grandes). # ============================================================================== _IC_CACHE: dict = {} _CATEGORIAS_IC = [ "estructuras de hormigón y acero", "pavimento asfáltico y terminaciones", "bases granulares y subbases", "movimiento de tierras pesado y excavación", ] _MAPA_IC = {_CATEGORIAS_IC[0]: 1.0, _CATEGORIAS_IC[1]: 1.5, _CATEGORIAS_IC[2]: 2.0, _CATEGORIAS_IC[3]: 3.0} def _ic_por_palabras_clave(nombre_str: str) -> float: """Fallback determinista por RegEx (idéntico al original).""" if any(w in nombre_str for w in ['acero', 'hormigon', 'hormigón', 'encofrado', 'vaciado', 'muro', 'alcantarilla', 'losa', 'zapata', 'columna', 'viga', 'platea', 'fundacion', 'fundación', 'estructura', 'paisajismo', 'limpieza', 'grama', 'terminacion', 'terminación']): return 1.0 elif any(w in nombre_str for w in ['pintura', 'señalizacion', 'señalización']): return 1.5 elif any(w in nombre_str for w in ['base', 'subbase', 'sub-base', 'granular', 'afirmado', 'asfalto', 'imprimacion', 'imprimación']): return 2.0 elif any(w in nombre_str for w in ['corte', 'relleno', 'subrasante', 'tierra', 'excavacion', 'excavación']): return 3.0 return 1.5 def precompute_ic_batch(nombres, usar_ia=True): """Clasifica en UN SOLO lote todos los nombres únicos y llena el caché. Acelera drásticamente la corrida: una llamada al modelo en lugar de N. Si la IA no está disponible no hace nada (el motor usará el fallback por palabras clave). """ if not usar_ia: return nlp = get_nlp_model() if not nlp: return unicos = sorted({str(n).lower() for n in nombres if n is not None and str(n).strip()}) pendientes = [n for n in unicos if (n, True) not in _IC_CACHE] if not pendientes: return try: resultados = nlp(pendientes, _CATEGORIAS_IC) if isinstance(resultados, dict): # el pipeline devuelve dict si es un solo texto resultados = [resultados] for nombre, res in zip(pendientes, resultados): _IC_CACHE[(nombre, True)] = _MAPA_IC.get(res['labels'][0], 1.5) except Exception: pass def calcular_ic_ia(nombre_tarea, usar_ia=True): nombre_str = str(nombre_tarea).lower() key = (nombre_str, bool(usar_ia)) cached = _IC_CACHE.get(key) if cached is not None: return cached nlp_classifier = get_nlp_model() if usar_ia else None if not usar_ia or not nlp_classifier: val = _ic_por_palabras_clave(nombre_str) _IC_CACHE[key] = val return val try: res = nlp_classifier(nombre_str, _CATEGORIAS_IC) val = _MAPA_IC.get(res['labels'][0], 1.5) except Exception: val = 1.5 _IC_CACHE[key] = val return val def calcular_tr_y_ic_dinamico(lluvia_mm, temp_c, humedad_pct, tipo_suelo_ic, usar_ia=True): ml_tr_model = get_tr_model() if usar_ia else None if not usar_ia or not ml_tr_model: # [AUD-04] Fallback ciego (Ceguera Térmica, CFX-AUDIT-08): valores estáticos # declarados en la tesis: 48 h arcillas, 12 h granulares, 6 h asfalto, 0 h estructuras. if tipo_suelo_ic >= 3.0: return 48.0, tipo_suelo_ic elif tipo_suelo_ic >= 2.0: return 12.0, tipo_suelo_ic elif tipo_suelo_ic >= 1.5: return 6.0, tipo_suelo_ic else: return 0.0, tipo_suelo_ic # [AUD-05] Ec. 6.19: Δθ=0 ⟹ Tr=0 para hormigón/acero (modo estricto opcional). if ENFORCE_TR0_RESILIENTES and tipo_suelo_ic <= 1.0: return 0.0, tipo_suelo_ic suelo_cat = 1 if tipo_suelo_ic >= 3.0 else (2 if tipo_suelo_ic >= 2.0 else 3) # La IA predice HORAS reales. Cacheamos la predicción con las entradas redondeadas: # sklearn tarda ~3.8 ms por predicción de UNA muestra, y el motor la invoca una vez # por día lluvioso; en un proyecto grande eso son decenas de miles de llamadas # idénticas. El redondeo (0.5 mm / 0.5 °C / 0.5 %) deja Tr prácticamente igual y # colapsa miles de llamadas a unas pocas. Resultado: hasta cientos de veces más rápido. key = (round(lluvia_mm * 2) / 2, round(temp_c * 2) / 2, round(humedad_pct * 2) / 2, suelo_cat) tr_horas = _TR_CACHE.get(key) if tr_horas is None: tr_horas = float(ml_tr_model.predict([[key[0], key[1], key[2], suelo_cat]])[0]) _TR_CACHE[key] = tr_horas # [AUD-07] Penalización dinámica del coeficiente — Ec. 6.22 de la tesis: # Ic_dinámico = Q(Ic) = Ic_base + Tr/48 (constante de normalización 48 h) tr_dias = tr_horas / 24.0 ic_dinamico = round(tipo_suelo_ic + (tr_dias * 0.5), 2) return round(tr_horas, 1), ic_dinamico