heatwave3 1.4.0__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.
Files changed (39) hide show
  1. heatwave3-1.4.0/CMakeLists.txt +108 -0
  2. heatwave3-1.4.0/LICENSE.md +21 -0
  3. heatwave3-1.4.0/PKG-INFO +67 -0
  4. heatwave3-1.4.0/pyproject.toml +84 -0
  5. heatwave3-1.4.0/python/README.md +35 -0
  6. heatwave3-1.4.0/python/heatwave3/__init__.py +38 -0
  7. heatwave3-1.4.0/python/heatwave3/_frames.py +163 -0
  8. heatwave3-1.4.0/python/heatwave3/_utils.py +158 -0
  9. heatwave3-1.4.0/python/heatwave3/blob.py +251 -0
  10. heatwave3-1.4.0/python/heatwave3/category.py +189 -0
  11. heatwave3-1.4.0/python/heatwave3/climatology.py +119 -0
  12. heatwave3-1.4.0/python/heatwave3/events.py +369 -0
  13. heatwave3-1.4.0/python/heatwave3/export.py +337 -0
  14. heatwave3-1.4.0/python/heatwave3/plot.py +341 -0
  15. heatwave3-1.4.0/python/heatwave3/threads.py +23 -0
  16. heatwave3-1.4.0/python/src/bindings.cpp +772 -0
  17. heatwave3-1.4.0/python/tests/test_api.py +330 -0
  18. heatwave3-1.4.0/python/tests/test_array.py +243 -0
  19. heatwave3-1.4.0/python/tests/test_core.py +48 -0
  20. heatwave3-1.4.0/python/tests/test_parity_r.py +210 -0
  21. heatwave3-1.4.0/python/tests/test_plot.py +162 -0
  22. heatwave3-1.4.0/python/tests/test_signatures.py +75 -0
  23. heatwave3-1.4.0/python/tests/test_version.py +20 -0
  24. heatwave3-1.4.0/src/blob_core.cpp +265 -0
  25. heatwave3-1.4.0/src/blob_core.h +43 -0
  26. heatwave3-1.4.0/src/climatology.cpp +425 -0
  27. heatwave3-1.4.0/src/climatology.h +61 -0
  28. heatwave3-1.4.0/src/event_detect.cpp +810 -0
  29. heatwave3-1.4.0/src/event_detect.h +153 -0
  30. heatwave3-1.4.0/src/heatwave3_types.h +112 -0
  31. heatwave3-1.4.0/src/hw3_api.cpp +888 -0
  32. heatwave3-1.4.0/src/hw3_api.h +202 -0
  33. heatwave3-1.4.0/src/hw3_h5chunk.cpp +257 -0
  34. heatwave3-1.4.0/src/hw3_h5chunk.h +45 -0
  35. heatwave3-1.4.0/src/hw3_log.h +43 -0
  36. heatwave3-1.4.0/src/hw3_omp.h +128 -0
  37. heatwave3-1.4.0/src/hw3_round.h +108 -0
  38. heatwave3-1.4.0/src/netcdf_io.cpp +1701 -0
  39. heatwave3-1.4.0/src/netcdf_io.h +245 -0
@@ -0,0 +1,108 @@
1
+ # Python build of heatwave3 (driven by scikit-build-core via pyproject.toml).
2
+ # Compiles the language-neutral C++ core in src/ plus the pybind11 bindings in
3
+ # python/src/. The R package's Rcpp files in src/ are not compiled here.
4
+
5
+ cmake_minimum_required(VERSION 3.18...3.30)
6
+ project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX)
7
+
8
+ set(CMAKE_CXX_STANDARD 17)
9
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
10
+
11
+ find_package(Python REQUIRED COMPONENTS Interpreter Development.Module)
12
+ find_package(pybind11 CONFIG REQUIRED)
13
+ find_package(Threads REQUIRED)
14
+
15
+ pybind11_add_module(_core
16
+ python/src/bindings.cpp
17
+ src/netcdf_io.cpp
18
+ src/climatology.cpp
19
+ src/event_detect.cpp
20
+ src/hw3_api.cpp
21
+ src/blob_core.cpp
22
+ src/hw3_h5chunk.cpp
23
+ )
24
+ target_include_directories(_core PRIVATE src)
25
+ target_compile_definitions(_core PRIVATE HW3_VERSION="${SKBUILD_PROJECT_VERSION_FULL}")
26
+ target_link_libraries(_core PRIVATE Threads::Threads)
27
+ # Keep the path to libnetcdf in the installed module's rpath, so the wheel
28
+ # repair tools (auditwheel, delocate) can find the library to bundle.
29
+ set_target_properties(_core PROPERTIES INSTALL_RPATH_USE_LINK_PATH TRUE)
30
+
31
+ # libnetcdf: netCDFConfig.cmake first, then nc-config, then pkg-config
32
+ # (mirrors the R package's configure script).
33
+ find_package(netCDF CONFIG QUIET)
34
+ if(netCDF_FOUND)
35
+ message(STATUS "netCDF ${netCDF_VERSION} via netCDFConfig.cmake")
36
+ target_link_libraries(_core PRIVATE netCDF::netcdf)
37
+ else()
38
+ find_program(NC_CONFIG nc-config)
39
+ if(NC_CONFIG)
40
+ message(STATUS "netCDF via ${NC_CONFIG}")
41
+ execute_process(COMMAND ${NC_CONFIG} --cflags OUTPUT_VARIABLE NC_CFLAGS
42
+ OUTPUT_STRIP_TRAILING_WHITESPACE)
43
+ execute_process(COMMAND ${NC_CONFIG} --libs OUTPUT_VARIABLE NC_LIBS
44
+ OUTPUT_STRIP_TRAILING_WHITESPACE)
45
+ separate_arguments(NC_CFLAGS UNIX_COMMAND "${NC_CFLAGS}")
46
+ separate_arguments(NC_LIBS UNIX_COMMAND "${NC_LIBS}")
47
+ target_compile_options(_core PRIVATE ${NC_CFLAGS})
48
+ target_link_libraries(_core PRIVATE ${NC_LIBS})
49
+ else()
50
+ find_package(PkgConfig REQUIRED)
51
+ pkg_check_modules(NETCDF REQUIRED IMPORTED_TARGET netcdf)
52
+ message(STATUS "netCDF ${NETCDF_VERSION} via pkg-config")
53
+ target_link_libraries(_core PRIVATE PkgConfig::NETCDF)
54
+ endif()
55
+ endif()
56
+
57
+ # Optional: compress gridded outputs on all threads (src/hw3_h5chunk.cpp),
58
+ # which needs HDF5 >= 1.10.3 and zlib. Use the HDF5 that libnetcdf uses (the
59
+ # wheels point HDF5_ROOT at the prefix built by python/tools/build_netcdf.sh)
60
+ # and check that its headers match the library; otherwise outputs are written
61
+ # by netCDF on one thread. Disable with -DHW3_HDF5=OFF.
62
+ option(HW3_HDF5 "Compress gridded outputs on all threads (HDF5 >= 1.10.3 + zlib)" ON)
63
+ set(HW3_HDF5_USED OFF)
64
+ if(HW3_HDF5)
65
+ find_package(ZLIB QUIET)
66
+ find_package(HDF5 QUIET COMPONENTS C)
67
+ if(HDF5_FOUND AND ZLIB_FOUND)
68
+ # Only libhdf5 itself (FindHDF5 also lists its dependencies, e.g. curl)
69
+ if(HDF5_C_LIBRARY_hdf5)
70
+ set(HW3_H5_LIBS ${HDF5_C_LIBRARY_hdf5})
71
+ else()
72
+ set(HW3_H5_LIBS ${HDF5_C_LIBRARIES})
73
+ endif()
74
+ if(NOT HW3_H5_LIBS)
75
+ set(HW3_H5_LIBS ${HDF5_LIBRARIES})
76
+ endif()
77
+ include(CheckCXXSourceRuns)
78
+ set(CMAKE_REQUIRED_INCLUDES ${HDF5_INCLUDE_DIRS} ${ZLIB_INCLUDE_DIRS})
79
+ set(CMAKE_REQUIRED_LIBRARIES ${HW3_H5_LIBS} ZLIB::ZLIB)
80
+ set(CMAKE_REQUIRED_DEFINITIONS ${HDF5_DEFINITIONS})
81
+ check_cxx_source_runs([=[
82
+ #include <hdf5.h>
83
+ #include <zlib.h>
84
+ int main() {
85
+ unsigned a, b, c;
86
+ auto wc = &H5Dwrite_chunk;
87
+ auto cz = &compress2;
88
+ if (!wc || !cz || H5get_libversion(&a, &b, &c) < 0) return 1;
89
+ return (a == H5_VERS_MAJOR && b == H5_VERS_MINOR && c == H5_VERS_RELEASE) ? 0 : 2;
90
+ }]=] HW3_HDF5_WORKS)
91
+ unset(CMAKE_REQUIRED_INCLUDES)
92
+ unset(CMAKE_REQUIRED_LIBRARIES)
93
+ unset(CMAKE_REQUIRED_DEFINITIONS)
94
+ if(HW3_HDF5_WORKS)
95
+ target_compile_definitions(_core PRIVATE HW3_HAVE_HDF5 ${HDF5_DEFINITIONS})
96
+ target_include_directories(_core PRIVATE ${HDF5_INCLUDE_DIRS})
97
+ target_link_libraries(_core PRIVATE ${HW3_H5_LIBS} ZLIB::ZLIB)
98
+ set(HW3_HDF5_USED ON)
99
+ endif()
100
+ endif()
101
+ endif()
102
+ if(HW3_HDF5_USED)
103
+ message(STATUS "HDF5 ${HDF5_VERSION} for parallel compression: ${HW3_H5_LIBS}")
104
+ else()
105
+ message(STATUS "HDF5 for parallel compression: not used (gridded outputs are compressed on one thread)")
106
+ endif()
107
+
108
+ install(TARGETS _core LIBRARY DESTINATION heatwave3)
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2023 heatwave3 authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: heatwave3
3
+ Version: 1.4.0
4
+ Summary: Detect marine heatwaves and cold-spells directly in gridded NetCDF data
5
+ Keywords: marine heatwave,cold-spell,climatology,NetCDF,oceanography
6
+ Author-Email: "Robert W. Schlegel" <robwschlegel@gmail.com>, "Albertus J. Smit" <albertus.smit@gmail.com>
7
+ Maintainer-Email: "Robert W. Schlegel" <robwschlegel@gmail.com>, "Albertus J. Smit" <albertus.smit@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE.md
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: C++
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
15
+ Classifier: Topic :: Scientific/Engineering :: Hydrology
16
+ Project-URL: Homepage, https://github.com/robwschlegel/heatwave3
17
+ Project-URL: Documentation, https://heatwave3.readthedocs.io
18
+ Project-URL: Source, https://github.com/robwschlegel/heatwave3
19
+ Project-URL: R package, https://robwschlegel.github.io/heatwave3/
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: numpy>=1.23
22
+ Requires-Dist: pandas>=1.5
23
+ Provides-Extra: parquet
24
+ Requires-Dist: pyarrow>=10; extra == "parquet"
25
+ Provides-Extra: plot
26
+ Requires-Dist: matplotlib>=3.6; extra == "plot"
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=7; extra == "test"
29
+ Requires-Dist: pyarrow>=10; extra == "test"
30
+ Requires-Dist: matplotlib>=3.6; extra == "test"
31
+ Description-Content-Type: text/markdown
32
+
33
+ # heatwave3
34
+
35
+ Detect marine heatwaves (MHWs) and marine cold-spells (MCSs) directly in
36
+ gridded NetCDF data, following the Hobday et al. (2016, 2018) definitions.
37
+
38
+ `heatwave3` is the Python interface to the same C++ core as the
39
+ [heatwave3 R package](https://robwschlegel.github.io/heatwave3/): climatology,
40
+ event detection, categorisation and blob detection run in multi-threaded C++
41
+ with direct NetCDF I/O. Function names, arguments and outputs match the R
42
+ package, so code translates line for line.
43
+
44
+ ```python
45
+ import heatwave3 as hw3
46
+
47
+ # Climatology + events (+ categories, + daily series) in one call
48
+ out = hw3.detect3("sst.nc", "results/run1", ("1982-01-01", "2011-12-31"),
49
+ category=True, daily="also", n_threads=4)
50
+
51
+ events = hw3.hw3_export(out["events"]) # pandas DataFrame
52
+ daily = hw3.hw3_export(out["daily"], time_range=("2023-01-01", "2023-12-31"))
53
+ hw3.hw3_export(out["events"], file_out="events.parquet") # or .csv
54
+ ```
55
+
56
+ Also available: `ts2clm3()`, `detect_event3()`, `category3()`,
57
+ `category_daily3()`, `block_average3()`, `exceedance3()`, `detect_blob3()`
58
+ (spatially connected events), `getHW3threads()` and `setHW3threads()`, and,
59
+ with `pip install heatwave3[plot]`, the matplotlib plots `event_line3()`,
60
+ `plot_metric3()`, `geom_flame3()` and `geom_lolli3()`. The one
61
+ naming difference from R: `detect_blob3()`'s `return` argument is `return_`,
62
+ because `return` is a Python keyword.
63
+
64
+ Documentation: <https://heatwave3.readthedocs.io>
65
+
66
+ Its version is pinned to the R package's: heatwave3 1.4.0 in Python has the
67
+ same functions and outputs as heatwave3 1.4.0 in R.
@@ -0,0 +1,84 @@
1
+ [build-system]
2
+ requires = ["scikit-build-core>=0.10", "pybind11>=2.13"]
3
+ build-backend = "scikit_build_core.build"
4
+
5
+ [project]
6
+ name = "heatwave3"
7
+ version = "1.4.0" # pinned to Version in DESCRIPTION (R): same features
8
+ description = "Detect marine heatwaves and cold-spells directly in gridded NetCDF data"
9
+ readme = "python/README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE.md"]
13
+ authors = [
14
+ { name = "Robert W. Schlegel", email = "robwschlegel@gmail.com" },
15
+ { name = "Albertus J. Smit", email = "albertus.smit@gmail.com" },
16
+ ]
17
+ maintainers = [
18
+ { name = "Robert W. Schlegel", email = "robwschlegel@gmail.com" },
19
+ { name = "Albertus J. Smit", email = "albertus.smit@gmail.com" },
20
+ ]
21
+ keywords = ["marine heatwave", "cold-spell", "climatology", "NetCDF", "oceanography"]
22
+ classifiers = [
23
+ "Development Status :: 5 - Production/Stable",
24
+ "Intended Audience :: Science/Research",
25
+ "Programming Language :: C++",
26
+ "Programming Language :: Python :: 3",
27
+ "Topic :: Scientific/Engineering :: Atmospheric Science",
28
+ "Topic :: Scientific/Engineering :: Hydrology",
29
+ ]
30
+ dependencies = ["numpy>=1.23", "pandas>=1.5"]
31
+
32
+ [project.optional-dependencies]
33
+ parquet = ["pyarrow>=10"]
34
+ plot = ["matplotlib>=3.6"]
35
+ test = ["pytest>=7", "pyarrow>=10", "matplotlib>=3.6"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/robwschlegel/heatwave3"
39
+ Documentation = "https://heatwave3.readthedocs.io"
40
+ Source = "https://github.com/robwschlegel/heatwave3"
41
+ "R package" = "https://robwschlegel.github.io/heatwave3/"
42
+
43
+ [tool.scikit-build]
44
+ minimum-version = "build-system.requires"
45
+ wheel.packages = ["python/heatwave3"]
46
+ # The repo root is also the R package. Publish an explicit allowlist so R files
47
+ # and stray local files (e.g. editor or agent settings) never reach PyPI.
48
+ sdist.exclude = ["*"]
49
+ sdist.include = [
50
+ "/pyproject.toml", "/CMakeLists.txt", "/LICENSE.md", "/python/README.md",
51
+ "/python/heatwave3/*.py", "/python/src/*.cpp", "/python/tests/*.py",
52
+ "/src/hw3_api.cpp", "/src/hw3_api.h", "/src/hw3_log.h", "/src/hw3_omp.h",
53
+ "/src/hw3_round.h", "/src/hw3_h5chunk.cpp", "/src/hw3_h5chunk.h",
54
+ "/src/heatwave3_types.h", "/src/netcdf_io.cpp", "/src/netcdf_io.h",
55
+ "/src/climatology.cpp", "/src/climatology.h", "/src/event_detect.cpp",
56
+ "/src/event_detect.h", "/src/blob_core.cpp", "/src/blob_core.h",
57
+ ]
58
+
59
+ [tool.cibuildwheel]
60
+ # CPython 3.10-3.14. CPython 3.15 and free-threaded builds can be added once
61
+ # numpy and pandas publish wheels for them; musllinux (Alpine) users install
62
+ # from the sdist for now.
63
+ build = "cp310-* cp311-* cp312-* cp313-* cp314-*"
64
+ skip = "cp31?t-* *-musllinux_*"
65
+ build-verbosity = 1
66
+ # Tests run against the installed wheel, from copies of these folders.
67
+ test-sources = ["python/tests", "inst/extdata", "DESCRIPTION"]
68
+ test-requires = ["pytest", "matplotlib"]
69
+ test-command = "pytest python/tests -p no:cacheprovider"
70
+
71
+ [tool.cibuildwheel.linux]
72
+ # Pinned HDF5 + netcdf-c built from source; auditwheel bundles them.
73
+ before-all = "dnf install -y zlib-devel && bash python/tools/build_netcdf.sh /opt/hw3-netcdf"
74
+ environment = { CMAKE_PREFIX_PATH = "/opt/hw3-netcdf", HDF5_ROOT = "/opt/hw3-netcdf" }
75
+
76
+ [tool.cibuildwheel.macos]
77
+ # One architecture per runner (arm64 and x86_64 runners); delocate bundles
78
+ # the libraries. MACOSX_DEPLOYMENT_TARGET is set inline for before-all.
79
+ archs = ["native"]
80
+ before-all = "MACOSX_DEPLOYMENT_TARGET=11.0 bash python/tools/build_netcdf.sh /tmp/hw3-netcdf"
81
+ environment = { CMAKE_PREFIX_PATH = "/tmp/hw3-netcdf", HDF5_ROOT = "/tmp/hw3-netcdf", MACOSX_DEPLOYMENT_TARGET = "11.0" }
82
+
83
+ [tool.pytest.ini_options]
84
+ testpaths = ["python/tests"]
@@ -0,0 +1,35 @@
1
+ # heatwave3
2
+
3
+ Detect marine heatwaves (MHWs) and marine cold-spells (MCSs) directly in
4
+ gridded NetCDF data, following the Hobday et al. (2016, 2018) definitions.
5
+
6
+ `heatwave3` is the Python interface to the same C++ core as the
7
+ [heatwave3 R package](https://robwschlegel.github.io/heatwave3/): climatology,
8
+ event detection, categorisation and blob detection run in multi-threaded C++
9
+ with direct NetCDF I/O. Function names, arguments and outputs match the R
10
+ package, so code translates line for line.
11
+
12
+ ```python
13
+ import heatwave3 as hw3
14
+
15
+ # Climatology + events (+ categories, + daily series) in one call
16
+ out = hw3.detect3("sst.nc", "results/run1", ("1982-01-01", "2011-12-31"),
17
+ category=True, daily="also", n_threads=4)
18
+
19
+ events = hw3.hw3_export(out["events"]) # pandas DataFrame
20
+ daily = hw3.hw3_export(out["daily"], time_range=("2023-01-01", "2023-12-31"))
21
+ hw3.hw3_export(out["events"], file_out="events.parquet") # or .csv
22
+ ```
23
+
24
+ Also available: `ts2clm3()`, `detect_event3()`, `category3()`,
25
+ `category_daily3()`, `block_average3()`, `exceedance3()`, `detect_blob3()`
26
+ (spatially connected events), `getHW3threads()` and `setHW3threads()`, and,
27
+ with `pip install heatwave3[plot]`, the matplotlib plots `event_line3()`,
28
+ `plot_metric3()`, `geom_flame3()` and `geom_lolli3()`. The one
29
+ naming difference from R: `detect_blob3()`'s `return` argument is `return_`,
30
+ because `return` is a Python keyword.
31
+
32
+ Documentation: <https://heatwave3.readthedocs.io>
33
+
34
+ Its version is pinned to the R package's: heatwave3 1.4.0 in Python has the
35
+ same functions and outputs as heatwave3 1.4.0 in R.
@@ -0,0 +1,38 @@
1
+ """Detect marine heatwaves and cold-spells directly in gridded NetCDF data.
2
+
3
+ Python interface to the heatwave3 C++ core, shared with the R package of the
4
+ same name. Function names, arguments and outputs match the R package.
5
+ """
6
+
7
+ from importlib.metadata import version as _version
8
+
9
+ from . import _core
10
+ from .blob import detect_blob3
11
+ from .category import block_average3, category3, category_daily3
12
+ from .climatology import ts2clm3
13
+ from .events import detect3, detect_event3, detect_event_array3, exceedance3
14
+ from .export import hw3_export
15
+ from .plot import event_line3, geom_flame3, geom_lolli3, plot_metric3
16
+ from .threads import getHW3threads, setHW3threads
17
+
18
+ __version__ = _version("heatwave3")
19
+
20
+ __all__ = [
21
+ "__version__",
22
+ "block_average3",
23
+ "category3",
24
+ "category_daily3",
25
+ "detect3",
26
+ "detect_blob3",
27
+ "detect_event3",
28
+ "detect_event_array3",
29
+ "event_line3",
30
+ "exceedance3",
31
+ "geom_flame3",
32
+ "geom_lolli3",
33
+ "getHW3threads",
34
+ "hw3_export",
35
+ "plot_metric3",
36
+ "setHW3threads",
37
+ "ts2clm3",
38
+ ]
@@ -0,0 +1,163 @@
1
+ """Long data frames from the C++ readers (ports of the R builders in export.R).
2
+
3
+ Gridded products use the pixel-major layout of the C++ core:
4
+ pixel = (ilon * nlat + ilat) * ndepth + idepth, then the third axis
5
+ (day-of-year or time) varies fastest within each pixel.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from ._utils import jd_to_datetime, nullable_int
14
+
15
+ CAT_LABELS = np.array([None, "I Moderate", "II Strong", "III Severe", "IV Extreme"], dtype=object)
16
+ SEASON_LABELS = np.array([None, "Summer", "Fall", "Winter", "Spring"], dtype=object)
17
+
18
+ EVENT_METRICS = [
19
+ "duration",
20
+ "intensity_mean", "intensity_max", "intensity_var", "intensity_cumulative",
21
+ "intensity_mean_relThresh", "intensity_max_relThresh",
22
+ "intensity_var_relThresh", "intensity_cumulative_relThresh",
23
+ "intensity_mean_abs", "intensity_max_abs", "intensity_var_abs",
24
+ "intensity_cumulative_abs",
25
+ "rate_onset", "rate_decline",
26
+ ]
27
+
28
+
29
+ def _pixel_axes(nlon, nlat, ndepth, pixels):
30
+ """(ilon, ilat, idepth) 0-based for 0-based pixel indices."""
31
+ pixels = np.asarray(pixels)
32
+ idepth = pixels % ndepth
33
+ lonlat = pixels // ndepth
34
+ return lonlat // nlat, lonlat % nlat, idepth
35
+
36
+
37
+ def clim_df(cd: dict, start: int = 0, stop: int | None = None) -> pd.DataFrame:
38
+ """Climatology rows for pixels [start, stop): lon, lat, doy, seas, thresh, (depth)."""
39
+ ndepth = max(1, cd["ndepth"])
40
+ npix = cd["nlon"] * cd["nlat"] * ndepth
41
+ stop = npix if stop is None else stop
42
+ ndoy = cd["ndoy"]
43
+ has_depth = ndepth > 1 and len(cd["depth"]) > 0
44
+ pixels = np.arange(start, stop)
45
+ ilon, ilat, idepth = _pixel_axes(cd["nlon"], cd["nlat"], ndepth, pixels)
46
+ sl = slice(start * ndoy, stop * ndoy)
47
+ df = pd.DataFrame({
48
+ "lon": np.repeat(cd["lon"][ilon], ndoy),
49
+ "lat": np.repeat(cd["lat"][ilat], ndoy),
50
+ "doy": np.tile(np.arange(1, ndoy + 1, dtype=np.int32), len(pixels)),
51
+ "seas": cd["seas"][sl],
52
+ "thresh": cd["thresh"][sl],
53
+ })
54
+ if has_depth:
55
+ df["depth"] = np.repeat(cd["depth"][idepth], ndoy)
56
+ return df
57
+
58
+
59
+ def daily_df(dd: dict, start: int = 0, stop: int | None = None) -> pd.DataFrame:
60
+ """Per-day rows (daily or protoevents) for pixels [start, stop)."""
61
+ ndepth = max(1, dd["ndepth"])
62
+ npix = dd["nlon"] * dd["nlat"] * ndepth
63
+ stop = npix if stop is None else stop
64
+ ntime = dd["ntime"]
65
+ has_depth = ndepth > 1 and len(dd["depth"]) > 0
66
+ pixels = np.arange(start, stop)
67
+ ilon, ilat, idepth = _pixel_axes(dd["nlon"], dd["nlat"], ndepth, pixels)
68
+ sl = slice(start * ntime, stop * ntime)
69
+ has = lambda k: len(dd[k]) > 0
70
+ df = pd.DataFrame({
71
+ "lon": np.repeat(dd["lon"][ilon], ntime),
72
+ "lat": np.repeat(dd["lat"][ilat], ntime),
73
+ "t": np.tile(jd_to_datetime(dd["time_jd"]), len(pixels)),
74
+ })
75
+ # temp / seas / thresh are absent from slim daily files (intensity + category)
76
+ for k in ("temp", "seas", "thresh"):
77
+ if has(k):
78
+ df[k] = dd[k][sl]
79
+ if has("threshCriterion"):
80
+ df["threshCriterion"] = dd["threshCriterion"][sl] != 0
81
+ if has("durationCriterion"):
82
+ df["durationCriterion"] = dd["durationCriterion"][sl] != 0
83
+ if has("intensity"):
84
+ df["intensity"] = dd["intensity"][sl]
85
+ if has("event"):
86
+ df["event"] = dd["event"][sl] != 0
87
+ if has("event_no"):
88
+ df["event_no"] = nullable_int(dd["event_no"][sl])
89
+ if has("category"):
90
+ df["category"] = nullable_int(dd["category"][sl])
91
+ if has_depth:
92
+ df["depth"] = np.repeat(dd["depth"][idepth], ntime)
93
+ return df
94
+
95
+
96
+ def event_df(ev: dict, idx=None) -> pd.DataFrame:
97
+ """Event rows (all, or the 0-based indices `idx`), as .event_df_from_indices()."""
98
+ idx = np.arange(ev["nevents"]) if idx is None else np.asarray(idx, dtype=np.int64)
99
+ ref = ev["ref_date_jd"]
100
+ df = pd.DataFrame({
101
+ "lon": ev["lon"][idx],
102
+ "lat": ev["lat"][idx],
103
+ "pixel_index": ev["pixel_index"][idx],
104
+ "event_no": ev["event_no"][idx],
105
+ "date_start": jd_to_datetime(ref + ev["date_start"][idx]),
106
+ "date_peak": jd_to_datetime(ref + ev["date_peak"][idx]),
107
+ "date_end": jd_to_datetime(ref + ev["date_end"][idx]),
108
+ })
109
+ for m in EVENT_METRICS:
110
+ df[m] = ev[m][idx]
111
+ if len(ev["depth"]) > 0:
112
+ df["depth"] = ev["depth"][idx]
113
+ if len(ev["category"]) > 0 and (ev["category"] > 0).any():
114
+ df["category"] = CAT_LABELS[np.clip(ev["category"][idx], 0, 4)]
115
+ for p in ("p_moderate", "p_strong", "p_severe", "p_extreme"):
116
+ df[p] = ev[p][idx]
117
+ df["season"] = SEASON_LABELS[np.clip(ev["season"][idx], 0, 4)]
118
+ return df
119
+
120
+
121
+ def subset_df(sub: dict) -> pd.DataFrame:
122
+ """Long frame from a hyperslab read: lon, lat, t|doy, vars..., (depth)."""
123
+ nlon, nlat, n3 = sub["nlon"], sub["nlat"], sub["n3"]
124
+ has_depth = bool(sub["has_depth"])
125
+ ndepth = sub["ndepth"] if has_depth else 1
126
+ vnames = list(sub["data"].keys())
127
+ third_is_t = sub["third_name"] == "t"
128
+ third = "t" if third_is_t else "doy"
129
+
130
+ if nlon == 0 or nlat == 0 or (has_depth and ndepth == 0) or n3 == 0:
131
+ cols = {"lon": np.array([], float), "lat": np.array([], float)}
132
+ if has_depth:
133
+ cols["depth"] = np.array([], float)
134
+ cols[third] = (np.array([], "datetime64[ns]") if third_is_t
135
+ else np.array([], np.int32))
136
+ for v in vnames:
137
+ cols[v] = np.array([], float)
138
+ return _finalize_subset(pd.DataFrame(cols), vnames)
139
+
140
+ npix = nlon * nlat * ndepth
141
+ df = pd.DataFrame({
142
+ "lon": np.repeat(sub["lon"], nlat * ndepth * n3),
143
+ "lat": np.tile(np.repeat(sub["lat"], ndepth * n3), nlon),
144
+ })
145
+ if third_is_t:
146
+ df["t"] = np.tile(jd_to_datetime(sub["third"]), npix)
147
+ else:
148
+ df["doy"] = np.tile(sub["third"].astype(np.int32), npix)
149
+ for v in vnames:
150
+ df[v] = sub["data"][v]
151
+ if has_depth:
152
+ df["depth"] = np.tile(np.repeat(sub["depth"], n3), nlon * nlat)
153
+ return _finalize_subset(df, vnames)
154
+
155
+
156
+ def _finalize_subset(df: pd.DataFrame, vnames: list[str]) -> pd.DataFrame:
157
+ for v in ("threshCriterion", "durationCriterion", "event"):
158
+ if v in vnames:
159
+ df[v] = df[v].to_numpy() != 0
160
+ for v in ("event_no", "category"):
161
+ if v in vnames:
162
+ df[v] = nullable_int(df[v].to_numpy())
163
+ return df
@@ -0,0 +1,158 @@
1
+ """Internal helpers: output paths, input resolution, dates and argument checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import warnings
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from . import _core
14
+
15
+ # Julian Day Number of 1970-01-01.
16
+ JD_UNIX_EPOCH = 2440588
17
+
18
+
19
+ # Output paths: a single `name` stem maps to the four product files.
20
+
21
+ def clim_path(name) -> str:
22
+ return f"{os.fspath(name)}_clim.nc"
23
+
24
+
25
+ def events_path(name) -> str:
26
+ return f"{os.fspath(name)}_events.nc"
27
+
28
+
29
+ def daily_path(name) -> str:
30
+ return f"{os.fspath(name)}_events_daily.nc"
31
+
32
+
33
+ def proto_path(name) -> str:
34
+ return f"{os.fspath(name)}_protoevents.nc"
35
+
36
+
37
+ def check_out_dir(path: str) -> None:
38
+ out_dir = os.path.dirname(os.path.abspath(path))
39
+ if not os.path.isdir(out_dir):
40
+ raise FileNotFoundError(f"Output directory does not exist: {os.path.dirname(path)}")
41
+
42
+
43
+ # Inputs
44
+
45
+ def is_multi(file_in) -> bool:
46
+ """True for a directory or a sequence of paths (as opposed to one file)."""
47
+ return not isinstance(file_in, (str, os.PathLike))
48
+
49
+
50
+ def list_nc(directory, what: str = "the specified directory") -> list[str]:
51
+ files = sorted(str(p) for p in Path(directory).iterdir()
52
+ if p.suffix in (".nc", ".nc4") and p.is_file())
53
+ if not files:
54
+ raise FileNotFoundError(f"No .nc or .nc4 files found in {what}.")
55
+ return files
56
+
57
+
58
+ def resolve_input(file_in, missing_msg: str = "Input file does not exist: ",
59
+ dir_what: str = "the specified directory") -> tuple[list[str], bool]:
60
+ """Resolve a file, directory or sequence of files to (files, multi_file)."""
61
+ if not is_multi(file_in):
62
+ path = os.fspath(file_in)
63
+ if os.path.isdir(path):
64
+ return list_nc(path, dir_what), True
65
+ if not os.path.exists(path):
66
+ raise FileNotFoundError(f"{missing_msg}{path}")
67
+ return [path], False
68
+ files = [os.fspath(f) for f in file_in]
69
+ if len(files) == 1:
70
+ return resolve_input(files[0], missing_msg, dir_what)
71
+ return files, True
72
+
73
+
74
+ # Dates
75
+
76
+ def to_datetime64(dates) -> np.ndarray:
77
+ """Convert date-like input(s) to datetime64[D]; raise ValueError if invalid."""
78
+ try:
79
+ with warnings.catch_warnings():
80
+ # Invalid input raises below; don't also leak pandas' parse warnings.
81
+ warnings.simplefilter("ignore")
82
+ d = pd.to_datetime(pd.Series(np.atleast_1d(np.asarray(dates, dtype=object))))
83
+ except (ValueError, TypeError) as e:
84
+ raise ValueError(str(e)) from None
85
+ if d.isna().any():
86
+ raise ValueError("NaT")
87
+ return d.to_numpy().astype("datetime64[D]")
88
+
89
+
90
+ def date_strings(dates) -> list[str]:
91
+ return [str(d) for d in to_datetime64(dates)]
92
+
93
+
94
+ def to_jd(dates) -> list[int]:
95
+ days = to_datetime64(dates).astype(np.int64)
96
+ return [int(x) + JD_UNIX_EPOCH for x in days]
97
+
98
+
99
+ def jd_to_datetime(jd) -> np.ndarray:
100
+ """Julian Day Numbers to datetime64[ns] (pandas' native resolution)."""
101
+ days = np.asarray(jd, dtype=np.int64) - JD_UNIX_EPOCH
102
+ return days.astype("datetime64[D]").astype("datetime64[ns]")
103
+
104
+
105
+ # Arguments
106
+
107
+ def pair(x, what: str) -> list[float] | None:
108
+ """Validate an optional (min, max) pair."""
109
+ if x is None:
110
+ return None
111
+ vals = [float(v) for v in np.atleast_1d(x)]
112
+ if len(vals) != 2:
113
+ raise ValueError(f"{what} must be a numeric vector of length 2.")
114
+ return vals
115
+
116
+
117
+ def int_or_zero(x) -> int:
118
+ """R's `if (is.numeric(x)) as.integer(x) else 0L`: False/None mean 0."""
119
+ if x is None or isinstance(x, (bool, np.bool_)):
120
+ return 0
121
+ return int(x)
122
+
123
+
124
+ def nthreads(n_threads) -> int:
125
+ """n_threads argument -> C++: None means the session default (0 tells the
126
+ core to use getHW3threads())."""
127
+ return 0 if n_threads is None else int(n_threads)
128
+
129
+
130
+ def compression_level(compression) -> int:
131
+ """compression argument -> C++: one whole number from 0 to 9."""
132
+ if (isinstance(compression, bool) or not isinstance(compression, (int, float))
133
+ or compression != int(compression) or not 0 <= compression <= 9):
134
+ raise ValueError("compression must be a whole number from 0 to 9.")
135
+ return int(compression)
136
+
137
+
138
+ def choose(value: str, choices: tuple[str, ...], what: str) -> str:
139
+ if value not in choices:
140
+ raise ValueError(f"'{what}' must be one of: {', '.join(choices)}.")
141
+ return value
142
+
143
+
144
+ def nullable_int(values, na_values=(0,)) -> pd.arrays.IntegerArray:
145
+ """Integer column with the given sentinel values as missing (R NA_integer_)."""
146
+ v = np.asarray(values, dtype=np.int64)
147
+ mask = np.isin(v, na_values)
148
+ return pd.arrays.IntegerArray(v.astype(np.int32), mask)
149
+
150
+
151
+ @contextmanager
152
+ def quiet_core(quiet: bool):
153
+ """Silence the C++ core's messages (not warnings) on this thread while active."""
154
+ old = _core.hw3_set_quiet(bool(quiet))
155
+ try:
156
+ yield
157
+ finally:
158
+ _core.hw3_set_quiet(old)