ICEPyUS 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1019 @@
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ from scipy import stats
4
+ import calendar
5
+ import pandas as pd
6
+
7
+ import geopandas as gpd
8
+
9
+ import cartopy.crs as ccrs
10
+ import cartopy.feature as cfeature
11
+ from cartopy.mpl.ticker import LatitudeFormatter, LongitudeFormatter
12
+
13
+ import warnings
14
+ warnings.simplefilter(action = "ignore", category = RuntimeWarning)
15
+ warnings.simplefilter(action = "ignore", category = FutureWarning)
16
+
17
+
18
+ ######################
19
+ ###################
20
+ # Estadísticos in situ
21
+ #######################
22
+ #######################
23
+
24
+
25
+ def mann_kendall_test(data):
26
+ """
27
+ Perform the Mann-Kendall trend test
28
+
29
+ Parameters:
30
+ data: array-like, time series data
31
+
32
+ Returns:
33
+ tau: Kendall's tau statistic
34
+ p_value: two-tailed p-value
35
+ trend: string describing the trend ('increasing', 'decreasing', 'no trend')
36
+ z_stat: standardized test statistic
37
+ """
38
+ n = len(data)
39
+
40
+ # Calculate S statistic
41
+ S = 0
42
+ for i in range(n-1):
43
+ for j in range(i+1, n):
44
+ S += np.sign(data[j] - data[i])
45
+
46
+ # Calculate variance
47
+ var_S = n * (n - 1) * (2 * n + 5) / 18
48
+
49
+ # Calculate standardized test statistic
50
+ if S > 0:
51
+ Z = (S - 1) / np.sqrt(var_S)
52
+ elif S < 0:
53
+ Z = (S + 1) / np.sqrt(var_S)
54
+ else:
55
+ Z = 0
56
+
57
+ # Calculate p-value (two-tailed)
58
+ p_value = 2 * (1 - stats.norm.cdf(abs(Z)))
59
+
60
+ # Calculate Kendall's tau
61
+ tau = S / (n * (n - 1) / 2)
62
+
63
+ # Determine trend
64
+ alpha = 0.05
65
+ if p_value < alpha:
66
+ if tau > 0:
67
+ trend = 'increasing'
68
+ else:
69
+ trend = 'decreasing'
70
+ else:
71
+ trend = 'no trend'
72
+
73
+ return tau, p_value, trend, Z
74
+
75
+ def theil_sen_estimator_with_ci(x, y, confidence_level=0.95):
76
+ """
77
+ Calculate the Theil-Sen slope estimator with confidence intervals
78
+
79
+ Parameters:
80
+ x: array-like, independent variable (time)
81
+ y: array-like, dependent variable (data)
82
+ confidence_level: float, confidence level for intervals (default 0.95)
83
+
84
+ Returns:
85
+ slope: Theil-Sen slope estimate
86
+ intercept: intercept of the trend line
87
+ slope_ci_lower: lower bound of slope confidence interval
88
+ slope_ci_upper: upper bound of slope confidence interval
89
+ """
90
+ n = len(x)
91
+ slopes = []
92
+
93
+ # Calculate all pairwise slopes
94
+ for i in range(n-1):
95
+ for j in range(i+1, n):
96
+ if x[j] != x[i]: # Avoid division by zero
97
+ slope = (y[j] - y[i]) / (x[j] - x[i])
98
+ slopes.append(slope)
99
+
100
+ slopes = np.array(slopes)
101
+
102
+ # Theil-Sen slope is the median of all slopes
103
+ slope = np.median(slopes)
104
+
105
+ # Calculate confidence interval for slope
106
+ alpha = 1 - confidence_level
107
+ z_alpha_2 = stats.norm.ppf(1 - alpha/2)
108
+
109
+ # Number of slope estimates
110
+ n_slopes = len(slopes)
111
+
112
+ # Calculate confidence interval bounds using the sorted slopes
113
+ slopes_sorted = np.sort(slopes)
114
+
115
+ # Standard error approximation for confidence interval
116
+ c_gamma = z_alpha_2 * np.sqrt(n * (n-1) * (2*n + 5) / 18)
117
+
118
+ # Calculate indices for confidence interval
119
+ m1 = int(np.floor((n_slopes - c_gamma) / 2))
120
+ m2 = int(np.ceil((n_slopes + c_gamma) / 2))
121
+
122
+ # Ensure indices are within bounds
123
+ m1 = max(0, m1)
124
+ m2 = min(n_slopes - 1, m2)
125
+
126
+ slope_ci_lower = slopes_sorted[m1] if m1 < len(slopes_sorted) else slopes_sorted[0]
127
+ slope_ci_upper = slopes_sorted[m2] if m2 < len(slopes_sorted) else slopes_sorted[-1]
128
+
129
+ # Calculate intercept
130
+ intercept = np.median(y) - slope * np.median(x)
131
+
132
+ return slope, intercept, slope_ci_lower, slope_ci_upper
133
+
134
+ def calculate_confidence_bands(x, y, slope, intercept, slope_ci_lower, slope_ci_upper):
135
+ """
136
+ Calculate confidence bands for the trend line
137
+ """
138
+ x_median = np.median(x)
139
+ y_median = np.median(y)
140
+
141
+ # Calculate trend lines
142
+ trend_line = slope * x + intercept
143
+ trend_line_lower = slope_ci_lower * (x - x_median) + y_median
144
+ trend_line_upper = slope_ci_upper * (x - x_median) + y_median
145
+
146
+ return trend_line, trend_line_lower, trend_line_upper
147
+
148
+
149
+ # Función para calcular promedio excluyendo ceros
150
+ def calculate_mean_no_zeros(data_array):
151
+ """
152
+ Calcula el promedio de un array excluyendo los valores cero
153
+
154
+ Parameters:
155
+ data_array: array 2D con datos de precipitación
156
+
157
+ Returns:
158
+ mean_value: promedio excluyendo ceros, o NaN si todos son ceros
159
+ """
160
+ # Aplanar el array y remover ceros
161
+ flat_data = data_array.flatten()
162
+ non_zero_data = flat_data[flat_data > 0]
163
+
164
+ if len(non_zero_data) > 0:
165
+ return np.mean(non_zero_data)
166
+ else:
167
+ return np.nan # Retorna NaN si todos los valores son cero
168
+
169
+
170
+
171
+
172
+ ################################################################################
173
+ ################################################################################
174
+ class ETCCDI_precip_plot_in_situ:
175
+
176
+ ######################
177
+ ## Graficar FIGURA
178
+ #######
179
+
180
+ def plot(archivo_excel: str, salida_figura: str, salida_excel:str):
181
+
182
+ # Load the Excel file (first sheet by default)
183
+ df = pd.read_excel(archivo_excel)
184
+
185
+ # Obtener nombre de la variable desde el encabezado de la segunda columna
186
+ nombre_variable = df.columns[1]
187
+ # Diccionario opcional con unidades comunes (ampliar según necesidad)
188
+ unidades_dict = {
189
+ 'RX1day': 'mm',
190
+ 'RX5day': 'mm',
191
+ 'R10mm': 'días',
192
+ 'R20mm': 'días',
193
+ 'CDD': 'días',
194
+ 'CWD': 'días',
195
+ 'PRCPTOT': 'mm',
196
+ 'SDII': 'mm/day',
197
+ 'R95P': 'mm',
198
+ 'R99P': 'mm'
199
+ }
200
+ unidades = unidades_dict.get(nombre_variable, '')
201
+ ylabel = f'{nombre_variable} ({unidades})' if unidades else nombre_variable
202
+
203
+ data_in = df.iloc[:, 1]
204
+
205
+ time_array = df.iloc[:, 0]
206
+ # Calculate global y-limits
207
+ all_data = np.array(data_in)
208
+ diff_min_ar = np.floor(min([data.min() for data in all_data])) - 1
209
+ diff_max_ar = np.ceil(max([data.max() for data in all_data])) + 1
210
+
211
+
212
+ data_array = all_data
213
+
214
+ # # Create plots
215
+ fig, axes = plt.subplots(1, 1, figsize=(20, 12))
216
+
217
+ # # Colors for datasets
218
+ colors = 'black'
219
+
220
+ # # Store results for summary table
221
+ results = []
222
+
223
+ # for dataset_idx, (dataset_name, data_arrays) in enumerate(zip(datasets, [livneh_data, mexhi_data])):
224
+ # for month_idx, (month, data) in enumerate(zip(months, data_arrays)):
225
+ ax = axes
226
+
227
+ # Perform Mann-Kendall test
228
+ tau, p_value, trend, z_stat = mann_kendall_test(all_data)
229
+
230
+ # Calculate Theil-Sen estimator with confidence intervals
231
+ slope, intercept, slope_ci_lower, slope_ci_upper = theil_sen_estimator_with_ci(time_array, data_array)
232
+
233
+ # Calculate confidence bands
234
+ trend_line, trend_line_lower, trend_line_upper = calculate_confidence_bands(
235
+ time_array, data_array, slope, intercept, slope_ci_lower, slope_ci_upper)
236
+
237
+
238
+ # Store results
239
+ results.append({
240
+ # 'Dataset': dataset_name,
241
+ # 'Mes': month,
242
+ 'Kendall_tau': tau,
243
+ 'p_value': p_value,
244
+ 'Tendencia': trend,
245
+ 'Z-statistic': z_stat,
246
+ 'Sen_slope': slope,
247
+ 'IC_inferior': slope_ci_lower,
248
+ 'IC_superior': slope_ci_upper,
249
+ 'Intercept': intercept
250
+ })
251
+
252
+ # Plot original data
253
+ ax.plot(time_array, data_array, 'o-', color=colors,
254
+ alpha=0.7, markersize=4, linewidth=1, label='Data')
255
+
256
+ # Plot Theil-Sen trend line
257
+ ax.plot(time_array, trend_line, '-', color='red', linewidth=2,
258
+ label='Theil-Sen trend')
259
+
260
+ # Plot 95% confidence band
261
+ ax.fill_between(time_array, trend_line_lower, trend_line_upper,
262
+ color='red', alpha=0.2, label='95% CI')
263
+
264
+ # Set plot properties
265
+ ax.set_xlabel('Year')
266
+ ax.set_ylabel(ylabel)
267
+ ax.set_ylim(diff_min_ar, diff_max_ar)
268
+ ax.set_xticks(np.arange(1951, 2014, 10))
269
+ ax.set_xticklabels(np.arange(1951, 2014, 10).astype(int))
270
+
271
+ # Title with trend information
272
+ significance = "**" if p_value < 0.01 else "*" if p_value < 0.05 else ""
273
+ title = f'{nombre_variable}\n' #f'{dataset_name} - {month}\n'
274
+ title += f'Trend: {trend}{significance}\n'
275
+ title += f'Slope: {slope:.4f} mm/year\n'
276
+ title += f'95% CI: [{slope_ci_lower:.4f}, {slope_ci_upper:.4f}]\n'
277
+ title += f'τ = {tau:.3f}, p = {p_value:.3f}'
278
+ ax.set_title(title, fontsize=9)
279
+
280
+ # Add legend only to first subplot
281
+ # if dataset_idx == 0 and month_idx == 0:
282
+ ax.legend(loc='upper left', fontsize=8)
283
+
284
+ # Add grid
285
+ ax.grid(True, alpha=0.3)
286
+
287
+
288
+ plt.tight_layout()
289
+ plt.savefig(salida_figura, dpi=150, bbox_inches='tight')
290
+ print("********************")
291
+ print(f"✅Figura guardada correctamente: {salida_figura}")
292
+ print("********************")
293
+
294
+
295
+
296
+ print("********************")
297
+ print(f"✅ Archivo de estadísticas exportado: {salida_excel} ")
298
+ print("********************")
299
+
300
+
301
+ plt.show()
302
+
303
+
304
+ # Convertir diccionario a DataFrame de Pandas
305
+ df = pd.DataFrame(results)
306
+
307
+ # Exportar a Excel
308
+ df.to_excel(salida_excel, index=False)
309
+
310
+
311
+ return results, df
312
+
313
+
314
+
315
+ def plot_rx_monthly(archivo_excel: str, salida_figura: str, salida_excel: str,
316
+ mes_seleccionado: int = None, agrupar_anual: bool = False,
317
+ salida_interpolado: str = None):
318
+ """
319
+ Grafica y calcula tendencia para índices RX1day/RX5day a partir de un archivo
320
+ Excel con columnas: year, month, index.
321
+
322
+ Parámetros
323
+ ----------
324
+ archivo_excel : str
325
+ Ruta al archivo .xlsx con 3 columnas (year, month, index).
326
+ salida_figura : str
327
+ Ruta donde guardar la figura.
328
+ salida_excel : str
329
+ Ruta donde guardar los estadísticos.
330
+ mes_seleccionado : int, opcional (default=None)
331
+ Número del mes (1=Enero, ..., 12=Diciembre) a graficar.
332
+ Si se especifica, se filtra la serie para ese mes.
333
+ agrupar_anual : bool, opcional (default=False)
334
+ Si es True, se agrupa por año tomando el máximo (RX1day/RX5day anual).
335
+ Solo se usa si `mes_seleccionado` es None.
336
+ salida_interpolado : str, opcional (default=None)
337
+ Ruta donde guardar el archivo Excel con los datos interpolados.
338
+ Si no se proporciona, se genera automáticamente añadiendo "_interpolated"
339
+ al nombre del archivo de entrada.
340
+ """
341
+ # 1. Leer el archivo
342
+ df_original = pd.read_excel(archivo_excel)
343
+ # Guardar nombres de columnas originales
344
+ col_year = df_original.columns[0]
345
+ col_month = df_original.columns[1]
346
+ col_index = df_original.columns[2]
347
+
348
+ # Renombrar para estandarizar el manejo interno
349
+ df = df_original.rename(columns={col_year: 'year', col_month: 'month', col_index: 'index'})
350
+
351
+ # ---- NUEVO: Forzar conversión a numérico y detectar NaN ----
352
+ # Convertir columna 'index' a numérico, forzando errores a NaN
353
+ df['index'] = pd.to_numeric(df['index'], errors='coerce')
354
+
355
+ n_nan_original = df['index'].isna().sum()
356
+ if n_nan_original > 0:
357
+ print(f"ℹ Se encontraron {n_nan_original} valores faltantes en la columna '{col_index}'.")
358
+ print(" Se interpolarán linealmente sobre toda la serie mensual.")
359
+
360
+ # Crear índice temporal fraccionario (año + (mes-1)/12)
361
+ df['time'] = df['year'] + (df['month'] - 1) / 12.0
362
+ df_sorted = df.sort_values('time')
363
+ serie = df_sorted['index']
364
+
365
+ # Interpolación lineal (con relleno en bordes)
366
+ serie_interp = serie.interpolate(method='linear', limit_direction='both')
367
+ # Si aún quedan NaN (por ejemplo, toda la serie es NaN), rellenar con la media
368
+ if serie_interp.isna().any():
369
+ print("⚠ Aún quedan NaN después de interpolar. Se rellenarán con la media de los valores no NaN.")
370
+ serie_interp = serie_interp.fillna(serie_interp.mean())
371
+
372
+ # Reemplazar en el DataFrame ordenado
373
+ df_sorted['index'] = serie_interp
374
+ # Restaurar el orden original (por year, month)
375
+ df = df_sorted.sort_values(['year', 'month']).reset_index(drop=True)
376
+
377
+ # Eliminar columna temporal
378
+ df = df.drop(columns=['time'])
379
+
380
+ # Guardar archivo interpolado con los nombres de columna originales
381
+ if salida_interpolado is None:
382
+ import os
383
+ base, ext = os.path.splitext(archivo_excel)
384
+ salida_interpolado = f"{base}_interpolated{ext}"
385
+ # Restaurar nombres originales para guardar
386
+ df_out = df.rename(columns={'year': col_year, 'month': col_month, 'index': col_index})
387
+ df_out.to_excel(salida_interpolado, index=False)
388
+ print(f"✅ Archivo con datos interpolados guardado: {salida_interpolado}")
389
+ else:
390
+ print("✅ No se encontraron valores faltantes. No se genera archivo interpolado.")
391
+
392
+ # A partir de aquí, trabajar con el DataFrame ya interpolado (df)
393
+ # 2. Preparar serie temporal según el modo elegido
394
+ if mes_seleccionado is not None:
395
+ # --- Modo: mes específico ---
396
+ if not (1 <= mes_seleccionado <= 12):
397
+ raise ValueError("mes_seleccionado debe estar entre 1 y 12.")
398
+ df_filtrado = df[df['month'] == mes_seleccionado].copy()
399
+ if df_filtrado.empty:
400
+ raise ValueError(f"No hay datos para el mes {mes_seleccionado}.")
401
+ time_array = df_filtrado['year'].values
402
+ data_array = df_filtrado['index'].values
403
+ xlabel = 'Año'
404
+ mes_nombre = calendar.month_abbr[mes_seleccionado]
405
+ titulo_extra = f' - Mes: {mes_nombre}'
406
+ modo_texto = f'para {mes_nombre}'
407
+ mes_guardado = mes_seleccionado
408
+ elif agrupar_anual:
409
+ # --- Modo: máximo anual ---
410
+ df_anual = df.groupby('year', as_index=False)['index'].max()
411
+ time_array = df_anual['year'].values
412
+ data_array = df_anual['index'].values
413
+ xlabel = 'Año'
414
+ titulo_extra = ' (máximo anual)'
415
+ modo_texto = 'anual'
416
+ mes_guardado = 'Anual'
417
+ else:
418
+ # --- Modo: serie mensual completa (por defecto) ---
419
+ time_array = df['year'].values + (df['month'].values - 1) / 12.0
420
+ data_array = df['index'].values
421
+ xlabel = 'Año (serie mensual)'
422
+ titulo_extra = ' (mensual)'
423
+ modo_texto = 'mensual completa'
424
+ mes_guardado = 'Todos'
425
+
426
+ # 3. Calcular estadísticos (con los datos ya interpolados)
427
+ tau, p_value, trend, z_stat = mann_kendall_test(data_array)
428
+ slope, intercept, slope_ci_lower, slope_ci_upper = theil_sen_estimator_with_ci(
429
+ time_array, data_array
430
+ )
431
+ trend_line, trend_line_lower, trend_line_upper = calculate_confidence_bands(
432
+ time_array, data_array, slope, intercept, slope_ci_lower, slope_ci_upper
433
+ )
434
+
435
+ # 4. Guardar estadísticos en Excel
436
+ resultados = pd.DataFrame([{
437
+ 'Variable': col_index,
438
+ 'Mes': mes_guardado,
439
+ 'Modo': modo_texto,
440
+ 'Kendall_tau': tau,
441
+ 'p_value': p_value,
442
+ 'Tendencia': trend,
443
+ 'Z-statistic': z_stat,
444
+ 'Sen_slope': slope,
445
+ 'IC_inferior': slope_ci_lower,
446
+ 'IC_superior': slope_ci_upper,
447
+ 'Intercept': intercept
448
+ }])
449
+ resultados.to_excel(salida_excel, index=False)
450
+ print(f"✅ Archivo de estadísticas exportado: {salida_excel}")
451
+
452
+ # 5. Graficar
453
+ fig, ax = plt.subplots(figsize=(20, 12))
454
+ ax.plot(time_array, data_array, 'o-', color='black', alpha=0.7,
455
+ markersize=4, linewidth=1, label='Datos')
456
+ ax.plot(time_array, trend_line, '-', color='red', linewidth=2,
457
+ label='Tendencia Theil-Sen')
458
+ ax.fill_between(time_array, trend_line_lower, trend_line_upper,
459
+ color='red', alpha=0.2, label='IC 95%')
460
+ ax.set_xlabel(xlabel)
461
+ # Unidades (intentar obtener del nombre de la variable)
462
+ unidades_dict = {
463
+ 'RX1day': 'mm',
464
+ 'RX5day': 'mm',
465
+ 'R10mm': 'días',
466
+ 'R20mm': 'días',
467
+ 'CDD': 'días',
468
+ 'CWD': 'días',
469
+ 'PRCPTOT': 'mm',
470
+ 'SDII': 'mm/day',
471
+ 'R95P': 'mm',
472
+ 'R99P': 'mm'
473
+ }
474
+ # Buscar el nombre de la variable en el diccionario (puede ser parte del nombre)
475
+ unidades = ''
476
+ for key, unit in unidades_dict.items():
477
+ if key in col_index:
478
+ unidades = unit
479
+ break
480
+ ylabel = f'{col_index} ({unidades})' if unidades else col_index
481
+ ax.set_ylabel(ylabel)
482
+ ymin = np.nanmin(data_array) - 1
483
+ ymax = np.nanmax(data_array) + 1
484
+ ax.set_ylim(ymin, ymax)
485
+
486
+ significance = "**" if p_value < 0.01 else "*" if p_value < 0.05 else ""
487
+ title = f'{col_index}{titulo_extra}\n'
488
+ title += f'Tendencia: {trend}{significance}\n'
489
+ title += f'Pendiente: {slope:.4f} {unidades}/año\n'
490
+ title += f'IC 95%: [{slope_ci_lower:.4f}, {slope_ci_upper:.4f}]\n'
491
+ title += f'τ = {tau:.3f}, p = {p_value:.3f}'
492
+ ax.set_title(title, fontsize=12)
493
+ ax.legend(loc='upper left', fontsize=10)
494
+ ax.grid(True, alpha=0.3)
495
+
496
+ plt.tight_layout()
497
+ plt.savefig(salida_figura, dpi=150, bbox_inches='tight')
498
+ print(f"✅ Figura guardada en: {salida_figura}")
499
+ plt.show()
500
+ plt.close(fig)
501
+
502
+ return resultados, df
503
+
504
+
505
+
506
+ ###############################################################################################
507
+ ###############################
508
+ ###############################
509
+ # Datos Grid NetCDF
510
+
511
+ import netCDF4 as nc
512
+
513
+ from tqdm import tqdm
514
+ import warnings
515
+ warnings.filterwarnings("ignore")
516
+
517
+
518
+ # ── pymannkendall opcional ───────────────────────────────────
519
+ try:
520
+ import pymannkendall as mk
521
+ USE_PYMANNKENDALL = True
522
+ # print("✔ pymannkendall disponible.")
523
+ except ImportError:
524
+ USE_PYMANNKENDALL = False
525
+ # print("⚠ pymannkendall no encontrado. Usando implementación propia (scipy).")
526
+
527
+
528
+ # ════════════════════════════════════════════════════════════
529
+ # 1. FUNCIONES DE TENDENCIA
530
+ # ════════════════════════════════════════════════════════════
531
+
532
+ def mann_kendall_test_malla(x):
533
+ """Mann-Kendall + Theil-Sen implementación propia."""
534
+ x = np.asarray(x, dtype=float)
535
+ n = len(x)
536
+
537
+ s = 0
538
+ for k in range(n - 1):
539
+ for j in range(k + 1, n):
540
+ s += np.sign(x[j] - x[k])
541
+
542
+ var_s = n * (n - 1) * (2 * n + 5) / 18
543
+
544
+ if s > 0:
545
+ z = (s - 1) / np.sqrt(var_s)
546
+ elif s < 0:
547
+ z = (s + 1) / np.sqrt(var_s)
548
+ else:
549
+ z = 0.0
550
+
551
+ p_valor = 2 * (1 - stats.norm.cdf(abs(z)))
552
+ tau = s / (0.5 * n * (n - 1))
553
+
554
+ slopes = [(x[j] - x[k]) / (j - k)
555
+ for k in range(n - 1)
556
+ for j in range(k + 1, n)]
557
+ pendiente = float(np.median(slopes)) if slopes else 0.0
558
+ t = np.arange(n)
559
+ intercepto = float(np.median(x - pendiente * t))
560
+
561
+ return tau, p_valor, pendiente, intercepto
562
+
563
+
564
+ def calcular_tendencia_punto(serie):
565
+ """Calcula todas las métricas para una serie temporal 1-D."""
566
+ serie = np.asarray(serie, dtype=float)
567
+ mask = ~np.isnan(serie)
568
+ n_validos = int(mask.sum())
569
+
570
+ resultado = dict(tau=np.nan, p_valor=np.nan,
571
+ pendiente=np.nan, intercepto=np.nan,
572
+ significativo=False, tendencia=0)
573
+
574
+ if n_validos < 4:
575
+ return resultado
576
+
577
+ x = serie[mask]
578
+
579
+ if USE_PYMANNKENDALL:
580
+ res = mk.original_test(x)
581
+ tau = float(res.Tau)
582
+ p_valor = float(res.p)
583
+ pendiente = float(res.slope)
584
+ intercepto = float(res.intercept)
585
+ else:
586
+ tau, p_value, trend_str, z_stat = mann_kendall_test(x)
587
+ t = np.arange(len(x))
588
+ pendiente, intercepto, _, _ = theil_sen_estimator_with_ci(t, x)
589
+
590
+ # ✅ Conversión aquí dentro, con el nombre correcto
591
+ pendiente = float(pendiente) if not np.isnan(pendiente) else 0.0
592
+ tau = float(tau)
593
+ p_valor = float(p_value) # ← p_value → p_valor
594
+ intercepto = float(intercepto)
595
+
596
+
597
+ resultado.update(
598
+ tau=tau,
599
+ p_valor=p_valor,
600
+ pendiente=pendiente,
601
+ intercepto=float(intercepto),
602
+ significativo=bool(p_valor < 0.05),
603
+ tendencia=int(np.sign(pendiente)) if pendiente != 0 else 0
604
+ )
605
+ return resultado
606
+
607
+
608
+ # ════════════════════════════════════════════════════════════
609
+ # 2. LECTURA DEL NETCDF
610
+ # ════════════════════════════════════════════════════════════
611
+
612
+ def leer_netcdf(ruta_archivo, nombre_variable=None):
613
+ """
614
+ Lee el NetCDF y devuelve los datos (con escalado), lat, lon,
615
+ nombre de variable, long_name y units.
616
+ Selecciona automáticamente la variable principal si no se especifica.
617
+ """
618
+ ds = nc.Dataset(ruta_archivo, 'r')
619
+
620
+ # Detectar coordenadas
621
+ coord_lat = next((v for v in ds.variables if v.lower() in ('lat', 'latitude', 'rlat', 'y')), None)
622
+ coord_lon = next((v for v in ds.variables if v.lower() in ('lon', 'longitude', 'rlon', 'x')), None)
623
+
624
+ if coord_lat is None or coord_lon is None:
625
+ raise ValueError("No se encontraron coordenadas lat/lon en el archivo.")
626
+
627
+ lats = np.array(ds.variables[coord_lat][:], dtype=float)
628
+ lons = np.array(ds.variables[coord_lon][:], dtype=float)
629
+
630
+ # Si el usuario no especifica la variable, la seleccionamos automáticamente
631
+ if nombre_variable is None:
632
+ # Variables a excluir (dimensiones, coordenadas, metadatos)
633
+ excluir = {
634
+ 'time', 'tiempo', 'time_bnds', 'time_bounds',
635
+ 'lon', 'lat', 'latitude', 'longitude', 'rlon', 'rlat', 'x', 'y',
636
+ 'spatial_ref', 'crs', 'crs_wkt', 'grid_mapping',
637
+ 'number_of_5day_heavy_precipitation_periods_per_time_period' # ¡añadimos esta!
638
+ }
639
+ # También excluimos cualquier variable que sea de tipo string o no tenga dimensiones espaciales
640
+ candidatas = []
641
+ for v in ds.variables:
642
+ if v in excluir or v in ds.dimensions:
643
+ continue
644
+ # Verificar que la variable tenga al menos dos dimensiones (lat, lon)
645
+ dims = ds.variables[v].dimensions
646
+ if coord_lat in dims and coord_lon in dims:
647
+ candidatas.append(v)
648
+ if not candidatas:
649
+ raise ValueError("No se encontró ninguna variable con coordenadas lat/lon.")
650
+ # Elegir la variable con más dimensiones (priorizar 3D sobre 2D)
651
+ candidatas.sort(key=lambda v: len(ds.variables[v].dimensions), reverse=True)
652
+ nombre_variable = candidatas[0]
653
+ print(f"ℹ Variable seleccionada automáticamente: '{nombre_variable}'")
654
+
655
+ var = ds.variables[nombre_variable]
656
+ long_name = getattr(var, 'long_name', nombre_variable)
657
+ units = getattr(var, 'units', '')
658
+
659
+ # Lectura + escalado
660
+ raw = var[:]
661
+ if hasattr(var, 'scale_factor') or hasattr(var, 'add_offset'):
662
+ scale = float(getattr(var, 'scale_factor', 1.0))
663
+ offset = float(getattr(var, 'add_offset', 0.0))
664
+ datos = np.asarray(raw, dtype=float) * scale + offset
665
+ else:
666
+ datos = np.asarray(raw, dtype=float)
667
+
668
+ # FillValue
669
+ fill_value = getattr(var, '_FillValue', None) or getattr(var, 'missing_value', None)
670
+ if fill_value is not None:
671
+ datos[np.isclose(datos, float(fill_value), rtol=1e-5, atol=1e8)] = np.nan
672
+
673
+ ds.close()
674
+ return datos, lats, lons, nombre_variable, long_name, units
675
+
676
+
677
+
678
+ # ════════════════════════════════════════════════════════════
679
+ # 3. CÁLCULO EN TODA LA GRILLA
680
+ # ════════════════════════════════════════════════════════════
681
+
682
+ def calcular_tendencias_grilla(datos):
683
+
684
+ if datos.ndim != 3:
685
+ raise ValueError(f"Se esperaban 3 dimensiones (tiempo, lat, lon), pero se obtuvieron {datos.ndim}.")
686
+ ntime, nlat, nlon = datos.shape
687
+ if ntime < 4:
688
+ raise ValueError(f"Solo hay {ntime} pasos de tiempo. Se necesitan al menos 4 para calcular tendencia.")
689
+
690
+ _, nlat, nlon = datos.shape
691
+ tau_map = np.full((nlat, nlon), np.nan)
692
+ pval_map = np.full((nlat, nlon), np.nan)
693
+ pend_map = np.full((nlat, nlon), np.nan)
694
+ sig_map = np.zeros((nlat, nlon), dtype=bool)
695
+ tend_map = np.zeros((nlat, nlon), dtype=int)
696
+
697
+ # print(f"\n📊 Procesando {nlat}×{nlon} = {nlat*nlon} puntos de malla...")
698
+ with tqdm(total=nlat * nlon, ncols=70, unit='pts') as pbar:
699
+ for i in range(nlat):
700
+ for j in range(nlon):
701
+ res = calcular_tendencia_punto(datos[:, i, j])
702
+ tau_map[i, j] = res['tau']
703
+ pval_map[i, j] = res['p_valor']
704
+ pend_map[i, j] = res['pendiente']
705
+ sig_map[i, j] = res['significativo']
706
+ tend_map[i, j] = res['tendencia']
707
+ pbar.update(1)
708
+
709
+ return tau_map, pval_map, pend_map, sig_map, tend_map
710
+
711
+
712
+ # ════════════════════════════════════════════════════════════
713
+ # 4. HELPERS DE FIGURA
714
+ # ════════════════════════════════════════════════════════════
715
+
716
+ def _base_ax(fig, lats, lons, shapefile_ruta):
717
+ """Crea un eje cartopy con fondo, costas y grilla estándar."""
718
+ proj = ccrs.PlateCarree()
719
+ extent = [lons.min() - 1, lons.max() + 1,
720
+ lats.min() - 1, lats.max() + 1]
721
+ ax = fig.add_subplot(1, 1, 1, projection=proj)
722
+ ax.set_extent(extent, crs=proj)
723
+ ax.set_facecolor('white')
724
+ ax.add_feature(cfeature.OCEAN, facecolor='None', edgecolor='black', zorder=0)
725
+ ax.add_feature(cfeature.LAND, facecolor='None', edgecolor='black', zorder=0)
726
+ ax.add_feature(cfeature.BORDERS, linewidth=0.4, edgecolor='black', zorder=3)
727
+ ax.add_feature(cfeature.COASTLINE, linewidth=0.6, edgecolor='black', zorder=3)
728
+ ax.add_feature(cfeature.STATES, linewidth=0.6, edgecolor='black', zorder=3)
729
+
730
+ # 8.1 Agregar shapefile opcional
731
+ if shapefile_ruta is not None and shapefile_ruta != '':
732
+ gdf = gpd.read_file(shapefile_ruta)
733
+ gdf.plot(ax=ax, transform=ccrs.PlateCarree(), edgecolor='gold', facecolor='none', linewidth=1)
734
+
735
+
736
+ gl = ax.gridlines(crs=proj, draw_labels=True,
737
+ linewidth=0.3, color='gray', alpha=0.6, linestyle='--')
738
+ gl.top_labels = False
739
+ gl.right_labels = False
740
+ gl.xlabel_style = dict(color='black', size=8)
741
+ gl.ylabel_style = dict(color='black', size=8)
742
+ gl.xformatter = LongitudeFormatter()
743
+ gl.yformatter = LatitudeFormatter()
744
+ return ax, proj
745
+
746
+
747
+ # cb_label = f'Pendiente Theil-Sen de {long_name} ({units})' if units else f'Pendiente Theil-Sen de {long_name}'
748
+
749
+
750
+ def _pcolormesh_cb(fig, ax, proj, lons, lats, data2d, cmap,
751
+ vmin, vmax, cb_label):
752
+ """Pinta pcolormesh + colorbar horizontal centrada en 0."""
753
+ if vmin is None and vmax is None:
754
+ # Límite simétrico: el 0 queda exactamente en el centro del colormap
755
+ abs_max = max(abs(np.nanpercentile(data2d, 2)),
756
+ abs(np.nanpercentile(data2d, 98)))
757
+ vn, vx = -abs_max, abs_max
758
+ else:
759
+ vn = vmin if vmin is not None else np.nanpercentile(data2d, 2)
760
+ vx = vmax if vmax is not None else np.nanpercentile(data2d, 98)
761
+
762
+ im = ax.pcolormesh(lons, lats, data2d,
763
+ cmap=cmap, vmin=vn, vmax=vx,
764
+ transform=proj, zorder=1,
765
+ shading='auto', alpha=0.9)
766
+ cb = plt.colorbar(im, ax=ax, orientation='horizontal',
767
+ pad=0.05, fraction=0.046, aspect=35)
768
+ cb.set_label(cb_label, color='black', fontsize=9)
769
+ cb.ax.tick_params(labelcolor='black', labelsize=8, colors='black')
770
+ cb.outline.set_edgecolor('black')
771
+
772
+
773
+ def _guardar(fig, archivo):
774
+ """Título, guardado y cierre."""
775
+ # fig.suptitle( color='#e8f4f8',
776
+ # fontsize=12, fontweight='bold', y=1.01)
777
+ plt.tight_layout()
778
+ plt.savefig(archivo, dpi=150,
779
+ bbox_inches='tight', facecolor=fig.get_facecolor())
780
+
781
+ print("************************")
782
+ print(f"✅ Figura guardada correctamente: {archivo}")
783
+ print("************************")
784
+
785
+ plt.show()
786
+
787
+ plt.close(fig)
788
+
789
+
790
+ # ════════════════════════════════════════════════════════════
791
+ # 5. GUARDAR RESULTADOS EN NETCDF
792
+ # ════════════════════════════════════════════════════════════
793
+
794
+ def guardar_resultados(lats, lons, tau_map, pval_map, pend_map,
795
+ sig_map, tend_map, nombre_var,
796
+ archivo_salida):
797
+ ds = nc.Dataset(archivo_salida, 'w', format='NETCDF4')
798
+ ds.createDimension('lat', len(lats))
799
+ ds.createDimension('lon', len(lons))
800
+
801
+ vl = ds.createVariable('lat', 'f4', ('lat',)); vl.units = 'degrees_north'; vl[:] = lats
802
+ vn = ds.createVariable('lon', 'f4', ('lon',)); vn.units = 'degrees_east'; vn[:] = lons
803
+
804
+ def add(name, data, long_name, units='1'):
805
+ v = ds.createVariable(name, 'f4', ('lat', 'lon'), fill_value=np.nan)
806
+ v.long_name = long_name; v.units = units; v[:] = data
807
+
808
+ add('kendall_tau', tau_map, f'Kendall Tau — {nombre_var}')
809
+ add('p_value', pval_map, f'p-valor Mann-Kendall — {nombre_var}')
810
+ add('theilsen_slope', pend_map, f'Pendiente Theil-Sen — {nombre_var}', 'unidad/paso')
811
+ add('significant', sig_map.astype(float), 'Significativo 95% (1=sí, 0=no)')
812
+ add('trend_sign', tend_map.astype(float),'Signo (+1 pos, -1 neg)')
813
+
814
+ ds.description = f'Tendencias de {nombre_var}. Mann-Kendall, Theil-Sen, Kendall Tau.'
815
+ ds.close()
816
+
817
+ print("************************")
818
+ print(f"✅ NetCDF de estadísticos guardado: {archivo_salida}")
819
+ print("************************")
820
+
821
+
822
+
823
+ #################################################################################
824
+ ###############################################################################
825
+ ########## GRAFICAR FIGURAS GRID (NETCDF)
826
+
827
+ class ETCCDI_precip_plot_malla:
828
+
829
+
830
+
831
+
832
+ def Plot_netcdf_1_tiempo(Archivo_NETCDF: str, Salida_FIGURA: str,
833
+ color_scale: str,
834
+ shapefile_ruta: str,
835
+ center_cmap=False,
836
+ levels=None,
837
+ set_global=False,
838
+ ax=None):
839
+ """
840
+ Grafica el primer paso de tiempo de una variable en un archivo NetCDF.
841
+ Usa leer_netcdf para obtener datos y coordenadas de forma robusta.
842
+ """
843
+ # 1. Leer el archivo con la función auxiliar (global)
844
+ datos, lats, lons, nombre_var, long_name, units = leer_netcdf(Archivo_NETCDF)
845
+
846
+ # 2. Seleccionar el primer tiempo si es 3D
847
+ if datos.ndim == 3:
848
+ data_array = datos[0, :, :] # (lat, lon)
849
+ elif datos.ndim == 2:
850
+ data_array = datos
851
+ else:
852
+ raise ValueError(f"Los datos tienen {datos.ndim} dimensiones, se esperaban 2 o 3.")
853
+
854
+ # 3. Asegurar que sean arrays numpy y con las formas correctas
855
+ lons = np.asarray(lons)
856
+ lats = np.asarray(lats)
857
+ data_array = np.asarray(data_array)
858
+
859
+ # Verificar consistencia
860
+ if lons.ndim != 1 or lats.ndim != 1:
861
+ raise ValueError("Las coordenadas deben ser 1D")
862
+ if data_array.shape != (len(lats), len(lons)):
863
+ raise ValueError(f"Forma de datos {data_array.shape} no coincide con (lat,lon) ({len(lats)},{len(lons)})")
864
+
865
+ # 4. Crear malla 2D para pcolormesh (más seguro y compatible con Cartopy)
866
+ lon2d, lat2d = np.meshgrid(lons, lats)
867
+
868
+ # 5. Configurar figura y ejes
869
+ projection = ccrs.PlateCarree()
870
+ if ax is None:
871
+ fig = plt.figure(figsize=(20, 12))
872
+ ax = plt.axes(projection=projection)
873
+ else:
874
+ fig = ax.get_figure()
875
+
876
+ # 6. Definir límites del colormap (usando percentiles)
877
+ valid = data_array[~np.isnan(data_array)]
878
+ if len(valid) == 0:
879
+ vmin, vmax = 0, 1
880
+ else:
881
+ vmin = float(np.percentile(valid, 1))
882
+ vmax = float(np.percentile(valid, 99.5))
883
+
884
+ # 7. Graficar con pcolormesh (usando malla 2D)
885
+ cmap = plt.get_cmap(color_scale)
886
+ im = ax.pcolormesh(lon2d, lat2d, data_array,
887
+ cmap=cmap, vmin=vmin, vmax=vmax,
888
+ transform=ccrs.PlateCarree(), shading='auto')
889
+
890
+ # 8. Agregar elementos geográficos
891
+ ax.add_feature(cfeature.BORDERS, edgecolor='black', linewidth=0.4)
892
+ ax.add_feature(cfeature.COASTLINE, edgecolor='black', linewidth=0.5)
893
+ ax.add_feature(cfeature.STATES, edgecolor='black', linewidth=0.4)
894
+
895
+
896
+
897
+ # 8.1 Agregar shapefile opcional
898
+ if shapefile_ruta is not None and shapefile_ruta != '':
899
+ gdf = gpd.read_file(shapefile_ruta)
900
+ gdf.plot(ax=ax, transform=ccrs.PlateCarree(), edgecolor='gold', facecolor='none', linewidth=1)
901
+
902
+
903
+ # 9. Extensión del mapa
904
+ ax.set_extent([lons.min()-1, lons.max()+1, lats.min()-1, lats.max()+1],
905
+ crs=ccrs.PlateCarree())
906
+
907
+ # 10. Etiquetas de ejes y grid
908
+ dx = 5
909
+ xticks = np.arange(np.floor(lons.min()), np.ceil(lons.max()) + 1, dx)
910
+ yticks = np.arange(np.floor(lats.min()), np.ceil(lats.max()) + 1, dx)
911
+ ax.set_xticks(xticks, crs=ccrs.PlateCarree())
912
+ ax.set_yticks(yticks, crs=ccrs.PlateCarree())
913
+ ax.xaxis.set_major_formatter(LongitudeFormatter())
914
+ ax.yaxis.set_major_formatter(LatitudeFormatter())
915
+
916
+ # 11. Colorbar
917
+ cb_label = f"{long_name} [{units}]" if units else long_name
918
+ plt.colorbar(im, ax=ax, orientation='vertical', pad=0.03,
919
+ fraction=0.035, aspect=30, label=cb_label)
920
+
921
+ # 12. Líneas de grid (opcional)
922
+ ax.gridlines(xlocs=xticks, ylocs=yticks, alpha=0.6, color='gray',
923
+ draw_labels=False, linewidth=0.25, linestyle='--')
924
+
925
+ # 13. Guardar y mostrar
926
+ plt.tight_layout()
927
+ plt.savefig(Salida_FIGURA, dpi=200, bbox_inches='tight', facecolor='white')
928
+ print(f"✅ Figura guardada correctamente: {Salida_FIGURA}")
929
+ plt.show()
930
+ plt.close(fig)
931
+
932
+ return fig, ax, im
933
+
934
+
935
+ # ════════════════════════════════════════════════════════════
936
+ # 4D. FIGURA 4 — Mapa de tendencias + significancia
937
+ # ════════════════════════════════════════════════════════════
938
+
939
+ def plot_netcdf_n_tiempos(Archivo_NC: str, Salida_FIG: str, Salida_NC: str, color_scale: str, shapefile_ruta:str):
940
+ # print(f"\n📂 Leyendo: {ARCHIVO_NC}")
941
+ VARIABLE = None
942
+
943
+ datos, lats, lons, nombre_var, long_name, units = leer_netcdf(Archivo_NC, VARIABLE)
944
+
945
+ # Calcular tendencias
946
+ tau_map, pval_map, pend_map, sig_map, tend_map = calcular_tendencias_grilla(datos)
947
+
948
+ fig = plt.figure(figsize=(12, 8))
949
+ fig.patch.set_facecolor('white') ##0d1117
950
+ ax, proj = _base_ax(fig, lats, lons, shapefile_ruta)
951
+
952
+ cmap = plt.get_cmap(color_scale)
953
+
954
+ cb_label = f'Pendiente Theil-Sen de {long_name} ({units})' if units else f'Pendiente Theil-Sen de {long_name}'
955
+
956
+ # Fondo con pendiente
957
+ _pcolormesh_cb(fig, ax, proj, lons, lats, pend_map,
958
+ cmap, None, None, cb_label )
959
+
960
+ lon2d, lat2d = np.meshgrid(lons, lats)
961
+
962
+ # Puntos significativos
963
+ for tend_val, color_s, marker_s, label_s in [
964
+ ( 1, 'None', '^', 'Tendencia ↑ (p < 0.05)'),
965
+ (-1, 'None', 'v', 'Tendencia ↓ (p < 0.05)')]:
966
+ mask = (tend_map == tend_val) & sig_map
967
+ if mask.sum() == 0:
968
+ continue
969
+ ax.scatter(lon2d[mask], lat2d[mask],
970
+ marker=marker_s, c=color_s, s=20, alpha=0.9,
971
+ linewidths=1.0, edgecolors='black',
972
+ transform=proj, zorder=5, label=label_s)
973
+
974
+ handles, labels = ax.get_legend_handles_labels()
975
+ if handles:
976
+ ax.legend(handles=handles, labels=labels, loc='lower left',
977
+ fontsize=9, facecolor='white', edgecolor='black')
978
+
979
+ n_sig = int(sig_map.sum())
980
+ n_tot = int(np.sum(~np.isnan(tau_map)))
981
+ pct_sig = 100 * n_sig / n_tot if n_tot > 0 else 0
982
+
983
+ # ax.set_title(f'{long_name}\nTendencias significativas: {n_sig}/{n_tot} ({pct_sig:.1f}%) | α=0.05',
984
+ # color='black', fontsize=12, fontweight='bold', pad=10)
985
+
986
+ _guardar(fig, Salida_FIG)
987
+
988
+
989
+ guardar_resultados(lats, lons, tau_map, pval_map, pend_map,
990
+ sig_map, tend_map, nombre_var,
991
+ archivo_salida=Salida_NC)
992
+
993
+
994
+ # # # Print summary table
995
+ # # print("\n" + "="*120)
996
+ # # print("MANN-KENDALL TEST AND THEIL-SEN ESTIMATOR WITH 95% CONFIDENCE INTERVALS")
997
+ # # print("="*120)
998
+ # # print(f"{'Dataset':<8} {'Month':<10} {'Tau':<8} {'P-value':<10} {'Trend':<12} {'Slope':<10} "
999
+ # # f"{'CI Lower':<10} {'CI Upper':<10} {'Sig':<4}")
1000
+ # # print("-"*120)
1001
+
1002
+ # # for result in results:
1003
+ # # significance = "**" if result['P-value'] < 0.01 else "*" if result['P-value'] < 0.05 else ""
1004
+ # # print(f"{result['Dataset']:<8} {result['Month']:<10} "
1005
+ # # f"{result['Tau']:<8.3f} {result['P-value']:<10.3f} "
1006
+ # # f"{result['Trend']:<12} {result['Slope (mm/year)']:<10.4f} "
1007
+ # # f"{result['Slope CI Lower']:<10.4f} {result['Slope CI Upper']:<10.4f} "
1008
+ # # f"{significance:<4}")
1009
+
1010
+
1011
+
1012
+
1013
+
1014
+
1015
+
1016
+
1017
+
1018
+
1019
+