functional_time_series 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.
@@ -0,0 +1 @@
1
+ """Functional Time Series package."""
@@ -0,0 +1,58 @@
1
+ """Entrypoint for FTS module."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import cyclopts
7
+ import xarray as xr
8
+
9
+ from functional_time_series.fts_spatial_module.fts_spatial_module import FtsSpatialModule
10
+ from functional_time_series.helpers import ZarrType, save_geo_zarr
11
+ from functional_time_series.monitoring import setup_logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ app = cyclopts.App()
16
+
17
+
18
+ @app.default
19
+ def main(
20
+ data_path: ZarrType,
21
+ output_path: Path,
22
+ curve_time_points: int = 5,
23
+ num_pca: int = 2,
24
+ num_factors: int = 3,
25
+ ) -> None:
26
+ """Run the Functional Time Series module.
27
+
28
+ Parameters
29
+ ----------
30
+ data_path : ZarrType
31
+ Path of the input GeoZarr file.
32
+ output_path : Path
33
+ Path of the output GeoZarr file.
34
+ curve_time_points : int, optional
35
+ Number of time points to be considered as a single curve
36
+ num_pca : int, optional
37
+ Number of principal components to retain. Only the first feeds the second step, so this does not
38
+ change the result.
39
+ num_factors: int, optional
40
+ Number of factors to retain in the second step dimension reduction
41
+ """
42
+ setup_logging()
43
+
44
+ fts_module = FtsSpatialModule()
45
+ ds = xr.load_dataset(data_path, engine="zarr", chunks={})
46
+
47
+ output = fts_module.run_spatial_analysis(
48
+ ds,
49
+ curve_time_points=curve_time_points,
50
+ num_pca=num_pca,
51
+ num_factors=num_factors,
52
+ )
53
+
54
+ save_geo_zarr(output, output_path)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ app()
@@ -0,0 +1 @@
1
+ """Functional Time Series modules."""
@@ -0,0 +1,202 @@
1
+ """Functional Time Series (FTS) module."""
2
+
3
+ import logging
4
+
5
+ import numpy as np
6
+ import xarray as xr
7
+
8
+ from functional_time_series.fts_spatial_module.hdfpca import hdfpca_factor_scores
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class FtsSpatialModule:
14
+ """FTS module: compresses each point's time series into curve coefficients."""
15
+
16
+ @staticmethod
17
+ def spatial_variables(ds: xr.Dataset) -> list[str]:
18
+ """
19
+ Names of the variables to analyse: those with exactly the dimensions time and point_id.
20
+
21
+ Parameters
22
+ ----------
23
+ ds : xr.Dataset
24
+ The input dataset.
25
+
26
+ Returns
27
+ -------
28
+ list[str]
29
+ Names of the variables with dimensions ("time", "point_id"), in dataset order.
30
+ """
31
+ return [str(v) for v in ds.data_vars if set(ds[v].dims) == {"time", "point_id"}]
32
+
33
+ @staticmethod
34
+ def as_point_cloud(ds: xr.Dataset) -> xr.Dataset:
35
+ """
36
+ Flatten a gridded dataset into the point cloud form this module analyses.
37
+
38
+ The GRF module interpolates its fit onto a regular grid before saving, so its published output has
39
+ the dimensions ("time", "x", "y") rather than ("time", "point_id"). Without this step
40
+ :func:`spatial_variables` matches nothing, every variable is dropped, and the result is an empty
41
+ dataset with no warning. Cells outside the fitted area are NaN and a curve cannot be built across a
42
+ gap, so a point is kept only where every variable is finite at every timestep.
43
+
44
+ Parameters
45
+ ----------
46
+ ds : xr.Dataset
47
+ The input dataset, either already a point cloud carrying a "point_id" dimension, or a grid
48
+ carrying x and y dimensions under those names or the old
49
+ "projection_x_coordinate" and "projection_y_coordinate".
50
+
51
+ Returns
52
+ -------
53
+ xr.Dataset
54
+ The same data with the two spatial dimensions stacked into "point_id", any variable that is
55
+ empty at a whole timestep removed, and the points that are not finite throughout the record
56
+ removed. A dataset that is already a point cloud, or that is neither form, is returned
57
+ unchanged.
58
+ """
59
+ if FtsSpatialModule.spatial_variables(ds):
60
+ return ds
61
+ if "projection_x_coordinate" in ds.dims:
62
+ ds = ds.rename({"projection_x_coordinate": "x", "projection_y_coordinate": "y"})
63
+ if "x" not in ds.dims or "y" not in ds.dims:
64
+ return ds
65
+
66
+ ds = ds.stack({"point_id": ["x", "y"]})
67
+ names = FtsSpatialModule.spatial_variables(ds)
68
+ if not names:
69
+ return ds
70
+
71
+ # A variable can be empty at a whole timestep while the others are not. The GRF
72
+ # module fills a snapshot that has no spatial variation with the constant it
73
+ # already holds and writes NaN for its uncertainty, since no posterior was
74
+ # sampled - so the uncertainty field of a lake record arrives empty for the
75
+ # months when the lakes have drained. There is no curve to fit through a
76
+ # timestep with no values, and the mask below is shared, so leaving such a
77
+ # variable in would drop every point in the catchment rather than analysing
78
+ # what is there.
79
+ empty = [v for v in names if not np.isfinite(ds[v].values).any(axis=1).all()]
80
+ if empty:
81
+ logger.warning("not analysing %s: no values at all in at least one timestep", ", ".join(empty))
82
+ ds = ds.drop_vars(empty)
83
+ names = FtsSpatialModule.spatial_variables(ds)
84
+ if not names:
85
+ return ds
86
+
87
+ finite = np.all([np.isfinite(ds[v].values) for v in names], axis=(0, 1))
88
+ if not finite.all():
89
+ logger.warning(
90
+ "dropping %d of %d points that are not finite throughout the record", int((~finite).sum()), finite.size
91
+ )
92
+ return ds.isel(point_id=finite)
93
+
94
+ @staticmethod
95
+ def whole_curves(data: np.ndarray, curve_time_points: int, name: str = "data") -> np.ndarray:
96
+ """
97
+ Truncate the time axis to a whole number of curves.
98
+
99
+ Every curve needs exactly curve_time_points values, so any time steps left over after the last
100
+ whole curve are dropped, with a warning.
101
+
102
+ This matches hdftsa as called from R. There, ``matrix(Coeff[k, ], delta, nn)`` with a non-whole
103
+ ``nn = tt/delta`` rounds the number of curves down and drops the same trailing values, so the
104
+ result is identical. The differences are that R does it silently apart from one R warning per
105
+ point, while this logs a single warning per variable.
106
+
107
+ Parameters
108
+ ----------
109
+ data : np.ndarray
110
+ Array of shape (n_points, n_times).
111
+ curve_time_points : int
112
+ Number of time points in one curve.
113
+ name : str, optional
114
+ Variable name, used only in the warning.
115
+
116
+ Returns
117
+ -------
118
+ np.ndarray
119
+ The input, truncated to the first whole number of curves along the time axis.
120
+ """
121
+ n_times = data.shape[1]
122
+ n_whole = (n_times // curve_time_points) * curve_time_points
123
+ if n_whole != n_times:
124
+ logger.warning(
125
+ "%s: %d time points is not a multiple of %d; using the first %d",
126
+ name,
127
+ n_times,
128
+ curve_time_points,
129
+ n_whole,
130
+ )
131
+ return data[:, :n_whole]
132
+
133
+ def run_spatial_analysis(
134
+ self,
135
+ ds: xr.Dataset,
136
+ curve_time_points: int = 24,
137
+ num_pca: int = 2,
138
+ num_factors: int = 3,
139
+ ) -> xr.Dataset:
140
+ """
141
+ Run spatial analysis on the given dataset.
142
+
143
+ Parameters
144
+ ----------
145
+ ds : xr.Dataset
146
+ The input dataset for analysis.
147
+ This must have the following dimensions:
148
+ - time
149
+ - point_id (flattened spatial coordinates)
150
+ Each of the spatial variables with dimensions ("time", "point_id") will be analyzed.
151
+ A gridded dataset with x and y dimensions in place of point_id is accepted too, and is
152
+ flattened by :func:`as_point_cloud` first.
153
+ curve_time_points : int, optional
154
+ Number of time points to be considered as a single curve
155
+ num_pca : int, optional
156
+ Number of principal components to retain. Only the first feeds the second step, so this does
157
+ not change the result.
158
+ num_factors: int, optional
159
+ Number of factors to retain in the second step dimension reduction
160
+
161
+ Returns
162
+ -------
163
+ xr.Dataset
164
+ The output dataset containing the model results, with dimensions:
165
+ - curve_id (length equal to the number of time points divided by curve_time_points, rounded down)
166
+ - coef_id (length equal to num_factors)
167
+ Each of the input spatial variables will have a corresponding variable of the same name, with dimensions
168
+ ("curve_id", "coef_id").
169
+ The input spatial_ref variable and its CRS metadata are preserved as a coordinate, if present.
170
+ Any other input variables will be discarded.
171
+
172
+ Raises
173
+ ------
174
+ ValueError
175
+ If the dataset holds no variable this module can analyse. Returning an empty dataset instead
176
+ would record the run as a success and leave the caller with a store holding no data.
177
+ """
178
+ ds = self.as_point_cloud(ds)
179
+ names = self.spatial_variables(ds)
180
+ if not names:
181
+ raise ValueError(
182
+ "no variable has the dimensions time and point_id, and the dataset is not a grid that can "
183
+ f"be flattened into them, so there is nothing to analyse: found {list(ds.data_vars)} over "
184
+ f"dimensions {list(ds.dims)}",
185
+ )
186
+
187
+ data_vars = {}
188
+ for var in names:
189
+ data = self.whole_curves(ds[var].transpose("point_id", "time").values, curve_time_points, var)
190
+ result = hdfpca_factor_scores(data, curve_time_points, num_pca, num_factors)
191
+ data_vars[var] = (("curve_id", "coef_id"), result)
192
+
193
+ output = xr.Dataset(
194
+ data_vars=data_vars,
195
+ coords={
196
+ "curve_id": np.arange(ds.sizes["time"] // curve_time_points),
197
+ "coef_id": np.arange(num_factors),
198
+ },
199
+ )
200
+ if "spatial_ref" in ds:
201
+ output = output.assign_coords(spatial_ref=ds["spatial_ref"].copy(deep=True))
202
+ return output
@@ -0,0 +1,252 @@
1
+ """
2
+ NumPy port of the part of ``hdfpca`` from the R package hdftsa that the FTS module uses.
3
+
4
+ ``hdfpca`` (Gao, Shang and Yang, 2019) reduces a panel of functional time series in two steps. First, each point's
5
+ series of curves is reduced by dynamic functional principal component analysis (``dfpca``), which takes the
6
+ eigenvectors of a long-run covariance rather than of the plain covariance. Second, the scores of the first principal
7
+ component from every point are stacked side by side and reduced again (``hdpca``), this time using the eigenvectors of
8
+ a sum of lagged autocovariance products. The FTS module keeps only the scores from that second step, so this port
9
+ stops there and leaves out the rest of ``hdfpca`` and the forecast that ``forecast.hdfpca`` builds on top of it.
10
+
11
+ The sources ported are ``hdfpca.R``, ``dfpca.R``, ``hdpca.R`` and ``wtflat.R`` from hdftsa 1.1.
12
+ """
13
+
14
+ import numpy as np
15
+
16
+ # hdpca's h0: the number of lags summed in the second step.
17
+ LONG_RUN_LAGS = 5
18
+
19
+
20
+ def flat_top_weight(x: float, c: float = 0.5) -> float:
21
+ """
22
+ Flat-top kernel weight, ported from hdftsa's ``wtflat``.
23
+
24
+ The weight is 1 on (-c, c) and falls linearly to 0 at -1 and 1. The R function uses a strict ``c < x`` on its
25
+ rising edge, so ``x == c`` falls through to 0 while ``x == -c`` gives 1. The quirk is kept so that results match
26
+ R exactly; it only matters when the number of curves is four times a square.
27
+
28
+ Parameters
29
+ ----------
30
+ x : float
31
+ Point at which to evaluate the kernel.
32
+ c : float, optional
33
+ Half-width of the flat top.
34
+
35
+ Returns
36
+ -------
37
+ float
38
+ The kernel weight at `x`.
39
+ """
40
+ if -1 < x <= -c:
41
+ return (x + 1) / (1 - c)
42
+ if -c < x < c:
43
+ return 1.0
44
+ if c < x < 1:
45
+ return (x - 1) / (c - 1)
46
+ return 0.0
47
+
48
+
49
+ def descending_eigenvectors(matrix: np.ndarray, order: int) -> np.ndarray:
50
+ """
51
+ Eigenvectors of the leading `order` eigenvalues of one or more symmetric matrices, largest first.
52
+
53
+ Like R's ``eigen(symmetric = TRUE)``, only the lower triangle is read.
54
+
55
+ Parameters
56
+ ----------
57
+ matrix : np.ndarray
58
+ Array of shape (..., n, n).
59
+ order : int
60
+ Number of eigenvectors to keep.
61
+
62
+ Returns
63
+ -------
64
+ np.ndarray
65
+ Array of shape (..., n, order), one eigenvector per column.
66
+ """
67
+ _, vectors = np.linalg.eigh(matrix, UPLO="L")
68
+ return vectors[..., ::-1][..., :order]
69
+
70
+
71
+ def dynamic_fpca_scores(curves: np.ndarray, order: int) -> np.ndarray:
72
+ """
73
+ Principal component scores of each point's curves, ported from hdftsa's ``dfpca``.
74
+
75
+ R loops over every lag and multiplies its autocovariance by the kernel weight, so it builds lags whose weight is
76
+ zero. Those lags are skipped here, which leaves roughly the square root of the number of curves to compute.
77
+
78
+ Parameters
79
+ ----------
80
+ curves : np.ndarray
81
+ Array of shape (n_points, n_curves, curve_time_points).
82
+ order : int
83
+ Number of principal components to keep.
84
+
85
+ Returns
86
+ -------
87
+ np.ndarray
88
+ Array of shape (n_points, n_curves, order).
89
+ """
90
+ n_curves = curves.shape[1]
91
+ bandwidth = np.sqrt(n_curves)
92
+ centred = curves - curves.mean(axis=1, keepdims=True)
93
+
94
+ long_run = np.einsum("pti,ptj->pij", centred, centred) / n_curves
95
+ for lag in range(1, n_curves):
96
+ # R stores lag `lag` at index h = lag + 1 and weights it by h / bandwidth, not lag / bandwidth.
97
+ h = lag + 1
98
+ forward = flat_top_weight(h / bandwidth)
99
+ backward = flat_top_weight(-h / bandwidth)
100
+ if forward == 0 and backward == 0:
101
+ continue
102
+ autocovariance = np.einsum("pti,ptj->pij", centred[:, lag:], centred[:, : n_curves - lag]) / n_curves
103
+ long_run += forward * autocovariance + backward * np.swapaxes(autocovariance, 1, 2)
104
+
105
+ return centred @ descending_eigenvectors(long_run, order)
106
+
107
+
108
+ def column_space_factor_directions(centred: np.ndarray, order: int) -> np.ndarray:
109
+ """
110
+ Leading eigenvectors of hdpca's ``f``, built directly as R builds it.
111
+
112
+ ``f`` is the sum over lags 1 to 5 of ``C_h C_hᵀ``, where ``C_h`` is the lag-h autocovariance between columns, so
113
+ it is n_columns square. That is cheap when there are few columns.
114
+
115
+ Parameters
116
+ ----------
117
+ centred : np.ndarray
118
+ Array of shape (n_rows, n_columns), with each column's mean removed.
119
+ order : int
120
+ Number of eigenvectors to keep.
121
+
122
+ Returns
123
+ -------
124
+ np.ndarray
125
+ Array of shape (n_columns, order), one eigenvector per column.
126
+ """
127
+ n_rows = centred.shape[0]
128
+ f = np.zeros((centred.shape[1], centred.shape[1]))
129
+ for h in range(1, LONG_RUN_LAGS + 1):
130
+ lagged = centred[h:].T @ centred[: n_rows - h] / (n_rows - h)
131
+ f += lagged @ lagged.T
132
+ return descending_eigenvectors(f, order)
133
+
134
+
135
+ def row_space_factor_directions(centred: np.ndarray, order: int) -> np.ndarray:
136
+ """
137
+ Leading eigenvectors of hdpca's ``f``, found without building ``f``.
138
+
139
+ Each ``C_h C_hᵀ`` in ``f`` equals ``centredᵀ M_h centred`` for a matrix ``M_h`` that is only n_rows square, so
140
+ ``f`` is ``Zᵀ Z`` for a ``Z`` with n_rows rows, and its eigenvectors are the right singular vectors of ``Z``. That
141
+ is cheap when there are few rows, which for a grid of points is the usual case.
142
+
143
+ Parameters
144
+ ----------
145
+ centred : np.ndarray
146
+ Array of shape (n_rows, n_columns), with each column's mean removed.
147
+ order : int
148
+ Number of eigenvectors to keep.
149
+
150
+ Returns
151
+ -------
152
+ np.ndarray
153
+ Array of shape (n_columns, order), one eigenvector per column.
154
+ """
155
+ n_rows = centred.shape[0]
156
+ weight = np.zeros((n_rows, n_rows))
157
+ for h in range(1, LONG_RUN_LAGS + 1):
158
+ earlier = centred[: n_rows - h]
159
+ weight[h:, h:] += earlier @ earlier.T / (n_rows - h) ** 2
160
+
161
+ values, vectors = np.linalg.eigh(weight)
162
+ z = (vectors * np.sqrt(np.clip(values, 0, None))).T @ centred
163
+ _, _, right = np.linalg.svd(z, full_matrices=False)
164
+ return right[:order].T
165
+
166
+
167
+ def factor_scores(x: np.ndarray, order: int) -> np.ndarray:
168
+ """
169
+ Scores of the leading factors of a panel of series, ported from the ``coef`` that hdftsa's ``hdpca`` returns.
170
+
171
+ R takes the eigenvectors of ``f``, a matrix that is n_columns square. A column here is a spatial point, so for a
172
+ grid ``f`` can have tens of thousands of rows, while a row is a curve, of which there are usually few. The
173
+ eigenvectors are therefore found from whichever of the columns or the rows is smaller; both give the same result.
174
+
175
+ An eigenvector's sign is arbitrary, and R and NumPy may pick differently, so each column of scores is flipped to
176
+ make its largest-magnitude entry positive. R's result therefore agrees with this one up to the sign of each column.
177
+
178
+ Parameters
179
+ ----------
180
+ x : np.ndarray
181
+ Array of shape (n_rows, n_columns): one row per time step, one column per series.
182
+ order : int
183
+ Number of factors to keep.
184
+
185
+ Returns
186
+ -------
187
+ np.ndarray
188
+ Array of shape (n_rows, order).
189
+
190
+ Raises
191
+ ------
192
+ ValueError
193
+ If there are too few rows for the lags summed, or more factors are asked for than the data can supply.
194
+ hdftsa stops with "subscript out of bounds" for too few rows, or for more factors than columns. Given more
195
+ factors than rows but no more than columns, it returns scores along eigenvectors whose eigenvalue is zero,
196
+ which are arbitrary; this refuses that case too.
197
+ """
198
+ n_rows, n_columns = x.shape
199
+ if n_rows <= LONG_RUN_LAGS:
200
+ raise ValueError(
201
+ f"{n_rows} curves is too few: the second step sums autocovariances over lags 1 to {LONG_RUN_LAGS}, so it "
202
+ f"needs at least {LONG_RUN_LAGS + 1}"
203
+ )
204
+ if order > min(n_rows, n_columns):
205
+ raise ValueError(
206
+ f"cannot keep {order} factors from {n_rows} curves at {n_columns} points; at most "
207
+ f"{min(n_rows, n_columns)} are available"
208
+ )
209
+
210
+ centred = x - x.mean(axis=0)
211
+ if n_columns <= n_rows:
212
+ scores = centred @ column_space_factor_directions(centred, order)
213
+ else:
214
+ scores = centred @ row_space_factor_directions(centred, order)
215
+
216
+ largest = scores[np.abs(scores).argmax(axis=0), np.arange(order)]
217
+ return scores * np.where(largest < 0, -1.0, 1.0)
218
+
219
+
220
+ def hdfpca_factor_scores(
221
+ data: np.ndarray,
222
+ curve_time_points: int,
223
+ num_pca: int,
224
+ num_factors: int,
225
+ ) -> np.ndarray:
226
+ """
227
+ Compute the factor scores of the first principal component, as ``hdfpca(...)$model$model2[[1]]$coef`` in hdftsa.
228
+
229
+ Only the first principal component of each point feeds the second step, so `num_pca` does not change the result
230
+ beyond needing to be at least 1. The data is converted to float64 first, as R works in double precision whatever
231
+ the input.
232
+
233
+ Parameters
234
+ ----------
235
+ data : np.ndarray
236
+ Array of shape (n_points, n_times), with n_times a whole multiple of `curve_time_points`.
237
+ curve_time_points : int
238
+ Number of time points in one curve.
239
+ num_pca : int
240
+ Number of principal components to keep in the first step.
241
+ num_factors : int
242
+ Number of factors to keep in the second step.
243
+
244
+ Returns
245
+ -------
246
+ np.ndarray
247
+ Array of shape (n_curves, num_factors).
248
+ """
249
+ n_points, n_times = data.shape
250
+ curves = np.asarray(data, dtype=np.float64).reshape(n_points, n_times // curve_time_points, curve_time_points)
251
+ first_component = dynamic_fpca_scores(curves, num_pca)[:, :, 0]
252
+ return factor_scores(first_component.T, num_factors)
@@ -0,0 +1,136 @@
1
+ """Utility functions for use across functional_time_series modules."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import numpy as np
8
+ import rioxarray # ruff:ignore[unused-import]
9
+ import xarray as xr
10
+ from cyclopts import Parameter
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def path_or_web_url_validator(type_: object, path_or_url: Path | str) -> str: # ruff:ignore[unused-function-argument] - needed type_ to match cyclopts validator signature
16
+ """Validate that the provided path is either a valid local path or a web URL.
17
+
18
+ NOTE: Path("") resolves to the current directory (".") - which is considered a valid path.
19
+
20
+ Parameters
21
+ ----------
22
+ type_ : object
23
+ The type of the argument being validated - unused, purely required for cyclopts validator compatibility.
24
+ path_or_url : Path | str
25
+ The candidate path or URL to validate.
26
+
27
+ Returns
28
+ -------
29
+ str
30
+ The validated path or URL as a string.
31
+
32
+ Raises
33
+ ------
34
+ ValueError
35
+ If the provided path is neither a valid local path nor a web URL.
36
+ """
37
+ if not Path(path_or_url).exists() and not str(path_or_url).startswith(("http://", "https://")):
38
+ raise ValueError(f"The provided path '{path_or_url}' is neither a valid local path nor a web URL.")
39
+ return str(path_or_url)
40
+
41
+
42
+ # Alias for use in cyclopts Parameter definitions.
43
+ # Override the sphinx documentation entry in the lines below since cyclopts doesn't produce nice results for
44
+ # custom validators:
45
+ #: A local path or web URL, represented as a string.
46
+ #:
47
+ #: Validated by :func:`path_or_web_url_validator`.
48
+ #:
49
+ #: :meta hide-value:
50
+ type ZarrType = Annotated[str, Parameter(validator=path_or_web_url_validator)]
51
+
52
+
53
+ def save_geo_zarr(ds: xr.Dataset, zarr_path: Path | str, target_chunk_size: str = "1024MB") -> None:
54
+ """Save dataset to GeoZarr adhering to CF conventions (https://cfconventions.org/).
55
+
56
+ Parameters
57
+ ----------
58
+ ds: xr.Dataset
59
+ The xarray dataset to save. This must follow the GeoZarr format,
60
+ including having a `spatial_ref` attribute with a valid CRS.
61
+ zarr_path: Path | str
62
+ Full path of the output .zarr file to be created.
63
+ target_chunk_size: str, optional
64
+ The target chunk size for the GeoZarr dataset, by default "1024MB".
65
+ """
66
+ ds = ds.rio.write_crs(ds["spatial_ref"].attrs["crs_wkt"], grid_mapping_name="spatial_ref")
67
+ ds = ds.reset_coords("spatial_ref")
68
+ if "time" in ds:
69
+ ds["time"] = prepare_cf_time_data_array(ds["time"])
70
+ # Prepare the spatial variables with the required attributes for GeoZarr.
71
+ for name, axis in (("x", "X"), ("y", "Y")):
72
+ if name in ds:
73
+ ds[name] = prepare_cf_spatial_data_array(ds[name], name=name, axis=axis)
74
+ ds = prepare_cf_data_variable_arrays(ds)
75
+
76
+ ds.attrs = {"_ARRAY_DIMENSIONS": ["spatial_ref", *sorted(ds.dims)]}
77
+ ds = ds.chunk(chunks=target_chunk_size)
78
+
79
+ ds.to_zarr(
80
+ str(zarr_path),
81
+ mode="w",
82
+ zarr_format=2,
83
+ consolidated=True,
84
+ )
85
+
86
+
87
+ def prepare_cf_data_variable_arrays(ds: xr.Dataset) -> xr.Dataset:
88
+ """Assign CF grid-mapping and fill-value metadata to dataset variables."""
89
+ for name in ds.variables:
90
+ variable = ds[name]
91
+ # Do not reuse source packing, time units, compression, or chunk encodings.
92
+ variable.encoding = {"coordinates": None}
93
+ if name != "spatial_ref":
94
+ variable.attrs["grid_mapping"] = "spatial_ref"
95
+
96
+ dtype = variable.dtype
97
+ fill_value = None
98
+ if variable.ndim:
99
+ if np.issubdtype(dtype, np.unsignedinteger):
100
+ fill_value = np.iinfo(dtype).max
101
+ else:
102
+ fill_value = {"f": -9999.0, "i": -9999, "b": 0, "U": ""}.get(dtype.kind)
103
+ # We want to preserve NaNs so set the _FillValue in .attrs, rather than .encoding.
104
+ variable.attrs["_FillValue"] = fill_value
105
+ return ds
106
+
107
+
108
+ def prepare_cf_spatial_data_array(
109
+ spatial_data_array: xr.DataArray,
110
+ name: str,
111
+ axis: str,
112
+ ) -> xr.DataArray:
113
+ """Assign the required GeoZarr attributes to a spatial coordinate."""
114
+ return spatial_data_array.assign_attrs(
115
+ variable_name_override=name,
116
+ standard_name=f"projection_{name}_coordinate",
117
+ long_name=f"projection_{name}_coordinate",
118
+ units="m",
119
+ axis=axis,
120
+ )
121
+
122
+
123
+ def prepare_cf_time_data_array(time_data_array: xr.DataArray) -> xr.DataArray:
124
+ """Convert a time coordinate to numeric epoch seconds with legacy CF metadata."""
125
+ logger.info("Converting type %s to seconds since epoch", time_data_array.dtype)
126
+ time_data_array = time_data_array.astype("datetime64[s]").astype("float64")
127
+ return time_data_array.assign_attrs(
128
+ {
129
+ "standard_name": "time",
130
+ "long_name": "time",
131
+ "axis": "T",
132
+ "bounds": "time_bnds",
133
+ "units": "seconds since 1970-01-01T00:00:00",
134
+ "calendar": "standard",
135
+ }
136
+ )
@@ -0,0 +1,25 @@
1
+ """Monitoring and logging tools."""
2
+
3
+ import logging
4
+
5
+
6
+ def setup_logging() -> None:
7
+ """
8
+ Set up root logger.
9
+
10
+ This configuration will be inherited by all module-level loggers created within the package.
11
+ """
12
+ # Create the root logger
13
+ logger = logging.getLogger()
14
+ logger.setLevel(logging.INFO)
15
+
16
+ # Create a console handler and set the level to info
17
+ console_handler = logging.StreamHandler()
18
+ console_handler.setLevel(logging.INFO)
19
+
20
+ # Create a formatter and set it for the console handler
21
+ formatter = logging.Formatter("[%(asctime)s] %(levelname)s in %(name)s: %(message)s")
22
+ console_handler.setFormatter(formatter)
23
+
24
+ # Add the console handler to the root logger
25
+ logger.addHandler(console_handler)
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: functional_time_series
3
+ Version: 0.0.1
4
+ Summary: DTC-IS module that fits a Functional Time Series to an input field of a Gaussian Random Field.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Author: Amber Leeson
8
+ Author-email: a.leeson@lancaster.ac.uk
9
+ Requires-Python: >=3.12,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Requires-Dist: aiohttp (>=3.14.1,<4.0.0)
17
+ Requires-Dist: cyclopts (>=4.8.0,<5.0.0)
18
+ Requires-Dist: dask[array] (>=2026.8.0,<2027.0.0)
19
+ Requires-Dist: numpy (>=2.3,<3.0)
20
+ Requires-Dist: requests (>=2.34.2,<3.0.0)
21
+ Requires-Dist: rioxarray (>=0.19.0,<0.20.0)
22
+ Requires-Dist: shapely (>=2.1.2,<3.0.0)
23
+ Requires-Dist: xarray (>=2025.12.0,<2026.0.0)
24
+ Requires-Dist: zarr (>=3.1.2,<4.0.0)
25
+ Project-URL: Homepage, https://github.com/dtc-ice-sheets/functional_time_series
26
+ Description-Content-Type: text/markdown
27
+
28
+ ![Tests](https://github.com/dtc-ice-sheets/functional_time_series/actions/workflows/functional_time_series_test.yml/badge.svg)
29
+ ![Deployment](https://github.com/dtc-ice-sheets/functional_time_series/actions/workflows/functional_time_series_deploy.yml/badge.svg)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/DTC-Ice-Sheets/functional_time_series/blob/main/LICENSE)
31
+
32
+ # DTC-IS _Functional Time Series_
33
+
34
+ The `functional_time_series` module is a sub-component of the
35
+ [Digital Twin Component for Ice Sheets (DTC-IS)](https://dtc-ice-sheets.org/), an ESA-funded, EO-driven digital twin project.
36
+
37
+ The `functional_time_series` module enables users to fit a continuous model to an input gaussian random field of
38
+ values such as surface meltwater runoff or supraglacial lake intensity."
39
+
40
+ For an in-depth overview of the functional_time_series module please [read the docs](https://dtc-ice-sheets.github.io/functional_time_series/).
41
+
42
+ The documentation includes sections on:
43
+
44
+ 1. [Getting started](https://dtc-ice-sheets.github.io/functional_time_series/quick-start.html)
45
+ 1. [Scientific overview](https://dtc-ice-sheets.github.io/functional_time_series/overview.html)
46
+ 1. [API reference](https://dtc-ice-sheets.github.io/functional_time_series/api.html)
47
+
48
+ For a more interactive way of exploring the system, please visit the digital twin's [dashboard](https://dashboards.dtc-ice-sheets.org/).
49
+
50
+ ## Installation for Use
51
+
52
+ You can install this package with pip:
53
+
54
+ ``` bash
55
+ pip install functional_time_series
56
+ ```
57
+
@@ -0,0 +1,11 @@
1
+ functional_time_series/__init__.py,sha256=a2_Tnpiytoo3C3wQAQYWB5YoXNo0jP-acKRy_UTCZPY,38
2
+ functional_time_series/fts_spatial_entrypoint.py,sha256=DM9Q6wQE_jHcnjaGRI950bs8X-ZA6ZneD6KvzgvaQms,1502
3
+ functional_time_series/fts_spatial_module/__init__.py,sha256=6i2M5fdUBP86xopcqgOB51YqBMcQk3CDzCR90S-cN-c,38
4
+ functional_time_series/fts_spatial_module/fts_spatial_module.py,sha256=R_4IUVtifY8OwRxav9QkgCiqwB11uAWFgFN7CwcpuTM,8588
5
+ functional_time_series/fts_spatial_module/hdfpca.py,sha256=t9kL4vG27rZ-CJ_HDUyybFM5JDdgSNIxFUUVM6oNWBE,9618
6
+ functional_time_series/helpers.py,sha256=FEZoyiYVN2LsP8KksQ_N9O5lwCeQFnr72G27gvA3vt8,4968
7
+ functional_time_series/monitoring.py,sha256=03Mm_O6VxZl0Nz-7zgj3hvmh9zPs8jZanoHpLBHDPdo,748
8
+ functional_time_series-0.0.1.dist-info/METADATA,sha256=HVGwfDxmlaOaYaqCvNAhNPJoJHgoAwKz6PCpGP3zgp8,2627
9
+ functional_time_series-0.0.1.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
10
+ functional_time_series-0.0.1.dist-info/licenses/LICENSE,sha256=550GeDi9mZblNmslE2iTQPbRBf1O3Nz45lkVyrlS4ho,1081
11
+ functional_time_series-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,14 @@
1
+ Copyright 2026 DTC-Ice-Sheets (DTC-IS) consortium
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
4
+ documentation files (the “Software”), to deal in the Software without restriction, including without limitation the
5
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
6
+ persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
9
+ Software.
10
+
11
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
12
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
13
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
14
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.