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.
- icepyus/Ploteo/ETCCDI_precip_plot.py +1019 -0
- icepyus/Ploteo/__init__.py +1 -0
- icepyus/Procesamiento/ETCCDI_precip_procesamiento.py +1442 -0
- icepyus/Procesamiento/__init__.py +1 -0
- icepyus/__init__.py +2 -0
- icepyus-1.0.1.dist-info/METADATA +148 -0
- icepyus-1.0.1.dist-info/RECORD +10 -0
- icepyus-1.0.1.dist-info/WHEEL +5 -0
- icepyus-1.0.1.dist-info/licenses/LICENSE +21 -0
- icepyus-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|