ticoi 0.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.

Potentially problematic release.


This version of ticoi might be problematic. Click here for more details.

ticoi/cube_writer.py ADDED
@@ -0,0 +1,741 @@
1
+ import datetime
2
+ from typing import Dict, List, Optional, Tuple, Union
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ import xarray as xr
7
+
8
+ from ticoi.cube_data_classxr import CubeDataClass
9
+ from ticoi.interpolation_functions import smooth_results
10
+
11
+ # %% ======================================================================== #
12
+ # Hardcoded configs #
13
+ # =========================================================================%% #
14
+
15
+ BASE_CONFIGS = {
16
+ "velocity": {
17
+ "suffixes": ["x", "y", "z", "h"],
18
+ "directions": ["East/West", "North/South", "Up/Down", "nSPF"],
19
+ "unit": "m year-1",
20
+ "var_prefix": "v",
21
+ "final_var_tpl": "v{dim}",
22
+ "long_name_tpl": "velocity in the {direction} direction",
23
+ },
24
+ "displacement": {
25
+ "suffixes": ["x", "y", "z", "h"],
26
+ "directions": ["East/West", "North/South", "Up/Down", "nSPF"],
27
+ "unit": "m",
28
+ "var_prefix": "result_d",
29
+ "final_var_tpl": "d{dim}",
30
+ "long_name_tpl": "cumulative displacement in the {direction} direction",
31
+ },
32
+ "contribution": {
33
+ "flag": "X_contribution",
34
+ "suffixes": ["x", "y", "z", "h"],
35
+ "unit": "count",
36
+ "var_prefix": "xcount_",
37
+ "final_var_tpl": "xcount_{dim}",
38
+ "long_name_tpl": "number of Y observations contributing to X estimation ({dim_upper})",
39
+ },
40
+ "error": {
41
+ "flag": "Error_propagation",
42
+ "suffixes": ["x", "y", "z", "h"],
43
+ "unit": "m year-1",
44
+ "var_prefix": "error_",
45
+ "final_var_tpl": "error_{dim}",
46
+ "long_name_tpl": "Error propagated for the displacement in {dim_upper} direction",
47
+ },
48
+ }
49
+
50
+ QUALITY_METRIC_CONFIGS = {
51
+ "Norm_residual": {
52
+ "vars": ["ResidualAXY_dx", "ResidualRegu_dx", "ResidualAXY_dy", "ResidualRegu_dy"],
53
+ "source_col": "NormR",
54
+ "long_names": [
55
+ "Residual from the inversion AX=Y, where Y is the displacement in the direction Est/West",
56
+ "Residual from the regularisation term for the displacement in the direction Est/West",
57
+ "Residual from the inversion AX=Y, where Y is the displacement in the direction North/South",
58
+ "Residual from the regularisation term for the displacement in the direction North/South",
59
+ ],
60
+ "unit": "m",
61
+ }
62
+ }
63
+
64
+
65
+ # %% ======================================================================== #
66
+ # WRITING RESULTS AS NETCDF #
67
+ # =========================================================================%% #
68
+
69
+
70
+ class CubeResultsWriter:
71
+ def __init__(self, cube: CubeDataClass):
72
+ self.ds = cube.ds
73
+ self.nx = cube.nx
74
+ self.ny = cube.ny
75
+ self.proj4 = cube.ds.proj4
76
+ self.variable_configs = {}
77
+
78
+ def write_result_ticoi(
79
+ self,
80
+ result: list,
81
+ source: str,
82
+ sensor: str,
83
+ filename: str = "Time_series",
84
+ savepath: Optional[str] = None,
85
+ result_quality: Optional[List[str]] = None,
86
+ smooth_res: bool = False,
87
+ smooth_window_size: int = 3,
88
+ return_result: bool = False,
89
+ verbose: bool = False,
90
+ ) -> Union["CubeDataClass", str, Tuple["CubeDataClass", list]]:
91
+ """
92
+ Write the result from TICOI, stored in result, in a xarray dataset matching the conventions CF-1.11
93
+ http://cfconventions.org/Data/cf-conventions/cf-conventions-1.11/cf-conventions.pdf
94
+
95
+ :param result: [list] --- List of pd xarray, results from the TICOI method
96
+ :param source: [str] --- Name of the source
97
+ :param sensor: [str] --- Sensors which have been used
98
+ :param filename: [str] [default is Time_series] --- Filename of file to saved
99
+ :param savepath: [Optional[str]] [default is None] --- Path to save file
100
+ :param result_quality: [list | str | None] [default is None] --- Which can contain 'Norm_residual' to determine the L2 norm of the residuals from the last inversion, 'X_contribution' to determine the number of Y observations which have contributed to estimate each value in X (it corresponds to A.dot(weight)):param savepath: string, path where to save the file
101
+ :param smooth_res: [bool] [default is False] --- Smooth the residuals before saving
102
+ :param smooth_window_size:[int] [default is 3] --- Size of the smoothing kernel
103
+ :param return_result: [bool] [default is False] --- If True, return result
104
+ :param verbose: [bool] [default is False] --- Print information throughout the process
105
+
106
+ :return cubenew: [cube_data_class] --- New cube where the results are saved
107
+ """
108
+ if not self._validate_input(result):
109
+ return "No results to write or save."
110
+
111
+ dimensions = self._detect_dimensions(result) # detect needed dimension (x,y and possibly z and h)
112
+ if verbose:
113
+ print(f"[Writing results] Detected dimensions: {dimensions}")
114
+
115
+ self.variable_configs = self._generate_variable_configs(
116
+ dimensions
117
+ ) # set variable long_names,short_names, and unit
118
+
119
+ time_base, non_null_el = self._get_time_base(result)
120
+
121
+ cubenew = self._initialize_cube(time_variable=time_base, add_date_vars=True, non_null_el=non_null_el)
122
+
123
+ available_vars = self._detect_available_variables(non_null_el, result_quality)
124
+ self._process_velocity_variables(cubenew, result, available_vars, time_base, smooth_res, smooth_window_size)
125
+
126
+ if result_quality: # if there are quality metrics
127
+ self._process_2d_quality_metrics(cubenew, result, result_quality)
128
+
129
+ self._set_metadata(cubenew, source, sensor, dimensions)
130
+
131
+ if savepath:
132
+ self._save_cube(cubenew, savepath, filename, verbose)
133
+
134
+ return (cubenew, result) if return_result else cubenew
135
+
136
+ def write_result_tico(
137
+ self,
138
+ result: list,
139
+ source: str,
140
+ sensor: str,
141
+ filename: str = "Time_series_invert",
142
+ savepath: Optional[str] = None,
143
+ result_quality: Optional[List[str]] = None,
144
+ return_result: bool = False,
145
+ verbose: bool = False,
146
+ ) -> Union["CubeDataClass", str]:
147
+ """
148
+ Write the result from TICOI, stored in result, in a xarray dataset matching the conventions CF-1.11
149
+ http://cfconventions.org/Data/cf-conventions/cf-conventions-1.11/cf-conventions.pdf
150
+
151
+ :param result: [list] --- List of pd xarray, results from the TICOI method
152
+ :param source: [str] --- Name of the source
153
+ :param sensor: [str] --- Sensors which have been used
154
+ :param filename: [str] [default is Time_series] --- Filename of file to saved
155
+ :param savepath: [Optional[str]] [default is None] --- Path to save file
156
+ :param result_quality: [list | str | None] [default is None] --- Which can contain 'Norm_residual' to determine the L2 norm of the residuals from the last inversion, 'X_contribution' to determine the number of Y observations which have contributed to estimate each value in X (it corresponds to A.dot(weight))
157
+ :param return_result: [bool] [default is False] --- If True, return result
158
+ :param verbose: [bool] [default is False] --- Print information throughout the process
159
+
160
+ :return cubenew: [cube_data_class] --- New cube where the results are saved
161
+ """
162
+ if not self._validate_input(result):
163
+ return "No results to write or save."
164
+
165
+ dimensions = self._detect_dimensions(result)
166
+ if verbose:
167
+ print(f"[Writing results] Detected dimensions: {dimensions}")
168
+
169
+ self.variable_configs = self._generate_variable_configs(dimensions)
170
+
171
+ sample = next((r for r in result if not r.empty), None) # first results not empty
172
+ available_vars = self._detect_available_variables(sample, result_quality)
173
+
174
+ reconstructed_data, time_base, ref_dates = self._vectorized_reconstruct(
175
+ result, available_vars
176
+ ) # reconstruct cumulative displacement time series
177
+
178
+ cubenew = self._initialize_cube(time_variable=time_base)
179
+ self._set_reference_date(
180
+ cubenew, ref_dates
181
+ ) # set reference date (i.e. the first date of the cumulative displacement time series
182
+
183
+ final_var_map = self._build_final_var_map()
184
+
185
+ for var_name, data_array in reconstructed_data.items():
186
+ if var_name in final_var_map:
187
+ config, idx = final_var_map[var_name]
188
+ self._add_variable_to_cube(
189
+ cubenew,
190
+ var_name,
191
+ data_array,
192
+ config["long_names"][idx],
193
+ config["unit"],
194
+ )
195
+
196
+ elif verbose:
197
+ print(f"Warning: Configuration for variable '{var_name}' not found. Skipping.")
198
+
199
+ self._set_metadata(cubenew, source, sensor, dimensions)
200
+
201
+ if savepath:
202
+ self._save_cube(cubenew, savepath, filename, verbose)
203
+
204
+ return (cubenew, result) if return_result else cubenew
205
+
206
+ def _process_2d_quality_metrics(self, cube: "CubeDataClass", result: list, result_quality: List[str]):
207
+ """
208
+ Processes and adds 2D quality metrics to the data cube.
209
+ :param cube: [CubeDataClass] --- Cube data class
210
+ :param result: [list] --- List of pd xarray, results from the TICOI method
211
+ :param result_quality: [list | str | None] [default is None] --- Which can contain 'Norm_residual' to determine the L2 norm of the residuals from the last inversion, 'X_contribution' to determine the number of Y observations which have contributed to estimate each value in X (it corresponds to A.dot(weight))
212
+ :return:
213
+ """
214
+
215
+ if "Norm_residual" not in result_quality:
216
+ return
217
+
218
+ config = QUALITY_METRIC_CONFIGS.get("Norm_residual")
219
+ if not config:
220
+ return
221
+
222
+ source_col = config["source_col"]
223
+ sample = next((r for r in result if not r.empty and source_col in r), None)
224
+ if sample is None:
225
+ return
226
+
227
+ for i, var_name in enumerate(config["vars"]):
228
+ data_arr = np.full((self.nx, self.ny), np.nan, dtype=np.float32)
229
+
230
+ for p_idx, df in enumerate(result):
231
+ if not df.empty and source_col in df and df[source_col].shape[0] > i:
232
+ x = p_idx // self.ny
233
+ y = p_idx % self.ny
234
+ data_arr[x, y] = df[source_col][i]
235
+
236
+ cube.ds[var_name] = xr.DataArray(data_arr, dims=["x", "y"], coords={"x": cube.ds["x"], "y": cube.ds["y"]})
237
+ cube.ds[var_name] = cube.ds[var_name].transpose("y", "x")
238
+ cube.ds[var_name].attrs = {
239
+ "short_name": var_name,
240
+ "unit": config["unit"],
241
+ "long_name": config["long_names"][i],
242
+ "grid_mapping": "grid_mapping",
243
+ }
244
+
245
+ def _build_final_var_map(self) -> Dict[str, Tuple[Dict, int]]:
246
+ """
247
+ Builds a mapping from a final variable name (e.g., 'dx') to its config and index.
248
+ :return:
249
+ """
250
+ final_var_map = {}
251
+ for config in self.variable_configs.values():
252
+ for i, final_var in enumerate(config.get("final_vars", [])):
253
+ final_var_map[final_var] = (config, i)
254
+ return final_var_map
255
+
256
+ def _vectorized_reconstruct(
257
+ self, result: list, available_vars: Dict
258
+ ) -> Tuple[Dict[str, np.ndarray], pd.Series, np.ndarray]:
259
+ """
260
+ A fully vectorized replacement for the original `reconstruct_common_ref` loop.
261
+ :param result: [list] --- List of pd xarray, result from the TICOI method
262
+ :param available_vars:[Dict] --- dictionary of available variables
263
+ :return:
264
+ """
265
+
266
+ all_dates = sorted(list({date for df in result if not df.empty for date in df["date2"]}))
267
+ time_axis = pd.Series(all_dates, dtype="datetime64[ns]")
268
+ time_len = len(time_axis)
269
+
270
+ vars_to_process = []
271
+ for var_type, final_var_list in available_vars.items():
272
+ if var_type in ["displacement", "contribution", "error"]:
273
+ config = self.variable_configs[var_type]
274
+ for final_var in final_var_list:
275
+ if final_var in config["final_vars"]:
276
+ idx = config["final_vars"].index(final_var)
277
+ vars_to_process.append(config["vars"][idx])
278
+
279
+ if not vars_to_process:
280
+ return {}, time_axis, np.full((self.nx, self.ny), np.nan, dtype="datetime64[ns]")
281
+
282
+ final_var_names = {v: v.replace("result_d", "d") for v in vars_to_process}
283
+
284
+ reconstructed_data = {
285
+ final_name: np.full((self.nx, self.ny, time_len), np.nan, dtype=np.float32)
286
+ for final_name in final_var_names.values()
287
+ } # initialize the reconstructed array as 3D array
288
+ ref_dates_array = np.full((self.nx, self.ny), np.nan, dtype="datetime64[ns]")
289
+
290
+ max_pixel_len = 0
291
+ if result and any(not df.empty for df in result):
292
+ max_pixel_len = max(len(df) for df in result if not df.empty) # maximal temporal length of each pixel
293
+
294
+ if max_pixel_len == 0: # empty cube
295
+ return {}, time_axis, ref_dates_array
296
+
297
+ packed_data = {
298
+ v: np.full((self.nx * self.ny, max_pixel_len), np.nan, dtype=np.float32) for v in vars_to_process
299
+ } # flatten spatial dimensions
300
+ packed_dates = np.full(
301
+ (self.nx * self.ny, max_pixel_len), np.nan, dtype="datetime64[ns]"
302
+ ) # flatten spatial dimensions
303
+ pixel_lengths = np.zeros(self.nx * self.ny, dtype=int)
304
+
305
+ for i, df in enumerate(result):
306
+ if not df.empty:
307
+ n = len(df)
308
+ pixel_lengths[i] = n
309
+ ref_dates_array[i // self.ny, i % self.ny] = df["date1"].iloc[0]
310
+ packed_dates[i, :n] = df["date2"].values
311
+ for v in vars_to_process:
312
+ if v in df:
313
+ packed_data[v][i, :n] = df[v].values
314
+
315
+ cumulative_data = {
316
+ v: np.nancumsum(arr, axis=1) for v, arr in packed_data.items()
317
+ } # cumulative summation of displacement along time
318
+
319
+ # put the results in a 3D array
320
+ for i in range(self.nx * self.ny):
321
+ n = pixel_lengths[i]
322
+ if n > 0:
323
+ pixel_dates = packed_dates[i, :n]
324
+ insert_indices = np.searchsorted(time_axis, pixel_dates)
325
+
326
+ x, y = i // self.ny, i % self.ny
327
+ for v_orig, v_cum in cumulative_data.items():
328
+ v_new = final_var_names[v_orig]
329
+ reconstructed_data[v_new][x, y, insert_indices] = v_cum[i, :n]
330
+
331
+ return reconstructed_data, time_axis, ref_dates_array
332
+
333
+ def _prepare_variable_array(self, result: list, var: str, time_len: int) -> np.ndarray:
334
+ """
335
+ Efficiently prepares a 3D numpy array for a given variable from the result list.
336
+ :param result: [list] --- list with results from ticoi or tico
337
+ :param var : [str] --- variable name
338
+ :param time_len : [int] --- length of the time axis
339
+ :return: 3D numpy array
340
+ """
341
+ final_array = np.full((self.nx, self.ny, time_len), np.nan, dtype=np.float32)
342
+ for i in range(self.nx):
343
+ for j in range(self.ny):
344
+ idx = i * self.ny + j
345
+ if idx < len(result) and not result[idx].empty and var in result[idx]:
346
+ data_slice = result[idx][var].values
347
+ if data_slice.shape[0] == time_len:
348
+ final_array[i, j, :] = data_slice
349
+ return final_array
350
+
351
+ def _process_velocity_variables(
352
+ self,
353
+ cube: "CubeDataClass",
354
+ result: list,
355
+ available_vars: Dict,
356
+ time_variable: pd.Series,
357
+ smooth_res: bool,
358
+ smooth_window_size: int,
359
+ ):
360
+ """
361
+ Process and add all detected velocity-related variables to the data cube.
362
+ :param cube : [CubeDataClass] --- cube we are saving
363
+ :param result: [list] --- List of pd xarray, results from the TICOI method
364
+ :param available_vars:[Dict] --- dictionary of available variables
365
+ :param time_variable : [pd.Series] --- centered dates for each estimation
366
+ :param smooth_res: [bool] [default is False] --- Smooth the residuals before saving
367
+ :param smooth_window_size:[int] [default is 3] --- Size of the smoothing kernel
368
+
369
+ :return:
370
+ """
371
+ time_len = len(time_variable)
372
+
373
+ for var_type, var_list in available_vars.items():
374
+ if var_type not in self.variable_configs:
375
+ continue
376
+ config = self.variable_configs[var_type]
377
+
378
+ for i, final_var in enumerate(config.get("final_vars", [])):
379
+ if final_var not in var_list:
380
+ continue
381
+ original_var_name = config["vars"][i]
382
+ result_arr = self._prepare_variable_array(result, original_var_name, time_len) # create a 3D np array
383
+
384
+ if smooth_res and var_type == "velocity":
385
+ result_arr = self._smooth_array(
386
+ result_arr, smooth_window_size
387
+ ) # smooth the result by applying a spatial smoothing
388
+ self._update_result_list(result, original_var_name, result_arr)
389
+
390
+ self._add_variable_to_cube(
391
+ cube,
392
+ final_var,
393
+ result_arr,
394
+ config["long_names"][i],
395
+ config["unit"],
396
+ )
397
+
398
+ def _initialize_cube(
399
+ self, time_variable: pd.Series, add_date_vars: bool = False, non_null_el: Optional[pd.DataFrame] = None
400
+ ) -> "CubeDataClass":
401
+ """
402
+ Initialize a data cube with basic coordinates and time variables.
403
+ :param time_variable [pd.Series]: centered dates for each estimation
404
+ :param add_date_vars [bool]: If yes, add also the two dates between each the velocity have been estimated
405
+ :param non_null_el [Optional[pd.DataFrame]]: results which are not null
406
+ :return:
407
+ """
408
+ cubenew = CubeDataClass()
409
+ cubenew.nx = self.nx
410
+ cubenew.ny = self.ny
411
+ cubenew.proj4 = self.proj4
412
+
413
+ x_attrs = {"standard_name": "projection_x_coordinate", "units": "m", "long_name": "x coordinate of projection"}
414
+ y_attrs = {"standard_name": "projection_y_coordinate", "units": "m", "long_name": "y coordinate of projection"}
415
+
416
+ epoch = pd.Timestamp("1970-01-01")
417
+ time_values = (time_variable - epoch).dt.total_seconds() / (24 * 3600)
418
+ time_attrs = {
419
+ "standard_name": "time",
420
+ "long_name": "center date of the velocity estimation",
421
+ "units": "days since 1970-01-01 00:00:00",
422
+ "calendar": "gregorian",
423
+ }
424
+
425
+ cubenew.ds = xr.Dataset(
426
+ coords={
427
+ "x": ("x", self.ds["x"].values, x_attrs),
428
+ "y": ("y", self.ds["y"].values, y_attrs),
429
+ "time": ("time", time_values.values, time_attrs),
430
+ }
431
+ )
432
+
433
+ # Set grid mapping variable
434
+ cubenew.ds.rio.write_crs(self.proj4, inplace=True)
435
+ grid_mapping_attrs = cubenew.ds.coords["spatial_ref"].attrs
436
+ cubenew.ds = cubenew.ds.drop_vars("spatial_ref")
437
+ cubenew.ds["grid_mapping"] = xr.DataArray(0, attrs=grid_mapping_attrs)
438
+
439
+ if add_date_vars and non_null_el is not None:
440
+ date1_values = (non_null_el["date1"] - epoch).dt.total_seconds() / (24 * 3600)
441
+ date2_values = (non_null_el["date2"] - epoch).dt.total_seconds() / (24 * 3600)
442
+ time_bnds_data = np.vstack([date1_values, date2_values]).T
443
+ cubenew.ds["time_bnds"] = (("time", "bnds"), time_bnds_data)
444
+ cubenew.ds["time"].attrs["bounds"] = "time_bnds"
445
+
446
+ return cubenew
447
+
448
+ def _add_variable_to_cube(
449
+ self,
450
+ cube: "CubeDataClass",
451
+ var: str,
452
+ data: np.ndarray,
453
+ long_name: str,
454
+ unit: str,
455
+ ):
456
+ """
457
+ Add a variable as a DataArray to the data cube.
458
+ :param cube: [CubeDataClass] --- Cube data class
459
+ :param var: [str] --- variable name
460
+ :param data: [np.ndarray] --- data array to add as variable
461
+ :param long_name: [str] --- long name of the variable
462
+ :param unit: [str] --- unit of the variable
463
+ :return:
464
+ """
465
+ data_array = xr.DataArray(
466
+ data, dims=["x", "y", "time"], coords={"x": cube.ds["x"], "y": cube.ds["y"], "time": cube.ds["time"]}
467
+ )
468
+ cube.ds[var] = data_array.transpose("time", "y", "x")
469
+ attrs = {"units": unit, "long_name": long_name, "grid_mapping": "grid_mapping"}
470
+
471
+ attrs["short_name"] = var # no standard_name exist for our variables
472
+ cube.ds[var].attrs = attrs
473
+
474
+ def _set_reference_date(self, cube: "CubeDataClass", ref_dates: np.ndarray):
475
+ """
476
+ Set the reference date for displacement time series.
477
+ :param cube: [CubeDataClass] --- Cube data class
478
+ :param ref_dates: [np.ndarray] --- reference dates
479
+ :return:
480
+ """
481
+ epoch = pd.Timestamp("1970-01-01")
482
+ # This handles NaT (Not a Time) values, which will become NaN after conversion.
483
+ numerical_dates = (pd.to_datetime(ref_dates.flatten()) - epoch).total_seconds() / (24 * 3600)
484
+ numerical_dates_arr = numerical_dates.values.reshape(ref_dates.shape)
485
+
486
+ cube.ds["reference_date"] = xr.DataArray(
487
+ numerical_dates_arr, dims=["x", "y"], coords={"x": cube.ds["x"], "y": cube.ds["y"]}
488
+ )
489
+ cube.ds["reference_date"].attrs = {
490
+ "long_name": "First date of the cumulative displacement time series",
491
+ "units": "days since 1970-01-01 00:00:00",
492
+ }
493
+
494
+ def _validate_input(self, result: list) -> bool:
495
+ """
496
+ Check if the inputs are valid
497
+ :param result: [list] --- List of pd xarray, results from the TICOI method
498
+ :return:
499
+ """
500
+ return bool(result) and any(not r.empty for r in result)
501
+
502
+ def _get_time_base(self, result: list) -> Tuple[pd.Series, pd.DataFrame]:
503
+ """
504
+ Get the centered date (time_variable) and the results which are not null
505
+ :param result: [list] --- List of pd xarray, results from the TICOI method
506
+ :return: entered date (time_variable) and the results which are not null
507
+ """
508
+ non_null_el = next((r for r in result if not r.empty), None)
509
+ if non_null_el is None:
510
+ return pd.Series([], dtype="datetime64[ns]"), None
511
+ time_variable = non_null_el["date1"] + (non_null_el["date2"] - non_null_el["date1"]) / 2
512
+ return time_variable, non_null_el
513
+
514
+ def _detect_dimensions(self, result: list) -> List[str]:
515
+ """
516
+ Detect the dimension in cube result
517
+ :param result: [list] --- List of pd xarray, results from the TICOI method
518
+ :return: list of dimensions
519
+ """
520
+ sample = next((r for r in result if not r.empty), None)
521
+ if sample is None:
522
+ return []
523
+ dim_map = {
524
+ "vx": "x",
525
+ "vy": "y",
526
+ "vz": "z",
527
+ "vh": "h",
528
+ "result_dx": "x",
529
+ "result_dy": "y",
530
+ "result_dz": "z",
531
+ "result_dh": "h",
532
+ }
533
+ return sorted(list({dim_map[col] for col in sample.columns if col in dim_map}))
534
+
535
+ def _generate_variable_configs(self, dimensions: List[str]) -> Dict[str, Dict]:
536
+ """
537
+ Generate config files
538
+ :param dimensions [List[str]]:
539
+ :return: dict o configs
540
+ """
541
+ configs = {}
542
+ for var_type, base_config in BASE_CONFIGS.items():
543
+ vars_list, long_names, final_vars = [], [], []
544
+ for dim in dimensions:
545
+ if dim not in base_config["suffixes"]:
546
+ continue # if the dimension is not defined
547
+ vars_list.append(base_config["var_prefix"] + dim)
548
+ final_vars.append(base_config["final_var_tpl"].format(dim=dim))
549
+ direction = base_config.get("directions", [""] * len(dimensions))[base_config["suffixes"].index(dim)]
550
+ long_names.append(base_config["long_name_tpl"].format(direction=direction, dim_upper=dim.upper()))
551
+
552
+ if vars_list:
553
+ configs[var_type] = {
554
+ "vars": vars_list,
555
+ "long_names": long_names,
556
+ "unit": base_config["unit"],
557
+ "final_vars": final_vars,
558
+ "flag": base_config.get("flag"),
559
+ }
560
+ return configs
561
+
562
+ def _detect_available_variables(
563
+ self, sample_result: pd.DataFrame, result_quality: Optional[List[str]]
564
+ ) -> Dict[str, List[str]]:
565
+ """
566
+ Detect variable names inside the cube result
567
+ :param sample_result [pd.DataFrame]: result for one particular date
568
+ :param result_quality: [list | str | None] [default is None] --- Which can contain 'Norm_residual' to determine the L2 norm of the residuals from the last inversion, 'X_contribution' to determine the number of Y observations which have contributed to estimate each value in X (it corresponds to A.dot(weight))
569
+ :return:
570
+ """
571
+ if sample_result is None:
572
+ return {}
573
+
574
+ # Get available variable
575
+ available = {}
576
+ for var_type, config in self.variable_configs.items():
577
+ # Always include base types if they exist
578
+ if var_type in ["velocity", "displacement"]:
579
+ if any(var in sample_result for var in config["vars"]):
580
+ available[var_type] = config.get("final_vars", [])
581
+ continue
582
+
583
+ # For quality metrics, check if the flag is set
584
+ if result_quality and config.get("flag") in result_quality:
585
+ if any(var in sample_result for var in config["vars"]):
586
+ available[var_type] = config.get("final_vars", [])
587
+
588
+ return available
589
+
590
+ def _set_metadata(self, cube: "CubeDataClass", source: str, sensor: str, dimensions: List[str]):
591
+ """
592
+ Set the global attributes of the cube
593
+ :param cube: [CubeDataClass] --- Cube data class
594
+ :param source: [str] --- processing steps that have been applied
595
+ :param sensor: [str] --- satellite sensors used to compute the original displacements
596
+ :param dimensions: List[str] -- dimensions of the cube
597
+ """
598
+ cube.ds.attrs = {
599
+ "Conventions": "CF-1.11",
600
+ "title": "Ice velocity and displacement time series",
601
+ "institution": "Université Grenoble Alpes",
602
+ "source": source,
603
+ "sensor": sensor,
604
+ "proj4": self.ds.proj4,
605
+ "author": "L. Charrier",
606
+ "history": f"Created on {datetime.date.today()}",
607
+ "dimensions": f"{len(dimensions)}D ({', '.join(dimensions)})",
608
+ "references": "Charrier, L., et al. (2025)",
609
+ }
610
+
611
+ def _save_cube(self, cube: "CubeDataClass", savepath: str, filename: str, verbose: bool):
612
+ """
613
+ Saves the data cube to a NetCDF file with appropriate encoding.
614
+ :param cube: [CubeDataClass] --- Cube data class
615
+ :param savepath: [Optional[str]] [default is None] --- Path to save file
616
+ :param filename: [str] [default is Time_series] --- Filename of file to saved
617
+ :param verbose: [bool] [default is False] --- Print information throughout the process
618
+ :return:
619
+ """
620
+ encoding = {}
621
+ for var in cube.ds.data_vars:
622
+ if var in cube.ds.coords or var == "grid_mapping":
623
+ continue
624
+ encoding[var] = {"zlib": True, "complevel": 5, "dtype": "int16" if var.startswith("xcount") else "float32"}
625
+
626
+ if "time_bnds" in cube.ds:
627
+ encoding["time_bnds"] = {"_FillValue": None}
628
+
629
+ filepath = f"{savepath}/{filename}.nc"
630
+ cube.ds.to_netcdf(filepath, engine="h5netcdf", encoding=encoding)
631
+ if verbose:
632
+ print(f"[Writing results] Saved to {filepath}")
633
+
634
+ def _parse_proj4_to_cf_attrs(self) -> dict:
635
+ """convert proj4 string to CF attributes."""
636
+ attrs = {}
637
+ proj_map = {
638
+ "proj": "grid_mapping_name",
639
+ "lat_0": "latitude_of_projection_origin",
640
+ "lon_0": "longitude_of_projection_origin",
641
+ "lat_ts": "standard_parallel",
642
+ "x_0": "false_easting",
643
+ "y_0": "false_northing",
644
+ "datum": "datum",
645
+ }
646
+ value_map = {"stere": "polar_stereographic"}
647
+
648
+ # BUG FIX: Robustly parse proj4 string to handle flags without values
649
+ params = {}
650
+ for item in self.proj4.replace("+", "").strip().split():
651
+ if "=" in item:
652
+ key, value = item.split("=", 1)
653
+ params[key] = value
654
+ else:
655
+ params[item] = True # Treat flags like 'no_defs' as boolean
656
+
657
+ for key, value in params.items():
658
+ if key in proj_map:
659
+ try:
660
+ # Attempt to convert to float, otherwise use string value
661
+ cf_value = float(value_map.get(value, value))
662
+ except (ValueError, TypeError):
663
+ cf_value = value_map.get(value, value)
664
+ attrs[proj_map[key]] = cf_value
665
+
666
+ if attrs.get("datum") == "WGS84":
667
+ attrs.update({"semi_major_axis": 6378137.0, "inverse_flattening": 298.257223563})
668
+
669
+ attrs["crs_wkt"] = self.proj4
670
+ return attrs
671
+
672
+ def _smooth_array(self, array: np.ndarray, smooth_window_size: int) -> np.ndarray:
673
+ """
674
+
675
+ :param array: [np.ndarray] --- array to be smoothed
676
+ :param smooth_window_size:[int] [default is 3] --- size of the smoothing kernel
677
+ :return:
678
+ """
679
+ return smooth_results(array, window_size=smooth_window_size)
680
+
681
+ def _update_result_list(self, result: list, var: str, smoothed_array: np.ndarray):
682
+ """
683
+
684
+ :param result: [list] --- list of pd xarray, results from the TICOI method
685
+ :param var: [str] --- name of the variable
686
+ :param smoothed_array: [np.ndarray] --- smoothed array
687
+ :return:
688
+ """
689
+ for x in range(self.nx):
690
+ for y in range(self.ny):
691
+ idx = x * self.ny + y
692
+ if idx < len(result) and not result[idx].empty:
693
+ result[idx][var] = smoothed_array[x, y, :]
694
+
695
+ def write_results_ticoi_or_tico(
696
+ self,
697
+ result: list,
698
+ source: str,
699
+ sensor: str,
700
+ filename: str = "Time_series",
701
+ savepath: str | None = None,
702
+ result_quality: list | None = None,
703
+ verbose: bool = False,
704
+ ) -> Union["CubeDataClass", str]:
705
+ """
706
+ Write the result from TICOI or TICO, stored in result, in a xarray dataset matching the conventions CF-1.10
707
+ It recognizes whether the results are irregular or regular and uses the appropriate saving method
708
+ http://cfconventions.org/Data/cf-conventions/cf-conventions-1.10/cf-conventions.pdf
709
+ units has been changed to unit, since it was producing an error while wirtting the netcdf file
710
+
711
+ :param result: [list] --- List of pd xarray, results from the TICOI method
712
+ :param source: [str] --- Name of the source
713
+ :param sensor: [str] --- Sensors which have been used
714
+ :param filename: [str] [default is Time_series] --- Filename of file to saved
715
+ :param result_quality: [list | str | None] [default is None] --- Which can contain 'Norm_residual' to determine the L2 norm of the residuals from the last inversion, 'X_contribution' to determine the number of Y observations which have contributed to estimate each value in X (it corresponds to A.dot(weight)):param savepath: string, path where to save the file
716
+ :param verbose: [bool] [default is None] --- Print information throughout the process (default is False)
717
+
718
+ :return cubenew: [cube_data_class] --- New cube where the results are saved
719
+ """
720
+
721
+ if result[0].columns[0] == "date1":
722
+ self.write_result_ticoi(
723
+ result=result,
724
+ source=source,
725
+ sensor=sensor,
726
+ filename=filename,
727
+ savepath=savepath,
728
+ result_quality=result_quality,
729
+ verbose=verbose,
730
+ )
731
+ else:
732
+ self.write_result_tico(
733
+ result=result,
734
+ source=source,
735
+ sensor=sensor,
736
+ filename=filename,
737
+ savepath=savepath,
738
+ result_quality=result_quality,
739
+ verbose=verbose,
740
+ )
741
+ return self