functional_time_series 0.0.1__tar.gz
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.
- functional_time_series-0.0.1/LICENSE +14 -0
- functional_time_series-0.0.1/PKG-INFO +57 -0
- functional_time_series-0.0.1/README.md +29 -0
- functional_time_series-0.0.1/pyproject.toml +115 -0
- functional_time_series-0.0.1/src/functional_time_series/__init__.py +1 -0
- functional_time_series-0.0.1/src/functional_time_series/fts_spatial_entrypoint.py +58 -0
- functional_time_series-0.0.1/src/functional_time_series/fts_spatial_module/__init__.py +1 -0
- functional_time_series-0.0.1/src/functional_time_series/fts_spatial_module/fts_spatial_module.py +202 -0
- functional_time_series-0.0.1/src/functional_time_series/fts_spatial_module/hdfpca.py +252 -0
- functional_time_series-0.0.1/src/functional_time_series/helpers.py +136 -0
- functional_time_series-0.0.1/src/functional_time_series/monitoring.py +25 -0
|
@@ -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.
|
|
@@ -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
|
+

|
|
29
|
+

|
|
30
|
+
[](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,29 @@
|
|
|
1
|
+

|
|
2
|
+

|
|
3
|
+
[](https://github.com/DTC-Ice-Sheets/functional_time_series/blob/main/LICENSE)
|
|
4
|
+
|
|
5
|
+
# DTC-IS _Functional Time Series_
|
|
6
|
+
|
|
7
|
+
The `functional_time_series` module is a sub-component of the
|
|
8
|
+
[Digital Twin Component for Ice Sheets (DTC-IS)](https://dtc-ice-sheets.org/), an ESA-funded, EO-driven digital twin project.
|
|
9
|
+
|
|
10
|
+
The `functional_time_series` module enables users to fit a continuous model to an input gaussian random field of
|
|
11
|
+
values such as surface meltwater runoff or supraglacial lake intensity."
|
|
12
|
+
|
|
13
|
+
For an in-depth overview of the functional_time_series module please [read the docs](https://dtc-ice-sheets.github.io/functional_time_series/).
|
|
14
|
+
|
|
15
|
+
The documentation includes sections on:
|
|
16
|
+
|
|
17
|
+
1. [Getting started](https://dtc-ice-sheets.github.io/functional_time_series/quick-start.html)
|
|
18
|
+
1. [Scientific overview](https://dtc-ice-sheets.github.io/functional_time_series/overview.html)
|
|
19
|
+
1. [API reference](https://dtc-ice-sheets.github.io/functional_time_series/api.html)
|
|
20
|
+
|
|
21
|
+
For a more interactive way of exploring the system, please visit the digital twin's [dashboard](https://dashboards.dtc-ice-sheets.org/).
|
|
22
|
+
|
|
23
|
+
## Installation for Use
|
|
24
|
+
|
|
25
|
+
You can install this package with pip:
|
|
26
|
+
|
|
27
|
+
``` bash
|
|
28
|
+
pip install functional_time_series
|
|
29
|
+
```
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry>=1.5"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[tool.poetry]
|
|
6
|
+
name = "functional_time_series"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "DTC-IS module that fits a Functional Time Series to an input field of a Gaussian Random Field."
|
|
9
|
+
authors = [
|
|
10
|
+
"Amber Leeson <a.leeson@lancaster.ac.uk>",
|
|
11
|
+
"DTC-IS consortium <support@dtc-ice-sheets.org>",
|
|
12
|
+
]
|
|
13
|
+
license = "MIT"
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
homepage = "https://github.com/dtc-ice-sheets/functional_time_series"
|
|
16
|
+
packages = [{ include = "functional_time_series", from = "src" }]
|
|
17
|
+
|
|
18
|
+
[tool.poetry.dependencies]
|
|
19
|
+
python = ">=3.12,<4.0"
|
|
20
|
+
numpy = "^2.3"
|
|
21
|
+
# rpy2 = "^3.6.4"
|
|
22
|
+
# pandas = "^2.3.3"
|
|
23
|
+
xarray = "^2025.12.0"
|
|
24
|
+
zarr = "^3.1.2"
|
|
25
|
+
dask = { extras = ["array"], version = "^2026.8.0" }
|
|
26
|
+
shapely = "^2.1.2"
|
|
27
|
+
cyclopts = "^4.8.0"
|
|
28
|
+
requests = "^2.34.2"
|
|
29
|
+
aiohttp = "^3.14.1"
|
|
30
|
+
rioxarray = "^0.19.0"
|
|
31
|
+
|
|
32
|
+
[tool.poetry.group.dev.dependencies]
|
|
33
|
+
pytest = "^9.0.2"
|
|
34
|
+
typeguard = "^4.5.1"
|
|
35
|
+
pre-commit = "^4.5"
|
|
36
|
+
ruff = "^0.17.4"
|
|
37
|
+
bandit = "^1.9.3"
|
|
38
|
+
pytest-cov = "^7.0.0"
|
|
39
|
+
sphinx = "^9.1.0"
|
|
40
|
+
sphinx-rtd-theme = "^3.1.0"
|
|
41
|
+
sphinx-copybutton = "^0.5.2"
|
|
42
|
+
commitizen = "^4.12.1"
|
|
43
|
+
pydoclint = "^0.8.3"
|
|
44
|
+
pyproj = "^3.8.0"
|
|
45
|
+
|
|
46
|
+
[tool.poetry.group.deployment.dependencies]
|
|
47
|
+
dtc-wrapper = { git = "https://github.com/DTC-Ice-Sheets/dtc_wrapper.git", branch = "main" }
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 120
|
|
51
|
+
target-version = "py312"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
preview = true
|
|
55
|
+
select = [
|
|
56
|
+
"T20", # Disallow print statements
|
|
57
|
+
"A", # Ambiguity
|
|
58
|
+
"ANN", # Annotations
|
|
59
|
+
"B", # Bad practices
|
|
60
|
+
"C90", # Complexity
|
|
61
|
+
"D", # Documentation
|
|
62
|
+
"E", # Errors
|
|
63
|
+
"F", # Pyflakes (e.g. unused imports and vars)
|
|
64
|
+
"G", # logging formatting
|
|
65
|
+
"I", # Imports
|
|
66
|
+
"N", # Naming
|
|
67
|
+
"Q", # Quoting
|
|
68
|
+
"W", # Warnings
|
|
69
|
+
"PT", # pytest-style
|
|
70
|
+
"SIM", # flake8-simplify
|
|
71
|
+
"C4", # flake8-comprehensions
|
|
72
|
+
"ARG", # flake8-unused-arguments
|
|
73
|
+
"PIE", # flake8-pie
|
|
74
|
+
"RUF", # Ruff-specific rules
|
|
75
|
+
"BLE", # Blind except
|
|
76
|
+
"RET", # Return statements
|
|
77
|
+
"PTH", # Use pathlib
|
|
78
|
+
"PD", # Pandas
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
[tool.pydoclint]
|
|
82
|
+
style = 'numpy'
|
|
83
|
+
check_style_mismatch = "true"
|
|
84
|
+
|
|
85
|
+
mccabe = { max-complexity = 10 }
|
|
86
|
+
|
|
87
|
+
ignore = [
|
|
88
|
+
"D203", # One blank line required before class docstring (conflicts with D211)
|
|
89
|
+
"D204", # One blank line required after class docstring
|
|
90
|
+
"D205", # Docstring with no content
|
|
91
|
+
"D212", # Multi-line docstring summary should start at the second line
|
|
92
|
+
"D213", # Multi-line docstring summary should start at the first line
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
[tool.ruff.lint.per-file-ignores]
|
|
96
|
+
"tests/**" = [
|
|
97
|
+
"print", # Allow print statements in test files, as they are commonly used for debugging
|
|
98
|
+
"module-import-not-at-top-of-file", # Allow 'import' statements not being at the top of the file (common in test files for clarity)
|
|
99
|
+
"assert", # Allow the use of 'assert' statements in test files (common practice in pytest)
|
|
100
|
+
"undocumented-public-function", # Ignore missing docstring warnings for functions in test files (commonly not required for small test functions)
|
|
101
|
+
"ANN", # Ignore missing type annotations for functions in test files (commonly not required for small test functions)
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
[tool.ruff.lint.pydocstyle]
|
|
105
|
+
convention = "numpy" # Accepts: "google", "numpy", or "pep257"
|
|
106
|
+
[tool.bandit.assert_used]
|
|
107
|
+
skips = ['*_test.py', '*/test_*.py']
|
|
108
|
+
|
|
109
|
+
[tool.pytest.ini_options]
|
|
110
|
+
addopts = "--disable-warnings --cov functional_time_series --cov-fail-under=80"
|
|
111
|
+
typeguard-packages = """functional_time_series"""
|
|
112
|
+
typeguard-debug-instrumentation = true
|
|
113
|
+
typeguard-forward-ref-policy = "ERROR"
|
|
114
|
+
typeguard-collection-check-strategy = "ALL_ITEMS"
|
|
115
|
+
markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]
|
|
@@ -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."""
|
functional_time_series-0.0.1/src/functional_time_series/fts_spatial_module/fts_spatial_module.py
ADDED
|
@@ -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)
|