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.
- heatwave3-1.4.0/CMakeLists.txt +108 -0
- heatwave3-1.4.0/LICENSE.md +21 -0
- heatwave3-1.4.0/PKG-INFO +67 -0
- heatwave3-1.4.0/pyproject.toml +84 -0
- heatwave3-1.4.0/python/README.md +35 -0
- heatwave3-1.4.0/python/heatwave3/__init__.py +38 -0
- heatwave3-1.4.0/python/heatwave3/_frames.py +163 -0
- heatwave3-1.4.0/python/heatwave3/_utils.py +158 -0
- heatwave3-1.4.0/python/heatwave3/blob.py +251 -0
- heatwave3-1.4.0/python/heatwave3/category.py +189 -0
- heatwave3-1.4.0/python/heatwave3/climatology.py +119 -0
- heatwave3-1.4.0/python/heatwave3/events.py +369 -0
- heatwave3-1.4.0/python/heatwave3/export.py +337 -0
- heatwave3-1.4.0/python/heatwave3/plot.py +341 -0
- heatwave3-1.4.0/python/heatwave3/threads.py +23 -0
- heatwave3-1.4.0/python/src/bindings.cpp +772 -0
- heatwave3-1.4.0/python/tests/test_api.py +330 -0
- heatwave3-1.4.0/python/tests/test_array.py +243 -0
- heatwave3-1.4.0/python/tests/test_core.py +48 -0
- heatwave3-1.4.0/python/tests/test_parity_r.py +210 -0
- heatwave3-1.4.0/python/tests/test_plot.py +162 -0
- heatwave3-1.4.0/python/tests/test_signatures.py +75 -0
- heatwave3-1.4.0/python/tests/test_version.py +20 -0
- heatwave3-1.4.0/src/blob_core.cpp +265 -0
- heatwave3-1.4.0/src/blob_core.h +43 -0
- heatwave3-1.4.0/src/climatology.cpp +425 -0
- heatwave3-1.4.0/src/climatology.h +61 -0
- heatwave3-1.4.0/src/event_detect.cpp +810 -0
- heatwave3-1.4.0/src/event_detect.h +153 -0
- heatwave3-1.4.0/src/heatwave3_types.h +112 -0
- heatwave3-1.4.0/src/hw3_api.cpp +888 -0
- heatwave3-1.4.0/src/hw3_api.h +202 -0
- heatwave3-1.4.0/src/hw3_h5chunk.cpp +257 -0
- heatwave3-1.4.0/src/hw3_h5chunk.h +45 -0
- heatwave3-1.4.0/src/hw3_log.h +43 -0
- heatwave3-1.4.0/src/hw3_omp.h +128 -0
- heatwave3-1.4.0/src/hw3_round.h +108 -0
- heatwave3-1.4.0/src/netcdf_io.cpp +1701 -0
- 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.
|
heatwave3-1.4.0/PKG-INFO
ADDED
|
@@ -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)
|