From 981583ea08ffd13bb8df7df634f99210eb652aba Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Thu, 19 Mar 2026 14:27:37 +0000 Subject: [PATCH 01/17] Add C++ bindings for DTW - Update build config. - Add `dtw_cost_matrix_no_window_1d` (limited use cases for now). Co-authored-by: Zhihao Dai --- darts/dataprocessing/dtw/dtw.py | 4 ++++ pyproject.toml | 8 +++++++- setup.py | 26 ++++++++++++++++++++++++++ src/bind.cpp | 25 +++++++++++++++++++++++++ src/dtw.cpp | 27 +++++++++++++++++++++++++++ src/dtw.h | 11 +++++++++++ 6 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 setup.py create mode 100644 src/bind.cpp create mode 100644 src/dtw.cpp create mode 100644 src/dtw.h diff --git a/darts/dataprocessing/dtw/dtw.py b/darts/dataprocessing/dtw/dtw.py index 1681f1980a..b3698078fb 100644 --- a/darts/dataprocessing/dtw/dtw.py +++ b/darts/dataprocessing/dtw/dtw.py @@ -10,6 +10,7 @@ import pandas as pd from darts import TimeSeries +from darts._internal import dtw_cost_matrix_no_window_1d from darts.dataprocessing.dtw.cost_matrix import CostMatrix from darts.dataprocessing.dtw.window import CRWindow, NoWindow, Window from darts.logging import get_logger, raise_if, raise_if_not @@ -362,6 +363,9 @@ def dtw( logger, ) cost_matrix = _fast_dtw(values_x, values_y, distance, multi_grid_radius) + elif isinstance(window, NoWindow) and both_univariate: + cost_matrix = np.full((len(values_x) + 1, len(values_y) + 1), np.inf) + dtw_cost_matrix_no_window_1d(values_x, values_y, cost_matrix) else: cost_matrix = _dtw_cost_matrix(values_x, values_y, distance, window) diff --git a/pyproject.toml b/pyproject.toml index fd521eeaa1..36dac0b80f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,8 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = [ + "setuptools>=61.0", + "pybind11>=2.10.0", +] build-backend = "setuptools.build_meta" [project] @@ -105,6 +108,9 @@ exclude = ["examples*", "datasets*", "docs*", "darts.tests*"] [tool.setuptools.package-data] darts = ["py.typed"] +[tool.cibuildwheel] +archs = "all" + [tool.pytest.ini_options] addopts = [ "--strict-markers", diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..609f0cf227 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +# Available at setup time due to pyproject.toml +import glob + +from pybind11.setup_helpers import Pybind11Extension +from setuptools import setup + +# The main interface is through Pybind11Extension. +# * You can add cxx_std=11/14/17, and then build_ext can be removed. +# * You can set include_pybind11=false to add the include directory yourself, +# say from a submodule. +# +# Note: +# Sort input source files if you glob sources to ensure bit-for-bit +# reproducible builds (https://github.com/pybind/python_example/pull/53) + +ext_modules = [ + Pybind11Extension( + "darts._internal", + sources=glob.glob("src/*.cpp"), + cxx_std=17, + ), +] + +setup( + ext_modules=ext_modules, +) diff --git a/src/bind.cpp b/src/bind.cpp new file mode 100644 index 0000000000..bd3b43a251 --- /dev/null +++ b/src/bind.cpp @@ -0,0 +1,25 @@ +#include +#include +#include "dtw.h" + +namespace py = pybind11; + + +PYBIND11_MODULE(_internal, m) { + m.doc() = R"pbdoc( + Internal C++ implementation of DARTS algorithms. + )pbdoc"; + + m.def("dtw_cost_matrix_no_window_1d", &dtw::dtw_cost_matrix_no_window_1d, R"pbdoc( + Compute the cost matrix for Dynamic Time Warping (DTW) without any window constraints. + + Parameters + ---------- + x : numpy.ndarray + First input sequence (1D array). + y : numpy.ndarray + Second input sequence (1D array). + cost_matrix : numpy.ndarray + Pre-allocated 2D array to store the computed cost matrix. Should have shape (len(x)+1, len(y)+1). + )pbdoc"); +} diff --git a/src/dtw.cpp b/src/dtw.cpp new file mode 100644 index 0000000000..3b2574e55c --- /dev/null +++ b/src/dtw.cpp @@ -0,0 +1,27 @@ +#include +#include +#include +#include + +namespace dtw { + +namespace py = pybind11; + +void dtw_cost_matrix_no_window_1d(py::array_t x, py::array_t y, py::array_t cost_matrix) { + // use direct access to the underlying data of the numpy arrays + auto x_ptr = x.unchecked<1>(); // 2D array + auto y_ptr = y.unchecked<1>(); // 2D array + auto cost_ptr = cost_matrix.mutable_unchecked<2>(); // 2D array + // initialize the first values of the cost matrix + cost_ptr(0, 0) = 0.0; + // iterate over the cost matrix and fill it in + for (py::ssize_t i = 1; i <= x_ptr.shape(0); ++i) { + for (py::ssize_t j = 1; j <= y_ptr.shape(0); ++j) { + double cost = std::abs(x_ptr(i-1) - y_ptr(j-1)); + double min_prev_cost = std::min({cost_ptr(i-1, j), cost_ptr(i, j-1), cost_ptr(i-1, j-1)}); + cost_ptr(i, j) = cost + min_prev_cost; + } + } +} + +} // namespace dtw diff --git a/src/dtw.h b/src/dtw.h new file mode 100644 index 0000000000..2f445c210e --- /dev/null +++ b/src/dtw.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include + +namespace dtw { +void dtw_cost_matrix_no_window_1d( + py::array_t x, + py::array_t y, + py::array_t cost_matrix +); +} // namespace dtw From f70da91901525d415834957fb48b92d2c007143c Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Thu, 19 Mar 2026 14:29:15 +0000 Subject: [PATCH 02/17] Update README Co-authored-by: Zhihao Dai --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0840f1ac28..8d1cc5aa94 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ docs_env .venv .env uv.lock + +# C++ build artifacts +*.so From c42ebd9b088bf6a386d93cf6f757805297e291b6 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Thu, 19 Mar 2026 14:40:45 +0000 Subject: [PATCH 03/17] Fix compile bug Co-authored-by: Zhihao Dai --- darts/dataprocessing/dtw/dtw.py | 10 +++++----- src/dtw.h | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/darts/dataprocessing/dtw/dtw.py b/darts/dataprocessing/dtw/dtw.py index b3698078fb..0c5d90fc8b 100644 --- a/darts/dataprocessing/dtw/dtw.py +++ b/darts/dataprocessing/dtw/dtw.py @@ -38,9 +38,9 @@ def _dtw_cost_matrix( return dtw -def _dtw_path(dtw: CostMatrix) -> np.ndarray: - i = dtw.n - j = dtw.m +def _dtw_path(dtw: CostMatrix, n: int, m: int) -> np.ndarray: + i = n + j = m path = [] @@ -133,7 +133,7 @@ def _fast_dtw( half_y = _down_sample(y) low_res_cost = _fast_dtw(half_x, half_y, dist, radius, depth + 1) - low_res_path = _dtw_path(low_res_cost) + low_res_path = _dtw_path(low_res_cost, n, m) window = _expand_window(low_res_path, len(x), len(y), radius) cost = _dtw_cost_matrix(x, y, dist, window) @@ -189,7 +189,7 @@ def path(self) -> np.ndarray: if hasattr(self, "_path"): return self._path - self._path = _dtw_path(self.cost) + self._path = _dtw_path(self.cost, self.n, self.m) return self._path def distance(self) -> float: diff --git a/src/dtw.h b/src/dtw.h index 2f445c210e..2765ada922 100644 --- a/src/dtw.h +++ b/src/dtw.h @@ -2,6 +2,8 @@ #include #include +namespace py = pybind11; + namespace dtw { void dtw_cost_matrix_no_window_1d( py::array_t x, From 9f2b304330e9f6ec267f016701878a42baa3bf5a Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Fri, 20 Mar 2026 09:38:23 +0000 Subject: [PATCH 04/17] Add non-gil tag Co-authored-by: Zhihao Dai --- src/bind.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bind.cpp b/src/bind.cpp index bd3b43a251..b228b618b5 100644 --- a/src/bind.cpp +++ b/src/bind.cpp @@ -5,7 +5,7 @@ namespace py = pybind11; -PYBIND11_MODULE(_internal, m) { +PYBIND11_MODULE(_internal, m, py::mod_gil_not_used()) { m.doc() = R"pbdoc( Internal C++ implementation of DARTS algorithms. )pbdoc"; From 68b1c4ed36c85512f6bb6fb7fbc9f2e0fc40d6bf Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Fri, 20 Mar 2026 21:35:48 +0000 Subject: [PATCH 05/17] Add include path Co-authored-by: Zhihao Dai --- MANIFEST.in | 1 + {src => include}/dtw.h | 0 setup.py | 1 + 3 files changed, 2 insertions(+) create mode 100644 MANIFEST.in rename {src => include}/dtw.h (100%) diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000..eaf01e0870 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include include/*.h diff --git a/src/dtw.h b/include/dtw.h similarity index 100% rename from src/dtw.h rename to include/dtw.h diff --git a/setup.py b/setup.py index 609f0cf227..f9c4ff6294 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ Pybind11Extension( "darts._internal", sources=glob.glob("src/*.cpp"), + include_dirs=["include"], cxx_std=17, ), ] From 44022d16f67bda81109e9628cb4a0098a967d1ce Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Sat, 21 Mar 2026 15:18:38 +0000 Subject: [PATCH 06/17] Optimize the speed Co-authored-by: Zhihao Dai --- src/bind.cpp | 1 - src/dtw.cpp | 13 ++++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/bind.cpp b/src/bind.cpp index b228b618b5..faffa802b7 100644 --- a/src/bind.cpp +++ b/src/bind.cpp @@ -1,5 +1,4 @@ #include -#include #include "dtw.h" namespace py = pybind11; diff --git a/src/dtw.cpp b/src/dtw.cpp index 3b2574e55c..3b98369904 100644 --- a/src/dtw.cpp +++ b/src/dtw.cpp @@ -1,8 +1,9 @@ #include #include -#include #include +#define ABS(x) ((x) < 0 ? -(x) : (x)) + namespace dtw { namespace py = pybind11; @@ -14,11 +15,13 @@ void dtw_cost_matrix_no_window_1d(py::array_t x, py::array_t y, auto cost_ptr = cost_matrix.mutable_unchecked<2>(); // 2D array // initialize the first values of the cost matrix cost_ptr(0, 0) = 0.0; + double cost = 0.0, min_prev_cost = 0.0; + ssize_t x_size = x_ptr.shape(0), y_size = y_ptr.shape(0); // iterate over the cost matrix and fill it in - for (py::ssize_t i = 1; i <= x_ptr.shape(0); ++i) { - for (py::ssize_t j = 1; j <= y_ptr.shape(0); ++j) { - double cost = std::abs(x_ptr(i-1) - y_ptr(j-1)); - double min_prev_cost = std::min({cost_ptr(i-1, j), cost_ptr(i, j-1), cost_ptr(i-1, j-1)}); + for (py::ssize_t i = 1; i <= x_size; ++i) { + for (py::ssize_t j = 1; j <= y_size; ++j) { + cost = ABS(x_ptr(i-1) - y_ptr(j-1)); + min_prev_cost = std::min({cost_ptr(i-1, j), cost_ptr(i, j-1), cost_ptr(i-1, j-1)}); cost_ptr(i, j) = cost + min_prev_cost; } } From eb3974702b285e3994f20c476d72a1f5b35dd1f8 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Sat, 21 Mar 2026 15:21:41 +0000 Subject: [PATCH 07/17] Update uv config Co-authored-by: Zhihao Dai --- pyproject.toml | 1 + src/dtw.cpp | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 36dac0b80f..429ea35bd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ required-environments = [ "sys_platform == 'linux'", "sys_platform == 'win32'", ] +cache-keys = [ {file = "src/*.cpp"}, {file = "include/*.h"} ] [tool.uv.pip] # Use only binary dependency wheels to prevent arbitrary code execution diff --git a/src/dtw.cpp b/src/dtw.cpp index 3b98369904..f11b94725a 100644 --- a/src/dtw.cpp +++ b/src/dtw.cpp @@ -10,8 +10,8 @@ namespace py = pybind11; void dtw_cost_matrix_no_window_1d(py::array_t x, py::array_t y, py::array_t cost_matrix) { // use direct access to the underlying data of the numpy arrays - auto x_ptr = x.unchecked<1>(); // 2D array - auto y_ptr = y.unchecked<1>(); // 2D array + auto x_ptr = x.unchecked<1>(); // 1D array + auto y_ptr = y.unchecked<1>(); // 1D array auto cost_ptr = cost_matrix.mutable_unchecked<2>(); // 2D array // initialize the first values of the cost matrix cost_ptr(0, 0) = 0.0; From 79b3a5781ca1f27cd0d464ab96ed0894f3524e14 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Sat, 28 Mar 2026 19:42:31 +0000 Subject: [PATCH 08/17] Migrate to nanobind Co-authored-by: Zhihao Dai --- CMakeLists.txt | 62 +++++++++++++++++++++++++++++++++ darts/dataprocessing/dtw/dtw.py | 2 +- include/dtw.h | 14 ++++---- pyproject.toml | 31 ++++++++++++++--- setup.py | 27 -------------- src/bind.cpp | 16 ++++----- src/dtw.cpp | 39 ++++++++++----------- 7 files changed, 122 insertions(+), 69 deletions(-) create mode 100644 CMakeLists.txt delete mode 100644 setup.py diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000000..23ac6d7ff2 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,62 @@ +cmake_minimum_required(VERSION 3.15...3.26) + +project(darts LANGUAGES CXX) + +if (NOT SKBUILD) + message(WARNING "\ + This CMake file is meant to be executed using 'scikit-build'. Running + it directly will almost certainly not produce the desired result. If + you are a user trying to install this package, please use the command + below, which will install all necessary build dependencies, compile + the package in an isolated environment, and then install it. + ===================================================================== + $ pip install . + ===================================================================== + If you are a software developer, and this is your own package, then + it is usually much more efficient to install the build dependencies + in your environment once and use the following command that avoids + a costly creation of a new virtual environment at every compilation: + ===================================================================== + $ pip install nanobind scikit-build-core[pyproject] + $ pip install --no-build-isolation -ve . + ===================================================================== + You may optionally add -Ceditable.rebuild=true to auto-rebuild when + the package is imported. Otherwise, you need to re-run the above + after editing C++ files.") +endif() + +# Try to import all Python components potentially needed by nanobind +find_package(Python 3.10 + REQUIRED COMPONENTS Interpreter Development.Module + OPTIONAL_COMPONENTS Development.SABIModule) + +# Import nanobind through CMake's find_package mechanism +find_package(nanobind CONFIG REQUIRED) + +include_directories(include) + +# We are now ready to compile the actual extension module +nanobind_add_module( + # Name of the extension + _ext + + # Target the stable ABI for Python 3.12+, which reduces + # the number of binary wheels that must be built. This + # does nothing on older Python versions + STABLE_ABI + + # Build libnanobind statically and merge it into the + # extension (which itself remains a shared library) + # + # If your project builds multiple extensions, you can + # replace this flag by NB_SHARED to conserve space by + # reusing a shared libnanobind across libraries + NB_STATIC + + # Source code goes here + src/bind.cpp + src/dtw.cpp +) + +# Install directive for scikit-build-core +install(TARGETS _ext LIBRARY DESTINATION darts) diff --git a/darts/dataprocessing/dtw/dtw.py b/darts/dataprocessing/dtw/dtw.py index 0c5d90fc8b..f901de0613 100644 --- a/darts/dataprocessing/dtw/dtw.py +++ b/darts/dataprocessing/dtw/dtw.py @@ -10,7 +10,7 @@ import pandas as pd from darts import TimeSeries -from darts._internal import dtw_cost_matrix_no_window_1d +from darts._ext import dtw_cost_matrix_no_window_1d from darts.dataprocessing.dtw.cost_matrix import CostMatrix from darts.dataprocessing.dtw.window import CRWindow, NoWindow, Window from darts.logging import get_logger, raise_if, raise_if_not diff --git a/include/dtw.h b/include/dtw.h index 2765ada922..00174f7af5 100644 --- a/include/dtw.h +++ b/include/dtw.h @@ -1,13 +1,11 @@ #pragma once -#include -#include +#include +#include -namespace py = pybind11; +namespace nb = nanobind; namespace dtw { -void dtw_cost_matrix_no_window_1d( - py::array_t x, - py::array_t y, - py::array_t cost_matrix -); + void dtw_cost_matrix_no_window_1d(nb::ndarray> x, + nb::ndarray> y, + nb::ndarray> cost_matrix); } // namespace dtw diff --git a/pyproject.toml b/pyproject.toml index 429ea35bd1..b98aee3d3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,6 @@ [build-system] -requires = [ - "setuptools>=61.0", - "pybind11>=2.10.0", -] -build-backend = "setuptools.build_meta" +requires = ["scikit-build-core >=0.10", "nanobind >=1.3.2"] +build-backend = "scikit_build_core.build" [project] name = "darts" @@ -106,12 +103,36 @@ zip-safe = false [tool.setuptools.packages.find] exclude = ["examples*", "datasets*", "docs*", "darts.tests*"] +[tool.scikit-build] +# Protect the configuration against future changes in scikit-build-core +minimum-version = "build-system.requires" + +# Setuptools-style build caching in a local directory +build-dir = "build/{wheel_tag}" + +# Build stable ABI wheels for CPython 3.12+ +wheel.py-api = "cp312" + [tool.setuptools.package-data] darts = ["py.typed"] [tool.cibuildwheel] archs = "all" +# Necessary to see build output from the actual compilation +build-verbosity = 1 + +# # Run pytest to ensure that the package was correctly built +# test-command = "pytest {project}/tests" +# test-requires = "pytest" + +# # Don't test Python 3.8 wheels on macOS/arm64 +# test-skip="cp38-macosx_*:arm64" + +# Needed for full C++17 support +[tool.cibuildwheel.macos.environment] +MACOSX_DEPLOYMENT_TARGET = "10.14" + [tool.pytest.ini_options] addopts = [ "--strict-markers", diff --git a/setup.py b/setup.py deleted file mode 100644 index f9c4ff6294..0000000000 --- a/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -# Available at setup time due to pyproject.toml -import glob - -from pybind11.setup_helpers import Pybind11Extension -from setuptools import setup - -# The main interface is through Pybind11Extension. -# * You can add cxx_std=11/14/17, and then build_ext can be removed. -# * You can set include_pybind11=false to add the include directory yourself, -# say from a submodule. -# -# Note: -# Sort input source files if you glob sources to ensure bit-for-bit -# reproducible builds (https://github.com/pybind/python_example/pull/53) - -ext_modules = [ - Pybind11Extension( - "darts._internal", - sources=glob.glob("src/*.cpp"), - include_dirs=["include"], - cxx_std=17, - ), -] - -setup( - ext_modules=ext_modules, -) diff --git a/src/bind.cpp b/src/bind.cpp index faffa802b7..0564a96ec8 100644 --- a/src/bind.cpp +++ b/src/bind.cpp @@ -1,15 +1,15 @@ -#include #include "dtw.h" +#include -namespace py = pybind11; +namespace nb = nanobind; +using namespace nb::literals; -PYBIND11_MODULE(_internal, m, py::mod_gil_not_used()) { - m.doc() = R"pbdoc( - Internal C++ implementation of DARTS algorithms. - )pbdoc"; - - m.def("dtw_cost_matrix_no_window_1d", &dtw::dtw_cost_matrix_no_window_1d, R"pbdoc( +NB_MODULE(_ext, m) { + m.doc() = "Internal C++ implementation of Darts algorithms."; + m.def("add", [](int a, int b) { return a + b; }, "a"_a, "b"_a); + m.def("dtw_cost_matrix_no_window_1d", &dtw::dtw_cost_matrix_no_window_1d, "x"_a, "y"_a, "cost_matrix"_a, + R"pbdoc( Compute the cost matrix for Dynamic Time Warping (DTW) without any window constraints. Parameters diff --git a/src/dtw.cpp b/src/dtw.cpp index f11b94725a..849eb5d220 100644 --- a/src/dtw.cpp +++ b/src/dtw.cpp @@ -1,30 +1,29 @@ -#include -#include -#include +#include +#include +#include #define ABS(x) ((x) < 0 ? -(x) : (x)) namespace dtw { -namespace py = pybind11; + namespace nb = nanobind; -void dtw_cost_matrix_no_window_1d(py::array_t x, py::array_t y, py::array_t cost_matrix) { - // use direct access to the underlying data of the numpy arrays - auto x_ptr = x.unchecked<1>(); // 1D array - auto y_ptr = y.unchecked<1>(); // 1D array - auto cost_ptr = cost_matrix.mutable_unchecked<2>(); // 2D array - // initialize the first values of the cost matrix - cost_ptr(0, 0) = 0.0; - double cost = 0.0, min_prev_cost = 0.0; - ssize_t x_size = x_ptr.shape(0), y_size = y_ptr.shape(0); - // iterate over the cost matrix and fill it in - for (py::ssize_t i = 1; i <= x_size; ++i) { - for (py::ssize_t j = 1; j <= y_size; ++j) { - cost = ABS(x_ptr(i-1) - y_ptr(j-1)); - min_prev_cost = std::min({cost_ptr(i-1, j), cost_ptr(i, j-1), cost_ptr(i-1, j-1)}); - cost_ptr(i, j) = cost + min_prev_cost; + void dtw_cost_matrix_no_window_1d(nb::ndarray> x, + nb::ndarray> y, + nb::ndarray> cost_matrix) { + auto x_view = x.view(); + auto y_view = y.view(); + auto cost_view = cost_matrix.view(); + // initialize the first values of the cost matrix + cost_view(0, 0) = 0.0; + double cost = 0.0, min_prev_cost = 0.0; + for (size_t i = 1; i <= x_view.shape(0); ++i) { + for (size_t j = 1; j <= y_view.shape(0); ++j) { + cost = ABS(x_view(i-1) - y_view(j-1)); + min_prev_cost = std::min({cost_view(i-1, j), cost_view(i, j-1), cost_view(i-1, j-1)}); + cost_view(i, j) = cost + min_prev_cost; + } } } -} } // namespace dtw From 4550bf34b6bd1900ac5787b742d5c96058db5682 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Sat, 28 Mar 2026 19:44:57 +0000 Subject: [PATCH 09/17] View with run time check Co-authored-by: Zhihao Dai --- src/dtw.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dtw.cpp b/src/dtw.cpp index 849eb5d220..7a31f86d33 100644 --- a/src/dtw.cpp +++ b/src/dtw.cpp @@ -11,9 +11,9 @@ namespace dtw { void dtw_cost_matrix_no_window_1d(nb::ndarray> x, nb::ndarray> y, nb::ndarray> cost_matrix) { - auto x_view = x.view(); - auto y_view = y.view(); - auto cost_view = cost_matrix.view(); + auto x_view = x.view>(); + auto y_view = y.view>(); + auto cost_view = cost_matrix.view>(); // initialize the first values of the cost matrix cost_view(0, 0) = 0.0; double cost = 0.0, min_prev_cost = 0.0; From eb9224ab643eb9e7eef4c82939780c00b3eb1799 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Sat, 28 Mar 2026 23:59:04 +0000 Subject: [PATCH 10/17] Return np ndarray directly Co-authored-by: Zhihao Dai --- darts/dataprocessing/dtw/dtw.py | 3 +-- include/dtw.h | 5 ++--- src/bind.cpp | 10 ++++++--- src/dtw.cpp | 36 +++++++++++++++++++++++---------- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/darts/dataprocessing/dtw/dtw.py b/darts/dataprocessing/dtw/dtw.py index f901de0613..babc4b9f0a 100644 --- a/darts/dataprocessing/dtw/dtw.py +++ b/darts/dataprocessing/dtw/dtw.py @@ -364,8 +364,7 @@ def dtw( ) cost_matrix = _fast_dtw(values_x, values_y, distance, multi_grid_radius) elif isinstance(window, NoWindow) and both_univariate: - cost_matrix = np.full((len(values_x) + 1, len(values_y) + 1), np.inf) - dtw_cost_matrix_no_window_1d(values_x, values_y, cost_matrix) + cost_matrix = dtw_cost_matrix_no_window_1d(values_x, values_y) else: cost_matrix = _dtw_cost_matrix(values_x, values_y, distance, window) diff --git a/include/dtw.h b/include/dtw.h index 00174f7af5..b6483a50ed 100644 --- a/include/dtw.h +++ b/include/dtw.h @@ -5,7 +5,6 @@ namespace nb = nanobind; namespace dtw { - void dtw_cost_matrix_no_window_1d(nb::ndarray> x, - nb::ndarray> y, - nb::ndarray> cost_matrix); + nb::ndarray> dtw_cost_matrix_no_window_1d(nb::ndarray x, + nb::ndarray y); } // namespace dtw diff --git a/src/bind.cpp b/src/bind.cpp index 0564a96ec8..e097088e31 100644 --- a/src/bind.cpp +++ b/src/bind.cpp @@ -8,7 +8,7 @@ using namespace nb::literals; NB_MODULE(_ext, m) { m.doc() = "Internal C++ implementation of Darts algorithms."; m.def("add", [](int a, int b) { return a + b; }, "a"_a, "b"_a); - m.def("dtw_cost_matrix_no_window_1d", &dtw::dtw_cost_matrix_no_window_1d, "x"_a, "y"_a, "cost_matrix"_a, + m.def("dtw_cost_matrix_no_window_1d", &dtw::dtw_cost_matrix_no_window_1d, "x"_a, "y"_a, R"pbdoc( Compute the cost matrix for Dynamic Time Warping (DTW) without any window constraints. @@ -18,7 +18,11 @@ NB_MODULE(_ext, m) { First input sequence (1D array). y : numpy.ndarray Second input sequence (1D array). - cost_matrix : numpy.ndarray - Pre-allocated 2D array to store the computed cost matrix. Should have shape (len(x)+1, len(y)+1). + + Returns + ------- + numpy.ndarray + A 2D array representing the cost matrix for DTW between the two input sequences. + Shape: (len(x) + 1, len(y) + 1) )pbdoc"); } diff --git a/src/dtw.cpp b/src/dtw.cpp index 7a31f86d33..358afa9f8f 100644 --- a/src/dtw.cpp +++ b/src/dtw.cpp @@ -8,22 +8,36 @@ namespace dtw { namespace nb = nanobind; - void dtw_cost_matrix_no_window_1d(nb::ndarray> x, - nb::ndarray> y, - nb::ndarray> cost_matrix) { + nb::ndarray> dtw_cost_matrix_no_window_1d(nb::ndarray x, + nb::ndarray y) { auto x_view = x.view>(); auto y_view = y.view>(); - auto cost_view = cost_matrix.view>(); + size_t x_size = x_view.shape(0); + size_t y_size = y_view.shape(0); + // auto cost_view = cost_matrix.view>(); + double *cost_matrix = new double[(x_size + 1) * (y_size + 1)]; + std::fill_n(cost_matrix, (x_size + 1) * (y_size + 1), std::numeric_limits::infinity()); // initialize the first values of the cost matrix - cost_view(0, 0) = 0.0; - double cost = 0.0, min_prev_cost = 0.0; - for (size_t i = 1; i <= x_view.shape(0); ++i) { - for (size_t j = 1; j <= y_view.shape(0); ++j) { - cost = ABS(x_view(i-1) - y_view(j-1)); - min_prev_cost = std::min({cost_view(i-1, j), cost_view(i, j-1), cost_view(i-1, j-1)}); - cost_view(i, j) = cost + min_prev_cost; + cost_matrix[0] = 0.0; + for (size_t i = 1; i <= x_size; ++i) { + for (size_t j = 1; j <= y_size; ++j) { + double cost = ABS(x_view(i-1) - y_view(j-1)); + double min_prev_cost = std::min({cost_matrix[(i-1) * (y_size + 1) + j], cost_matrix[i * (y_size + 1) + (j-1)], cost_matrix[(i-1) * (y_size + 1) + (j-1)]}); + cost_matrix[i * (y_size + 1) + j] = cost + min_prev_cost; } } + + // create a capsule to manage the memory of the cost matrix + nb::capsule owner(cost_matrix, [](void *ptr) noexcept { + delete[] (double *)ptr; + }); + + // return the cost matrix as a numpy array + return nb::ndarray>( + cost_matrix, + {x_size + 1, y_size + 1}, + owner + ); } } // namespace dtw From 5120347614b77011d4d37c495767c7af91241cf8 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Sun, 29 Mar 2026 14:58:47 +0100 Subject: [PATCH 11/17] Speed up Co-authored-by: Zhihao Dai --- src/dtw.cpp | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/dtw.cpp b/src/dtw.cpp index 358afa9f8f..21efc35091 100644 --- a/src/dtw.cpp +++ b/src/dtw.cpp @@ -14,15 +14,25 @@ namespace dtw { auto y_view = y.view>(); size_t x_size = x_view.shape(0); size_t y_size = y_view.shape(0); - // auto cost_view = cost_matrix.view>(); - double *cost_matrix = new double[(x_size + 1) * (y_size + 1)]; - std::fill_n(cost_matrix, (x_size + 1) * (y_size + 1), std::numeric_limits::infinity()); + size_t total_size = (x_size + 1) * (y_size + 1); + + // allocate the cost matrix and initialize it with infinity + double *cost_matrix = new double[total_size]; + double inf = std::numeric_limits::infinity(); + std::fill_n(cost_matrix, y_size + 1, inf); + // initialize the first values of the cost matrix cost_matrix[0] = 0.0; + double cost = 0.0, min_prev_cost = 0.0; for (size_t i = 1; i <= x_size; ++i) { + cost_matrix[i * (y_size + 1)] = inf; for (size_t j = 1; j <= y_size; ++j) { - double cost = ABS(x_view(i-1) - y_view(j-1)); - double min_prev_cost = std::min({cost_matrix[(i-1) * (y_size + 1) + j], cost_matrix[i * (y_size + 1) + (j-1)], cost_matrix[(i-1) * (y_size + 1) + (j-1)]}); + cost = ABS(x_view(i-1) - y_view(j-1)); + min_prev_cost = std::min({ + cost_matrix[(i-1) * (y_size + 1) + j], + cost_matrix[i * (y_size + 1) + (j-1)], + cost_matrix[(i-1) * (y_size + 1) + (j-1)] + }); cost_matrix[i * (y_size + 1) + j] = cost + min_prev_cost; } } From e32dd322a0f4907eae7dd7ef6c99f59b81c54712 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Sun, 5 Apr 2026 22:24:00 +0100 Subject: [PATCH 12/17] Add 2d overload Co-authored-by: Zhihao Dai --- CMakeLists.txt | 20 ++++++++++++ darts/dataprocessing/dtw/dtw.py | 6 ++-- include/dtw.h | 8 +++-- pyproject.toml | 14 +++------ src/bind.cpp | 20 ++++++++++-- src/dtw.cpp | 54 +++++++++++++++++++++++++++++++-- 6 files changed, 103 insertions(+), 19 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 23ac6d7ff2..110f60abf5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,5 +58,25 @@ nanobind_add_module( src/dtw.cpp ) +nanobind_add_stub( + _ext_stub + MODULE _ext + OUTPUT _ext.pyi + PYTHON_PATH $ + DEPENDS _ext +) + +# Keep generated typing artifacts visible to language servers during local/editable +# development. VS Code/Pylance resolves this workspace package from source. +add_custom_command( + TARGET _ext_stub POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_CURRENT_BINARY_DIR}/_ext.pyi + ${CMAKE_CURRENT_SOURCE_DIR}/darts/_ext.pyi +) + # Install directive for scikit-build-core install(TARGETS _ext LIBRARY DESTINATION darts) + +# Install the generated stub file as well, so that it can be included in the wheel +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/py.typed ${CMAKE_CURRENT_BINARY_DIR}/_ext.pyi DESTINATION darts) diff --git a/darts/dataprocessing/dtw/dtw.py b/darts/dataprocessing/dtw/dtw.py index babc4b9f0a..c3c20e1894 100644 --- a/darts/dataprocessing/dtw/dtw.py +++ b/darts/dataprocessing/dtw/dtw.py @@ -10,7 +10,7 @@ import pandas as pd from darts import TimeSeries -from darts._ext import dtw_cost_matrix_no_window_1d +from darts._ext import dtw_cost_matrix_no_window from darts.dataprocessing.dtw.cost_matrix import CostMatrix from darts.dataprocessing.dtw.window import CRWindow, NoWindow, Window from darts.logging import get_logger, raise_if, raise_if_not @@ -363,8 +363,8 @@ def dtw( logger, ) cost_matrix = _fast_dtw(values_x, values_y, distance, multi_grid_radius) - elif isinstance(window, NoWindow) and both_univariate: - cost_matrix = dtw_cost_matrix_no_window_1d(values_x, values_y) + elif isinstance(window, NoWindow): + cost_matrix = dtw_cost_matrix_no_window(values_x, values_y) else: cost_matrix = _dtw_cost_matrix(values_x, values_y, distance, window) diff --git a/include/dtw.h b/include/dtw.h index b6483a50ed..d0a372a83f 100644 --- a/include/dtw.h +++ b/include/dtw.h @@ -5,6 +5,10 @@ namespace nb = nanobind; namespace dtw { - nb::ndarray> dtw_cost_matrix_no_window_1d(nb::ndarray x, - nb::ndarray y); + nb::ndarray> dtw_cost_matrix_no_window_1d( + nb::ndarray> x, + nb::ndarray> y); + nb::ndarray> dtw_cost_matrix_no_window_2d( + nb::ndarray> x, + nb::ndarray> y); } // namespace dtw diff --git a/pyproject.toml b/pyproject.toml index b98aee3d3d..7e9ca401fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ required-environments = [ "sys_platform == 'linux'", "sys_platform == 'win32'", ] -cache-keys = [ {file = "src/*.cpp"}, {file = "include/*.h"} ] +cache-keys = [ {file = "src/*.cpp"}, {file = "include/*.h"}, {file = "CMakeLists.txt"} ] [tool.uv.pip] # Use only binary dependency wheels to prevent arbitrary code execution @@ -97,12 +97,6 @@ name = "pytorch-cpu" url = "https://download.pytorch.org/whl/cpu" explicit = true # Only use this index for packages explicitly configured to use it -[tool.setuptools] -zip-safe = false - -[tool.setuptools.packages.find] -exclude = ["examples*", "datasets*", "docs*", "darts.tests*"] - [tool.scikit-build] # Protect the configuration against future changes in scikit-build-core minimum-version = "build-system.requires" @@ -112,9 +106,9 @@ build-dir = "build/{wheel_tag}" # Build stable ABI wheels for CPython 3.12+ wheel.py-api = "cp312" - -[tool.setuptools.package-data] -darts = ["py.typed"] +# Explicit package selection for wheel assembly via scikit-build-core. +# Keep this backend-exclusive to avoid accidental setuptools-built wheels. +wheel.packages = ["darts"] [tool.cibuildwheel] archs = "all" diff --git a/src/bind.cpp b/src/bind.cpp index e097088e31..c9b8e07dcb 100644 --- a/src/bind.cpp +++ b/src/bind.cpp @@ -7,8 +7,24 @@ using namespace nb::literals; NB_MODULE(_ext, m) { m.doc() = "Internal C++ implementation of Darts algorithms."; - m.def("add", [](int a, int b) { return a + b; }, "a"_a, "b"_a); - m.def("dtw_cost_matrix_no_window_1d", &dtw::dtw_cost_matrix_no_window_1d, "x"_a, "y"_a, + m.def("dtw_cost_matrix_no_window", &dtw::dtw_cost_matrix_no_window_2d, "x"_a, "y"_a, + R"pbdoc( + Compute the cost matrix for Dynamic Time Warping (DTW) without any window constraints for 2D sequences. + + Parameters + ---------- + x : numpy.ndarray + First input sequence (2D array). + y : numpy.ndarray + Second input sequence (2D array). + + Returns + ------- + numpy.ndarray + A 2D array representing the cost matrix for DTW between the two input sequences. + Shape: (len(x) + 1, len(y) + 1) + )pbdoc"); + m.def("dtw_cost_matrix_no_window", &dtw::dtw_cost_matrix_no_window_1d, "x"_a, "y"_a, R"pbdoc( Compute the cost matrix for Dynamic Time Warping (DTW) without any window constraints. diff --git a/src/dtw.cpp b/src/dtw.cpp index 21efc35091..d21a693a33 100644 --- a/src/dtw.cpp +++ b/src/dtw.cpp @@ -8,8 +8,9 @@ namespace dtw { namespace nb = nanobind; - nb::ndarray> dtw_cost_matrix_no_window_1d(nb::ndarray x, - nb::ndarray y) { + nb::ndarray> dtw_cost_matrix_no_window_1d( + nb::ndarray> x, + nb::ndarray> y) { auto x_view = x.view>(); auto y_view = y.view>(); size_t x_size = x_view.shape(0); @@ -50,4 +51,53 @@ namespace dtw { ); } + + nb::ndarray> dtw_cost_matrix_no_window_2d( + nb::ndarray> x, + nb::ndarray> y) { + auto x_view = x.view>(); + auto y_view = y.view>(); + size_t x_size = x_view.shape(0); + size_t y_size = y_view.shape(0); + size_t n_components = x_view.shape(1); + size_t total_size = (x_size + 1) * (y_size + 1); + + // allocate the cost matrix and initialize it with infinity + double *cost_matrix = new double[total_size]; + double inf = std::numeric_limits::infinity(); + std::fill_n(cost_matrix, y_size + 1, inf); + + // initialize the first values of the cost matrix + cost_matrix[0] = 0.0; + double cost = 0.0, min_prev_cost = 0.0; + for (size_t i = 1; i <= x_size; ++i) { + cost_matrix[i * (y_size + 1)] = inf; + for (size_t j = 1; j <= y_size; ++j) { + cost = 0.0; + for (size_t k = 0; k < n_components; ++k) { + cost += ABS(x_view(i-1, k) - y_view(j-1, k)); + } + min_prev_cost = std::min({ + cost_matrix[(i-1) * (y_size + 1) + j], + cost_matrix[i * (y_size + 1) + (j-1)], + cost_matrix[(i-1) * (y_size + 1) + (j-1)] + }); + cost_matrix[i * (y_size + 1) + j] = cost + min_prev_cost; + } + } + + // create a capsule to manage the memory of the cost matrix + nb::capsule owner(cost_matrix, [](void *ptr) noexcept { + delete[] (double *)ptr; + }); + + // return the cost matrix as a numpy array + return nb::ndarray>( + cost_matrix, + {x_size + 1, y_size + 1}, + owner + ); + } + + } // namespace dtw From 798155f76e27533a76657409039d8dc6e570f198 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Sun, 5 Apr 2026 22:27:26 +0100 Subject: [PATCH 13/17] Revert dtw path changes Co-authored-by: Zhihao Dai --- darts/dataprocessing/dtw/dtw.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/darts/dataprocessing/dtw/dtw.py b/darts/dataprocessing/dtw/dtw.py index c3c20e1894..473c84ea15 100644 --- a/darts/dataprocessing/dtw/dtw.py +++ b/darts/dataprocessing/dtw/dtw.py @@ -38,9 +38,9 @@ def _dtw_cost_matrix( return dtw -def _dtw_path(dtw: CostMatrix, n: int, m: int) -> np.ndarray: - i = n - j = m +def _dtw_path(dtw: CostMatrix) -> np.ndarray: + i = dtw.n + j = dtw.m path = [] @@ -133,7 +133,7 @@ def _fast_dtw( half_y = _down_sample(y) low_res_cost = _fast_dtw(half_x, half_y, dist, radius, depth + 1) - low_res_path = _dtw_path(low_res_cost, n, m) + low_res_path = _dtw_path(low_res_cost) window = _expand_window(low_res_path, len(x), len(y), radius) cost = _dtw_cost_matrix(x, y, dist, window) @@ -189,7 +189,7 @@ def path(self) -> np.ndarray: if hasattr(self, "_path"): return self._path - self._path = _dtw_path(self.cost, self.n, self.m) + self._path = _dtw_path(self.cost) return self._path def distance(self) -> float: From 376ef0c5e337b7c99b270952ea554bc6e927a309 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Sun, 5 Apr 2026 22:43:16 +0100 Subject: [PATCH 14/17] Exclude test files Co-authored-by: Zhihao Dai --- .gitignore | 1 + CMakeLists.txt | 1 + pyproject.toml | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 8d1cc5aa94..38cb659587 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ uv.lock # C++ build artifacts *.so +_ext.pyi diff --git a/CMakeLists.txt b/CMakeLists.txt index 110f60abf5..51e602ff82 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,6 +63,7 @@ nanobind_add_stub( MODULE _ext OUTPUT _ext.pyi PYTHON_PATH $ + MARKER_FILE py.typed DEPENDS _ext ) diff --git a/pyproject.toml b/pyproject.toml index 7e9ca401fd..ffe3007b59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,9 @@ explicit = true # Only use this index for packages explicitly configured to use # Protect the configuration against future changes in scikit-build-core minimum-version = "build-system.requires" +# Exclude non-code files from the source distribution to reduce size and avoid packaging issues. +sdist.exclude = ["examples*", "datasets*", "docs*"] + # Setuptools-style build caching in a local directory build-dir = "build/{wheel_tag}" @@ -109,6 +112,8 @@ wheel.py-api = "cp312" # Explicit package selection for wheel assembly via scikit-build-core. # Keep this backend-exclusive to avoid accidental setuptools-built wheels. wheel.packages = ["darts"] +# Exclude test modules from the wheel distribution +wheel.exclude = ["darts/tests*"] [tool.cibuildwheel] archs = "all" From 1e1a789cf215e49c7e8419b57352d2cbd29516a7 Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Mon, 6 Apr 2026 00:15:03 +0100 Subject: [PATCH 15/17] Rewrite DenseCostMatrix Co-authored-by: Zhihao Dai --- darts/dataprocessing/dtw/cost_matrix.py | 26 +- darts/dataprocessing/dtw/dtw.py | 5 +- darts/dataprocessing/dtw/dtw_old.py | 368 ++++++++++++++++++++++++ 3 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 darts/dataprocessing/dtw/dtw_old.py diff --git a/darts/dataprocessing/dtw/cost_matrix.py b/darts/dataprocessing/dtw/cost_matrix.py index b3d3e9b0b7..84e9b3147b 100644 --- a/darts/dataprocessing/dtw/cost_matrix.py +++ b/darts/dataprocessing/dtw/cost_matrix.py @@ -5,7 +5,9 @@ import array from abc import ABC, abstractmethod +from collections.abc import Generator from itertools import repeat +from typing import Any import numpy as np @@ -30,7 +32,7 @@ def fill(self, value: float): pass @abstractmethod - def __getitem__(self, item): + def __getitem__(self, item) -> Any: pass @abstractmethod @@ -38,7 +40,7 @@ def __setitem__(self, key, value): pass @abstractmethod - def __iter__(self): + def __iter__(self) -> Generator: pass @abstractmethod @@ -77,14 +79,24 @@ def _from_window(window: Window): return DenseCostMatrix(window.n, window.m) -class DenseCostMatrix(np.ndarray, CostMatrix): - def __new__(self, n, m): +class DenseCostMatrix(CostMatrix): + def __init__(self, n: int, m: int, dense: np.ndarray | None = None): self.n = n self.m = m - return super().__new__(self, (n + 1, m + 1), float) + self.dense = np.ndarray((n + 1, m + 1), float) if dense is None else dense - def to_dense(self) -> np.ndarray: - return self[1:, 1:] + def to_dense(self, copy: bool = False) -> np.ndarray: + data = self.dense[1:, 1:] + return data.copy() if copy else data + + def fill(self, value: float): + self.dense.fill(value) + + def __getitem__(self, item): + return self.dense[item] + + def __setitem__(self, key, value): + self.dense[key] = value def __iter__(self): for n in range(1, self.n): diff --git a/darts/dataprocessing/dtw/dtw.py b/darts/dataprocessing/dtw/dtw.py index 473c84ea15..ca6029da31 100644 --- a/darts/dataprocessing/dtw/dtw.py +++ b/darts/dataprocessing/dtw/dtw.py @@ -11,7 +11,7 @@ from darts import TimeSeries from darts._ext import dtw_cost_matrix_no_window -from darts.dataprocessing.dtw.cost_matrix import CostMatrix +from darts.dataprocessing.dtw.cost_matrix import CostMatrix, DenseCostMatrix from darts.dataprocessing.dtw.window import CRWindow, NoWindow, Window from darts.logging import get_logger, raise_if, raise_if_not @@ -364,7 +364,8 @@ def dtw( ) cost_matrix = _fast_dtw(values_x, values_y, distance, multi_grid_radius) elif isinstance(window, NoWindow): - cost_matrix = dtw_cost_matrix_no_window(values_x, values_y) + cost_array = dtw_cost_matrix_no_window(values_x, values_y) + cost_matrix = DenseCostMatrix(window.n, window.m, cost_array) else: cost_matrix = _dtw_cost_matrix(values_x, values_y, distance, window) diff --git a/darts/dataprocessing/dtw/dtw_old.py b/darts/dataprocessing/dtw/dtw_old.py new file mode 100644 index 0000000000..1681f1980a --- /dev/null +++ b/darts/dataprocessing/dtw/dtw_old.py @@ -0,0 +1,368 @@ +""" +Dynamic Time Warping (DTW) +-------------------------- +""" + +import copy +from collections.abc import Callable + +import numpy as np +import pandas as pd + +from darts import TimeSeries +from darts.dataprocessing.dtw.cost_matrix import CostMatrix +from darts.dataprocessing.dtw.window import CRWindow, NoWindow, Window +from darts.logging import get_logger, raise_if, raise_if_not + +logger = get_logger(__name__) + +SeriesValue = np.ndarray | np.floating +DistanceFunc = Callable[[SeriesValue, SeriesValue], float] + + +# CORE ALGORITHM +def _dtw_cost_matrix( + x: np.ndarray, y: np.ndarray, dist: DistanceFunc, window: Window +) -> CostMatrix: + dtw = CostMatrix._from_window(window) + + dtw.fill(np.inf) + dtw[0, 0] = 0 + + for i, j in window: + cost = dist(x[i - 1], y[j - 1]) + min_cost_prev = min(dtw[i - 1, j], dtw[i, j - 1], dtw[i - 1, j - 1]) + dtw[i, j] = cost + min_cost_prev + + return dtw + + +def _dtw_path(dtw: CostMatrix) -> np.ndarray: + i = dtw.n + j = dtw.m + + path = [] + + while i > 0 or j > 0: + path.append((i - 1, j - 1)) + + stencil = [ + (i - 1, j - 1), # diagonal + (i - 1, j), # left + (i, j - 1), # down + ] + + costs = [dtw[i, j] for i, j in stencil] + index_min = costs.index(min(costs)) + + i, j = stencil[index_min] + + path.reverse() + return np.array(path) + + +# MULTI-SCALE +def _down_sample(high_res: np.ndarray): + needs_padding = len(high_res) & 1 + if needs_padding: + high_res = np.append(high_res, high_res[-1]) + + low_res = np.reshape(high_res, (-1, 2)) + low_res = np.mean(low_res, axis=1) + + return low_res + + +def _expand_window(low_res_path: np.ndarray, n: int, m: int, radius: int) -> CRWindow: + high_res_grid = CRWindow(n, m) + + def is_valid(cell): + valid_x = 1 <= cell[0] <= n + valid_y = 1 <= cell[1] <= m + + return valid_x and valid_y + + # up-sample path, 1 grid point computed at half-resolution becomes at least 4 + # (1,2) and (2,1) allows for better DWT solution, as it isn't diagonally constrained + # + # 4-sample 6-sample + # X X X X + # X X vs. X X X + # X X X X X + # X X X X + + pattern = [(0, 0, 2), (1, 0, 3), (2, 1, 2)] + + for i, j in low_res_path: + for column, start, end in pattern: + # +1 offset since x[0], y[0] maps to DTW[1][1] + + column += i * 2 + 1 + + # ensure no out of bounds + # enlarge vertically by radius is O(1), due to compressed row representation + start = max(1, min(m + 1, start + j * 2 - radius)) + end = max(1, min(m + 1, end + j * 2 + 1 + radius)) + + # to create an nxn block, add (0-n) range n times + # ensure no out of bounds + # if column is too large m-column -> 0 + # if column-k is too small, column < radius+1 + for k in range(0, min(radius + 1, column, n - column + 1)): + high_res_grid.add_range(column - k, start, end) + + return high_res_grid + + +def _fast_dtw( + x: np.ndarray, y: np.ndarray, dist: DistanceFunc, radius: int, depth: int = 0 +) -> CostMatrix: + n = len(x) + m = len(y) + min_size = radius + 2 + + if n < min_size or m < min_size or radius == -1: + window = NoWindow() + window.init_size(n, m) + cost = _dtw_cost_matrix(x, y, dist, window) + + return cost + + half_x = _down_sample(x) + half_y = _down_sample(y) + + low_res_cost = _fast_dtw(half_x, half_y, dist, radius, depth + 1) + low_res_path = _dtw_path(low_res_cost) + window = _expand_window(low_res_path, len(x), len(y), radius) + cost = _dtw_cost_matrix(x, y, dist, window) + + return cost + + +def _default_distance_multi(x_values: np.ndarray, y_values: np.ndarray): + return np.sum(np.abs(x_values - y_values)) + + +def _default_distance_uni(x_value: float, y_value: float): + return abs(x_value - y_value) + + +# Public API Functions +class DTWAlignment: + """ + Dynamic Time Warping (DTW) Alignment. + + Attributes + ---------- + n + The length of `series1` + m + The length of `series2` + series1 + A `TimeSeries` to align with `series2`. + series2 + A `TimeSeries` to align with `series1`. + cost + The `CostMatrix` for DTW. + """ + + def __init__(self, series1: TimeSeries, series2: TimeSeries, cost: CostMatrix): + self.n = len(series1) + self.m = len(series2) + self.series1 = series1 + self.series2 = series2 + self.cost = cost + + from ._plot import plot, plot_alignment + + def path(self) -> np.ndarray: + """Gives the index paths from `series1` to `series2`. + + Returns + ------- + np.ndarray of shape `(len(path), 2)` + An array of indices [[i0,j0], [i1,j1], [i2,j2], ...], where i indexes into series1 + and j indexes into series2. + Indices are in monotonic order, path[n] >= path[n-1] + """ + + if hasattr(self, "_path"): + return self._path + self._path = _dtw_path(self.cost) + return self._path + + def distance(self) -> float: + """Gives the total distance between pair-wise elements in the two series after warping. + + Returns + ------- + float + The total distance between pair-wise elements in the two series after warping. + """ + return self.cost[(self.n, self.m)] + + def mean_distance(self) -> float: + """Gives the mean distance between pair-wise elements in the two series after warping. + + Returns + ------- + float + The mean distance between pair-wise elements in the two series after warping. + """ + if hasattr(self, "_mean_distance"): + return self._mean_distance + + path = self.path() + self._mean_distance = self.distance() / len(path) + return self._mean_distance + + def warped(self) -> (TimeSeries, TimeSeries): + """Warps the two time series according to the warp path returned by `DTWAlignment.path()`, which minimizes the + pair-wise distance. + This will bring two time series that are out-of-phase back into phase. + + Returns + ------- + (TimeSeries, TimeSeries) + Two new TimeSeries instances of the same length, indexed by pd.RangeIndex. + """ + values1 = self.series1.all_values(copy=False) + values2 = self.series2.all_values(copy=False) + path = self.path() + + values1, values2 = values1[path[:, 0]], values2[path[:, 1]] + + idx1 = pd.RangeIndex(values1.shape[0], name=self.series1._time_index.name) + idx2 = pd.RangeIndex(values2.shape[0], name=self.series2._time_index.name) + warped_series1 = TimeSeries( + times=idx1, + values=values1, + components=self.series1.components, + **self.series1._attrs, + ) + + warped_series2 = TimeSeries( + times=idx2, + values=values2, + components=self.series2.components, + **self.series2._attrs, + ) + + # todo: prevent time information being lost after warping + # Applying time index from series1 to series2 (take_dates = True) is disabled for consistency reasons + # Applying the warp path to the dates directly will result in duplicate dates + # and hence values being lost when converting back to a TimeSeries. + # As a result series1 will not be warped, whereas series2 will be. + # It could also cause the two series to have different lengths, if len(series1) < len(series2) + # One could generate intermediate dates, but then the series would not have a consistent frequency + return warped_series1, warped_series2 + + +def dtw( + series1: TimeSeries, + series2: TimeSeries, + window: Window | None = None, + distance: DistanceFunc | None = None, + multi_grid_radius: int = -1, +) -> DTWAlignment: + """ + Determines the optimal alignment between two time series `series1` and `series2`, according to the Dynamic Time + Warping algorithm. + The alignment minimizes the distance between pair-wise elements after warping. + All elements in the two series are matched and are in strictly monotonically increasing order. + Considers only the values in the series, ignoring the time axis. + + Dynamic Time Warping can be applied to determine how closely two time series correspond, + irrespective of phase, length or speed differences. + + Parameters + ---------- + series1 + A `TimeSeries` to align with `series2`. + series2 + A `TimeSeries` to align with `series1`. + window + Optionally, a `Window` used to constrain the search for the optimal alignment: see `SakoeChiba` and `Itakura`. + Default considers all possible alignments (`NoWindow`). + distance + Function taking as input either two `floats` for univariate series or two `np.ndarray`, + and returning the distance between them. + + Defaults to the abs difference for univariate-data and the + sum of the abs difference for multi-variate series. + multi_grid_radius + Default radius of `-1` results in an exact evaluation of the dynamic time warping algorithm. + Without constraints DTW runs in O(nxm) time where n,m are the size of the series. + Exact evaluation with no constraints, will result in a performance warning on large datasets. + + Setting `multi_grid_radius` to a value other than `-1`, will enable the approximate multi-grid solver, + which executes in linear time, vs quadratic time for exact evaluation. + Increasing radius trades solution accuracy for performance. + + Returns + ------- + DTWAlignment + Helper object for getting warp path, mean_distance, distance and warped time series + """ + if window is None: + window = NoWindow() + + if ( + multi_grid_radius == -1 + and type(window) is NoWindow + and len(series1) * len(series2) > 10**6 + ): + logger.warning( + "Exact evaluation will result in poor performance on large datasets." + " Consider enabling multi-grid or using a window." + ) + + both_univariate = series1.is_univariate and series2.is_univariate + + if distance is None: + raise_if_not( + series1.n_components == series2.n_components, + "Expected series to have same number of components, or to supply custom distance function", + logger, + ) + + distance = _default_distance_uni if both_univariate else _default_distance_multi + + if both_univariate: + values_x = series1.univariate_values(copy=False) + values_y = series2.univariate_values(copy=False) + else: + values_x = series1.values(copy=False) + values_y = series2.values(copy=False) + + raise_if( + np.any(np.isnan(values_x)), + "Dynamic Time Warping does not support nan values. " + "You can use the module darts.utils.missing_values to fill them, " + "before passing them to dtw.", + logger, + ) + raise_if( + np.any(np.isnan(values_y)), + "Dynamic Time Warping does not support nan values. " + "You can use the module darts.utils.missing_values to fill them," + "before passing it into dtw", + logger, + ) + + window = copy.deepcopy(window) + window.init_size(len(values_x), len(values_y)) + + raise_if(multi_grid_radius < -1, "Expected multi-grid radius to be positive or -1") + + if multi_grid_radius >= 0: + raise_if_not( + isinstance(window, NoWindow), + "Multi-grid solver does not currently support windows", + logger, + ) + cost_matrix = _fast_dtw(values_x, values_y, distance, multi_grid_radius) + else: + cost_matrix = _dtw_cost_matrix(values_x, values_y, distance, window) + + return DTWAlignment(series1, series2, cost_matrix) From 713eb3708dd3b87136c4944592eb7b13638dc57d Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Mon, 6 Apr 2026 17:09:55 +0100 Subject: [PATCH 16/17] Opt in free threaded Otherwise, GIL is enabled globally. Co-authored-by: Zhihao Dai --- CMakeLists.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 51e602ff82..b00984cb73 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,9 @@ nanobind_add_module( # Name of the extension _ext + # This extension is free-threaded (only applies to Python 3.14t+) + FREE_THREADED + # Target the stable ABI for Python 3.12+, which reduces # the number of binary wheels that must be built. This # does nothing on older Python versions From bba11bf389190aa7dd46fbe673c77716ded862ef Mon Sep 17 00:00:00 2001 From: Zhihao Dai Date: Mon, 6 Apr 2026 17:21:24 +0100 Subject: [PATCH 17/17] Lower macos target Co-authored-by: Zhihao Dai --- pyproject.toml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffe3007b59..2d9d7d4dfb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,12 +125,9 @@ build-verbosity = 1 # test-command = "pytest {project}/tests" # test-requires = "pytest" -# # Don't test Python 3.8 wheels on macOS/arm64 -# test-skip="cp38-macosx_*:arm64" - -# Needed for full C++17 support [tool.cibuildwheel.macos.environment] -MACOSX_DEPLOYMENT_TARGET = "10.14" +# Needed for C++17 support +MACOSX_DEPLOYMENT_TARGET = "10.13" [tool.pytest.ini_options] addopts = [