File size: 23,617 Bytes
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
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
"""
engine.py — Motor CPM estocástico V6 (núcleo de cálculo de CHRONOFLUX).

Es una copia FIEL de simular_cronograma() y sus auxiliares de
app_tesis_final_V3.py. La única diferencia: la detección de ciclos ya no llama a
st.error()/st.stop() (que dependen de Streamlit) sino que lanza CicloLogicoError.
Toda la aritmética —forward pass, backward pass, deuda de secado multi-día,
cuantización, holguras, ruta crítica— es byte a byte idéntica al original.
"""
import math
import re
from datetime import timedelta

import pandas as pd
import networkx as nx

from .climate import es_habil
from .models import calcular_ic_ia, calcular_tr_y_ic_dinamico


class CicloLogicoError(Exception):
    """Se lanza cuando la red de precedencias no es acíclica (requisito §7.4)."""
    def __init__(self, detalle: str):
        self.detalle = detalle
        super().__init__(
            f"Bucle lógico detectado en la red de precedencias (IDs: {detalle}). "
            "La condición acíclica del DAG es un requisito del motor (Capítulo 7.4)."
        )


def detalle_del_ciclo(G) -> str:
    try:
        ciclo = nx.find_cycle(G)
        return " → ".join(str(a) for a, _ in ciclo) + f" → {ciclo[0][0]}"
    except Exception:
        return "no identificable"


# C-03: desplazamiento en días HÁBILES (no calendario)
def contar_dias_habiles_shift(desde, hasta, dias_idx, feriados):
    """Cuenta cuántos días hábiles hay entre 'desde' (exclusive) y 'hasta' (inclusive)."""
    if desde is None or hasta is None or hasta <= desde:
        return 0
    c = desde
    n = 0
    while c < hasta:
        c += timedelta(days=1)
        if es_habil(c, dias_idx, feriados):
            n += 1
    return n


def avanzar_habiles(fecha, n, dias_idx, feriados):
    """Avanza 'n' días hábiles desde 'fecha' (inverso de contar_dias_habiles_shift)."""
    d = fecha
    if n <= 0:
        while not es_habil(d, dias_idx, feriados):
            d += timedelta(days=1)
        return d
    c = 0
    while c < n:
        d += timedelta(days=1)
        if es_habil(d, dias_idx, feriados):
            c += 1
    return d


def pool_ventana_climatica(cursor, clima, W):
    """Agrupa los registros históricos de ±W días alrededor de una fecha-calendario."""
    pool = []
    for off in range(-W, W + 1):
        kk = (cursor + timedelta(days=off)).strftime('%m-%d')
        h = clima.get(kk)
        if h:
            v = h.get('valores_mm')
            if v:
                pool.extend(v)
    return pool


def redondear_duracion(val):
    return round(float(val), 2)


def simular_cronograma(df, clima, prob_min, mm_min, dias_idx, feriados, reparar,
                       umbral_horas, h_inicio, h_fin, use_nlp, use_ml,
                       temp_global, hum_global, usar_clima_real=False, ventana_dias=7):
    G = nx.DiGraph()
    for _, row in df.iterrows():
        tid = row['ID']
        G.add_node(tid, data=row.to_dict())
        new_preds = str(row['OrigPreds']) if pd.notna(row['OrigPreds']) else ""
        if reparar == "Automática" and "Falta Predecesora" in row['Errores']:
            match = re.search(r'ID (\d+)', row['Errores'])
            if match:
                new_preds = match.group(1)
        G.nodes[tid]['new_preds'] = new_preds
        if new_preds.strip():
            for p in new_preds.split(','):
                p = p.strip()
                if p.isdigit() and int(p) != tid:
                    G.add_edge(int(p), tid)

    # [AUD-02] Blindaje acíclico (§7.4): un ciclo lógico no tiene solución topológica.
    try:
        orden = list(nx.topological_sort(G))
    except nx.NetworkXUnfeasible:
        raise CicloLogicoError(detalle_del_ciclo(G))

    fecha_fin_calculada = {}
    fecha_inicio_calculada = {}
    res_temp = {}

    for tid in orden:
        row = G.nodes[tid]['data']
        new_preds = G.nodes[tid]['new_preds']
        note = "Corregido Auto" if (reparar == "Automática" and "Falta Predecesora" in row['Errores']) else row['Errores']

        start_dt = pd.to_datetime(row['Start_XML']).date() if pd.notna(row['Start_XML']) else None
        finish_dt = pd.to_datetime(row['Finish_XML']).date() if pd.notna(row['Finish_XML']) else None
        base_dur_float = float(row['Duration_Days'])

        # CPM TIPO-CONSCIENTE (Opción B): FS/FF/SF propagan el retraso del FIN del
        # predecesor; SS propaga el del INICIO.
        pred_links = row['PredLinks'] if isinstance(row.get('PredLinks'), list) else []
        tipados = {pid for pid, _ in pred_links}
        for p in [int(x.strip()) for x in new_preds.split(',') if x.strip().isdigit()]:
            if p not in tipados:
                pred_links = pred_links + [(p, 'FS')]
        max_shift_dias = 0
        if pred_links and start_dt:
            for p, ltype in pred_links:
                if not G.has_node(p):
                    continue
                if ltype == 'SS':
                    ini_base_pred = G.nodes[p]['data']['Start_XML']
                    ini_base_pred = pd.to_datetime(ini_base_pred).date() if pd.notna(ini_base_pred) else None
                    ini_new_pred = fecha_inicio_calculada.get(p)
                    if ini_base_pred and ini_new_pred:
                        shift = contar_dias_habiles_shift(ini_base_pred, ini_new_pred, dias_idx, feriados)
                        if shift > max_shift_dias:
                            max_shift_dias = shift
                else:  # FS / FF / SF -> retraso del fin del predecesor
                    if fecha_fin_calculada.get(p) is not None:
                        fin_base_pred = G.nodes[p]['data']['Finish_XML']
                        fin_base_pred = pd.to_datetime(fin_base_pred).date() if pd.notna(fin_base_pred) else None
                        if fin_base_pred:
                            shift = contar_dias_habiles_shift(fin_base_pred, fecha_fin_calculada[p], dias_idx, feriados)
                            if shift > max_shift_dias:
                                max_shift_dias = shift

        new_start = start_dt
        if max_shift_dias > 0 and start_dt:
            new_start = avanzar_habiles(start_dt, max_shift_dias, dias_idx, feriados)
        if new_start:
            fecha_inicio_calculada[tid] = new_start

        new_finish = finish_dt
        new_dur_float = base_dur_float

        stats_prob = 0.0; prob_acumulada = 0.0; dias_evaluados = 0; prob_pico = 0.0
        stats_mm = 0; rain_total = 0.0; retraso_teorico_dias = 0.0; last_rain_date = None

        ic_base = calcular_ic_ia(row['Name'], use_nlp)
        tr_horas_max = 0.0
        ic_dinamico_max = ic_base

        if not row['IsSummary'] and not row['IsMilestone'] and new_start:
            work_needed = math.ceil(base_dur_float) if base_dur_float > 0 else 1
            work_done = 0; cursor = new_start

            # --- MOTOR ESTOCÁSTICO V6: DEUDA DE SECADO MULTI-DÍA (Ecs. 6.23–6.26) ---
            lluvia_acumulada_terreno = 0.0      # humedad del suelo (mm equivalentes)
            deuda_secado_horas = 0.0            # horas de inoperatividad pendientes
            prob_vigente = 0.0                  # P(d|Ur) del evento que originó la deuda activa
            ic_vigente = 1.0                    # Q(Ic) severidad vigente (NLP·ML) — Ec. 6.22
            horas_jornada = float(h_fin - h_inicio) if h_fin > h_inicio else 8.0

            while work_done < work_needed:
                if es_habil(cursor, dias_idx, feriados):
                    k = cursor.strftime('%m-%d')
                    if k in clima:
                        h = clima[k]

                        # P(d) = n/N (Ec. 6.14b). Ur se aplica POR SEPARADO sobre la magnitud.
                        if ventana_dias and ventana_dias > 0:
                            muestra = pool_ventana_climatica(cursor, clima, ventana_dias)
                        else:
                            muestra = h.get('valores_mm', None)

                        RAIN_REF = 1.0  # mm: define "llovió ese día"
                        if muestra:
                            n = len(muestra)
                            dias_lluvia = [v for v in muestra if v >= RAIN_REF]
                            prob_dia = len(dias_lluvia) / n if n > 0 else 0.0
                            mm_evento = (sum(dias_lluvia) / len(dias_lluvia)) if dias_lluvia else 0.0
                        else:
                            prob_dia = h.get('probabilidad', 0.0)
                            mm_evento = h.get('mm_promedio', 0.0)

                        # AE-04: fuente termodinámica (ERA5 real por defecto / override).
                        if usar_clima_real:
                            temp_d = h.get('temp_dia', temp_global)
                            hum_d = h.get('hum_dia', hum_global)
                        else:
                            temp_d = temp_global
                            hum_d = hum_global

                        rain_total += h.get('mm_promedio', 0.0)
                        prob_acumulada += prob_dia
                        prob_pico = max(prob_pico, prob_dia)
                        dias_evaluados += 1

                        tasa_evaporacion = max(0.1, (temp_d / 10.0) * ((100.0 - hum_d) / 20.0))

                        # GATE 1 (Pr) + GATE 2 (Ur)
                        if prob_dia >= prob_min and mm_evento >= mm_min:
                            lluvia_acumulada_terreno = max(0.0, lluvia_acumulada_terreno + mm_evento - tasa_evaporacion)
                            stats_mm = max(stats_mm, mm_evento)
                            if h['ultima_fecha_lluvia']:
                                last_rain_date = h['ultima_fecha_lluvia'].date()

                            tr_horas, ic_dinamico = calcular_tr_y_ic_dinamico(lluvia_acumulada_terreno, temp_d, hum_d, ic_base, use_ml)
                            tr_horas_max = max(tr_horas_max, tr_horas)
                            ic_dinamico_max = max(ic_dinamico_max, ic_dinamico)

                            horas_lluvia = mm_evento / 5.0  # intensidad de referencia 5 mm/h
                            deuda_secado_horas = max(deuda_secado_horas, tr_horas) + horas_lluvia
                            prob_vigente = prob_dia
                            ic_vigente = ic_dinamico
                        else:
                            lluvia_acumulada_terreno = max(0.0, lluvia_acumulada_terreno - tasa_evaporacion)

                        # AE-01: PÉRDIDA FRACCIONAL CONTINUA DE LA JORNADA (Ec. 6.26)
                        if deuda_secado_horas > 1e-6:
                            horas_perdidas_hoy = min(horas_jornada, deuda_secado_horas)
                            ventana_util = max(0.5, horas_jornada - umbral_horas)
                            fraccion_perdida = min(1.0, horas_perdidas_hoy / ventana_util)
                            retraso_teorico_dias += prob_vigente * fraccion_perdida * ic_vigente
                            deuda_secado_horas = max(0.0, deuda_secado_horas - horas_jornada)

                work_done += 1
                cursor += timedelta(days=1)

            stats_prob = prob_pico if dias_evaluados > 0 else 0
            stats_prob_media = (prob_acumulada / dias_evaluados) if dias_evaluados > 0 else 0  # noqa: F841

            nota_cuantizacion = ""
            total_cuantizado = base_dur_float
            if retraso_teorico_dias > 0:
                total_cuantizado = base_dur_float + math.ceil(retraso_teorico_dias)
                retraso_cuantizado = total_cuantizado - base_dur_float
                if retraso_cuantizado != round(retraso_teorico_dias, 2):
                    nota_cuantizacion = f" (Q={round(retraso_cuantizado, 2)}d)"
            else:
                retraso_cuantizado = 0.0

            if note == "OK" and retraso_cuantizado > 0:
                note = f"Impacto Clima{nota_cuantizacion} [Ic={ic_dinamico_max}]"
            elif note != "OK" and retraso_cuantizado > 0:
                note += f" | Impacto Clima{nota_cuantizacion} [Ic={ic_dinamico_max}]"

            dias_a_avanzar = math.ceil(total_cuantizado) if total_cuantizado > 0 else 1
            cursor_fin = new_start; dias_avanzados = 1
            while dias_avanzados < dias_a_avanzar:
                cursor_fin += timedelta(days=1)
                if es_habil(cursor_fin, dias_idx, feriados):
                    dias_avanzados += 1

            new_finish = cursor_fin; new_dur_float = total_cuantizado
            is_pushed_by_pred = (new_start > start_dt) if start_dt else False
            if not is_pushed_by_pred and retraso_cuantizado == 0 and finish_dt:
                new_finish = finish_dt; new_dur_float = base_dur_float

        elif row['IsMilestone']:
            new_dur_float = 0; stats_prob = 0
            if new_start:
                new_finish = new_start

        fecha_fin_calculada[tid] = new_finish
        G.nodes[tid]['ES'] = new_start; G.nodes[tid]['EF'] = new_finish; G.nodes[tid]['dur_ajustada'] = new_dur_float

        res_temp[tid] = {
            'ID': tid, 'WBS': row['WBS'], 'Actividad': row['Name'], 'IsSummary': row['IsSummary'], 'IsMilestone': row['IsMilestone'],
            'Duración Base': redondear_duracion(base_dur_float), 'Inicio Base': start_dt, 'Fin Base': finish_dt,
            'Duración Nueva': redondear_duracion(new_dur_float), 'Inicio Nuevo': new_start, 'Fin Nuevo': new_finish,
            'Tr (Secado/Horas)': round(tr_horas_max, 1), 'Ic_Estimado': round(ic_dinamico_max, 2),
            'Pred. Orig': row['OrigPreds'], 'Pred. Nueva': new_preds,
            'Prob. Lluvia': f"{stats_prob:.0%}" if stats_prob > 0 else "-", 'mm Lluvia Max': round(stats_mm, 1) if stats_mm > 0 else "-",
            'Lluvia Total Acum (mm)': round(rain_total, 1), 'Fecha Última Lluvia': last_rain_date if last_rain_date else "-",
            'Días Impacto': redondear_duracion(new_dur_float) - redondear_duracion(base_dur_float), 'Estado': note,
            'IsRain': ((redondear_duracion(new_dur_float) - redondear_duracion(base_dur_float)) > 0), 'IsLogic': (new_preds != row['OrigPreds'])
        }

    valid_efs = [data['EF'] for n, data in G.nodes(data=True) if data.get('EF') is not None]
    max_project_ef = max(valid_efs) if valid_efs else None

    for tid in reversed(orden):
        node = G.nodes[tid]
        if node.get('EF') is None:
            continue

        succs = list(G.successors(tid))
        if not succs:
            node['LF'] = max_project_ef
        else:
            valid_ls = [G.nodes[s].get('LS') for s in succs if G.nodes[s].get('LS') is not None]
            if valid_ls:
                min_succ_ls = min(valid_ls); cursor = min_succ_ls - timedelta(days=1)
                while not es_habil(cursor, dias_idx, feriados):
                    cursor -= timedelta(days=1)
                node['LF'] = cursor
            else:
                node['LF'] = max_project_ef

        dur = math.ceil(node.get('dur_ajustada', 0)); cursor = node['LF']
        if dur > 1:
            days_stepped = 1
            while days_stepped < dur:
                cursor -= timedelta(days=1)
                if es_habil(cursor, dias_idx, feriados):
                    days_stepped += 1
        node['LS'] = cursor

        ef = node['EF']; lf = node['LF']; tf_days = 0
        if ef and lf and lf >= ef:
            c = ef
            while c < lf:
                c += timedelta(days=1)
                if es_habil(c, dias_idx, feriados):
                    tf_days += 1
        elif ef and lf and lf < ef:
            c = lf
            while c < ef:
                c += timedelta(days=1)
                if es_habil(c, dias_idx, feriados):
                    tf_days -= 1

        node['TF'] = tf_days; node['is_critical'] = (tf_days <= 0)
        res_temp[tid]['Holgura (Días)'] = tf_days; res_temp[tid]['Ruta Crítica'] = "Sí" if tf_days <= 0 else "No"
        impact = res_temp[tid]['Días Impacto']
        inicio_nuevo = res_temp[tid].get('Inicio Nuevo')
        inicio_base = res_temp[tid].get('Inicio Base')
        # C-04: distinguir "Mutada por lluvia directa" vs "Empujada por cascada"
        fue_empujada_sin_lluvia = (
            tf_days <= 0 and
            impact == 0 and
            inicio_nuevo is not None and
            inicio_base is not None and
            inicio_nuevo > inicio_base
        )
        if fue_empujada_sin_lluvia:
            res_temp[tid]['Nivel Riesgo'] = "Crítico (Empujada)"
        elif tf_days <= 0 and impact > 0:
            res_temp[tid]['Nivel Riesgo'] = "Crítico (Mutada)"
        elif impact > 2:
            res_temp[tid]['Nivel Riesgo'] = "Alto"
        else:
            res_temp[tid]['Nivel Riesgo'] = "Normal"

    df_res = pd.DataFrame(list(res_temp.values())).sort_values('ID')
    df_res['Holgura (Días)'] = df_res['Holgura (Días)'].astype(object)
    df_res['Tr (Secado/Horas)'] = df_res['Tr (Secado/Horas)'].astype(object)
    df_res['Duración Nueva'] = df_res['Duración Nueva'].astype(object)
    df_res['Días Impacto'] = df_res['Días Impacto'].astype(object)
    df_res['Nivel Riesgo'] = df_res['Nivel Riesgo'].astype(object)

    for i in df_res[df_res['IsSummary'] == True].index:
        wbs_val = str(df_res.at[i, 'WBS']); wbs_prefix = wbs_val + '.'
        children = df_res[(df_res['WBS'].astype(str).str.startswith(wbs_prefix)) & (df_res['IsSummary'] == False)]
        if children.empty and (df_res.at[i, 'ID'] == 0 or wbs_val == '0' or wbs_val == 'None'):
            children = df_res[df_res['IsSummary'] == False]

        if not children.empty:
            min_start = children['Inicio Nuevo'].dropna().min()
            max_finish = children['Fin Nuevo'].dropna().max()
            if pd.notna(min_start):
                df_res.at[i, 'Inicio Nuevo'] = min_start
            if pd.notna(max_finish):
                df_res.at[i, 'Fin Nuevo'] = max_finish

            if pd.notna(min_start) and pd.notna(max_finish) and max_finish >= min_start:
                c_dias = 0; cursor = min_start
                while cursor <= max_finish:
                    if es_habil(cursor, dias_idx, feriados):
                        c_dias += 1
                    cursor += timedelta(days=1)
                # Las tareas RESUMEN no llevan duración pegable (MS Project la deriva).
                df_res.at[i, 'Duración Nueva'] = "-"
                df_res.at[i, 'Días Impacto'] = "-"
                df_res.at[i, 'Nivel Riesgo'] = "Resumen (auto)"
            else:
                df_res.at[i, 'Duración Nueva'] = "-"; df_res.at[i, 'Días Impacto'] = "-"; df_res.at[i, 'Nivel Riesgo'] = "Resumen (auto)"

            df_res.at[i, 'Prob. Lluvia'] = "-"; df_res.at[i, 'mm Lluvia Max'] = "-"
            df_res.at[i, 'Holgura (Días)'] = "-"; df_res.at[i, 'Ruta Crítica'] = "-"; df_res.at[i, 'Tr (Secado/Horas)'] = "-"

    df_res['ID'] = pd.to_numeric(df_res['ID'], errors='coerce')
    return df_res.sort_values('ID').reset_index(drop=True)


def agente_prescriptivo_mitigacion(df_tareas, evb_total):
    """Genera un mini-informe prescriptivo del estado analizado (siempre devuelve contenido)."""
    reporte = []

    act = df_tareas[(df_tareas['IsSummary'] == False) & (df_tareas['IsMilestone'] == False)].copy()
    act['_imp'] = pd.to_numeric(act['Días Impacto'], errors='coerce').fillna(0)
    act['_tr'] = pd.to_numeric(act['Tr (Secado/Horas)'], errors='coerce').fillna(0)
    act['_ic'] = pd.to_numeric(act['Ic_Estimado'], errors='coerce').fillna(0)

    n_total = len(act)
    afectadas = act[act['_imp'] > 0]
    n_afect = len(afectadas)
    criticas = act[act['Ruta Crítica'].astype(str) == "Sí"]
    n_crit = len(criticas)
    pct = (n_afect / n_total * 100.0) if n_total > 0 else 0.0

    if evb_total <= 0 and n_afect == 0:
        nivel = "🟢 ESTABLE"
    elif evb_total < 5:
        nivel = "🟡 RIESGO MODERADO"
    else:
        nivel = "🔴 RIESGO ALTO"
    reporte.append(
        f"📋 **Informe de Estado — {nivel}**<br>"
        f"El proyecto acumula un retraso climático estimado de **{int(round(evb_total))} días hábiles**. "
        f"Se analizaron **{n_total} actividades**, de las cuales **{n_afect} ({pct:.0f}%)** presentan impacto pluviométrico "
        f"y **{n_crit}** se encuentran sobre la Ruta Crítica estocástica."
    )

    if n_afect == 0:
        reporte.append("✅ **Diagnóstico:** El riesgo climático actual es absorbido por las holguras del cronograma. No se requieren medidas de mitigación.")
        return reporte

    peor = afectadas.loc[afectadas['_imp'].idxmax()]
    reporte.append(
        f"🧠 **Cuello de botella principal:** **'{peor['Actividad']}'** concentra el mayor impacto "
        f"({int(peor['_imp'])} días), con un tiempo de secado inferido de **{peor['_tr']:.0f} h** "
        f"y coeficiente de vulnerabilidad Ic={peor['_ic']:.1f}."
    )

    top = afectadas.sort_values('_imp', ascending=False).head(3)
    lineas = "<br>".join(
        f"&nbsp;&nbsp;• **{t['Actividad']}** — {int(t['_imp'])} d "
        f"({'Ruta Crítica' if str(t['Ruta Crítica']) == 'Sí' else 'con holgura'})"
        for _, t in top.iterrows()
    )
    reporte.append(f"📌 **Actividades más afectadas:**<br>{lineas}")

    # Recomendación logística con verificación de solapamiento (Ec. 6.10)
    TAU_MAX_BLOQUEO = 24.0
    tierras = afectadas[afectadas['_tr'] >= TAU_MAX_BLOQUEO]
    refugios = act[act['_ic'] <= 1.0]
    refugio_solapado = None
    if not tierras.empty and not refugios.empty:
        eb_ini = pd.to_datetime(peor.get('Inicio Nuevo'), errors='coerce')
        eb_fin = pd.to_datetime(peor.get('Fin Nuevo'), errors='coerce')
        if pd.notna(eb_ini) and pd.notna(eb_fin):
            for _, ref in refugios.iterrows():
                er_ini = pd.to_datetime(ref.get('Inicio Nuevo'), errors='coerce')
                er_fin = pd.to_datetime(ref.get('Fin Nuevo'), errors='coerce')
                if pd.notna(er_ini) and pd.notna(er_fin) and max(eb_ini, er_ini) <= min(eb_fin, er_fin):
                    refugio_solapado = ref
                    break

    if refugio_solapado is not None:
        reporte.append(
            f"👉 **Estrategia recomendada (Ec. 6.10 — solapamiento confirmado):** Reasignar temporalmente la "
            f"maquinaria del frente bloqueado hacia el nodo refugio estructural **'{refugio_solapado['Actividad']}'** "
            f"(Ic={refugio_solapado['_ic']:.1f}), cuya ventana operativa se solapa con el período de parálisis, "
            f"evitando que los recursos queden inactivos."
        )
    elif not tierras.empty:
        reporte.append(
            "⚠️ **Estrategia recomendada:** No hay frentes estructurales con ventana solapada al período de parálisis. "
            "Se recomienda **reprogramar el inicio** del frente afectado hacia una ventana de menor probabilidad de lluvia "
            "o evaluar la **movilización de recursos** a otro proyecto durante el secado."
        )
    else:
        reporte.append(
            "👉 **Estrategia recomendada:** Los impactos provienen de lluvias de baja persistencia. Se recomienda "
            "**ajustar la secuencia de tareas** para ejecutar las partidas sensibles en las ventanas secas detectadas "
            "y reforzar el drenaje superficial de los frentes activos."
        )

    if n_crit > 0:
        reporte.append(
            f"🚨 **Atención Ruta Crítica:** {n_crit} actividad(es) crítica(s) absorbieron el retraso y empujan la fecha "
            f"de término del proyecto. Prioriza su mitigación: cualquier día ganado en ellas se traduce directamente en "
            f"adelanto del hito final."
        )
    return reporte