File size: 8,596 Bytes
7599506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28927dd
 
7599506
 
28927dd
 
7599506
28927dd
 
 
 
 
 
 
 
 
 
7599506
 
 
 
 
 
 
28927dd
 
 
7599506
 
 
 
 
 
 
28927dd
7599506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28927dd
7599506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6330ad
 
 
7599506
f6330ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7599506
 
f6330ad
 
 
 
 
7599506
 
f6330ad
 
 
7599506
 
f6330ad
 
7599506
f6330ad
 
 
7599506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28927dd
 
 
 
 
 
 
 
 
 
7599506
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
"""
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