Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
uses: pypa/cibuildwheel@v4.3.1
env:
# All cibuildwheel options are here (nothing "hidden" in pyproject.toml)
CIBW_SKIP: "*-win32 *i686 cp3??t-* cp310-win_arm64"
CIBW_SKIP: "*-win32 *i686 cp313t-* cp310-win_arm64"
CIBW_BUILD_FRONTEND: build
CIBW_BUILD_VERBOSITY: 3
CIBW_TEST_REQUIRES: pytest scipy
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15"]
resolution: ["lowest-direct", "highest"]
exclude:
- python-version: "3.12"
Expand All @@ -22,6 +22,8 @@ jobs:
resolution: "lowest-direct"
- python-version: "3.14"
resolution: "lowest-direct"
- python-version: "3.14t"
resolution: "lowest-direct"
- python-version: "3.15"
resolution: "lowest-direct"
steps:
Expand All @@ -43,8 +45,6 @@ jobs:

- name: Install Ubuntu-build dependencies
run: |
# sudo apt-get update
# sudo apt-get install -y libopenblas-dev
gfortran --version
gcc --version

Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ of the original Fortran implementation, while working within the modern Python e
ODR, also known as [errors-in-variables regression], is designed primarily for instances when both
the explanatory and response variables have significant errors.

<p align="center">
<img src="https://github.com/HugoMVale/odrpack-python/blob/main/docs/odr.png" width="250" alt="Deming regression; special case of ODR." style="margin-right: 10px;">
</p>
![Deming regression; special case of ODR.](https://github.com/HugoMVale/odrpack-python/blob/main/docs/odr.png)

[errors-in-variables regression]: https://en.wikipedia.org/wiki/Errors-in-variables_models
[odrpack95]: https://github.com/HugoMVale/odrpack95
Expand Down
11 changes: 10 additions & 1 deletion meson.build
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
project(
'odrpack',
['cpp', 'fortran'],
version : '0.7.0',
version : '0.7.1',
meson_version: '>=1.5.0',
default_options: [
'buildtype=release',
Expand All @@ -15,6 +15,15 @@ project(
python = import('python').find_installation(pure: false)
python_dep = python.dependency()

# Free-threaded CPython (3.13t, 3.14t, ...): nanobind -- both the static library
# and the extension -- must be compiled with NB_FREE_THREADED. Otherwise the
# module does not declare Py_MOD_GIL_NOT_USED (CPython silently re-enables the
# GIL on import) and nanobind's internals are not thread-safe. Global arguments
# are used on purpose, because they also reach the nanobind subproject.
if python.get_variable('Py_GIL_DISABLED', 0) == 1
add_global_arguments('-DNB_FREE_THREADED', language: 'cpp')
endif

# nanobind (from subprojects)
nanobind_dep = dependency('nanobind', static: true)

Expand Down
49 changes: 38 additions & 11 deletions src/odrpack/__odrpack.py.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#include <algorithm>
#include <array>
#include <exception>
#include <iostream>
#include <map>
#include <optional>
Expand All @@ -24,13 +25,23 @@ struct Context {
nb::callable fcn_f;
nb::callable fcn_fjacb;
nb::callable fcn_fjacd;
nb::object odr_stop;
std::exception_ptr error;
};

/*
Callback function invoked from `odrpack` to evaluate the model function and, optionally, its
Jacobians. The actual python functions are stored in the `Context` struct, which is passed through
the void pointer argument `data`. This is used to call the appropriate functions without
creating closures.
Jacobians. The actual python functions are stored in the `Context` struct, which is passed
through the void pointer argument `data`. This is used to call the appropriate functions
without creating closures.

Exceptions must never propagate out of this function: it is called from Fortran, and unwinding
a C++ exception through Fortran frames is not guaranteed to work. Therefore:
- `OdrStop` (or a subclass): the regression is stopped gracefully (`istop = 1`) and a solution
is returned.
- Any other exception (Python or C++): it is stored in the context, the regression is aborted
(`istop = -1`) and the exception is rethrown by the caller after the Fortran routine has
returned normally.
*/
void fcn(const int *n_ptr, const int *m_ptr, const int *q_ptr, const int *npar_ptr, const int *ldifx_ptr,
const double beta[], const double xplusd[], const int ifixb[], const int ifixx[],
Expand All @@ -50,7 +61,13 @@ void fcn(const int *n_ptr, const int *m_ptr, const int *q_ptr, const int *npar_p
(m == 1) ? std::initializer_list<size_t>{n} : std::initializer_list<size_t>{m, n});

// Retrieve the model context
const auto *context = static_cast<const Context *>(data);
auto *context = static_cast<Context *>(data);

// A previous callback already failed: do not call back into Python, just keep aborting.
if (context->error) {
*istop = -1;
return;
}

*istop = 0;
try {
Expand All @@ -76,14 +93,18 @@ void fcn(const int *n_ptr, const int *m_ptr, const int *q_ptr, const int *npar_p
}

} catch (const nb::python_error &e) {
// temporary solution: need to figure out how to do this the right way
std::string ewhat = e.what();
if (ewhat.find("OdrStop") != std::string::npos) {
std::cerr << ewhat << std::endl;
if (e.matches(context->odr_stop)) {
// `OdrStop` (or a subclass) raised by the user: stop gracefully
std::cerr << e.what() << std::endl;
*istop = 1;
} else {
throw;
context->error = std::current_exception();
*istop = -1;
}
} catch (...) {
// Any other exception
context->error = std::current_exception();
*istop = -1;
}
};

Expand Down Expand Up @@ -175,8 +196,9 @@ int odr_wrapper(
if (rwork) lrwork = rwork.value().size();
if (iwork) liwork = iwork.value().size();

// Define the context for the user-supplied model function and its Jacobians.
Context context = {fcn_f, fcn_fjacb, fcn_fjacd};
// Define the context for the user-supplied model function and its Jacobians
nb::object odr_stop = nb::module_::import_("odrpack.exceptions").attr("OdrStop");
Context context = {fcn_f, fcn_fjacb, fcn_fjacd, odr_stop};

// Open files
int lunrpt = 6;
Expand Down Expand Up @@ -221,6 +243,11 @@ int odr_wrapper(
if (ierr != 0) throw std::runtime_error("Error closing error file.");
}

// Rethrow the exception (if any) that aborted the regression in a callback.
// This is done only now, after the Fortran routine has returned normally and the files
// have been closed.
if (context.error) std::rethrow_exception(context.error);

return info;
}

Expand Down
20 changes: 10 additions & 10 deletions subprojects/nanobind.wrap
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
[wrap-file]
directory = nanobind-2.12.0
source_url = https://github.com/wjakob/nanobind/archive/refs/tags/v2.12.0.tar.gz
source_filename = nanobind-2.12.0.tar.gz
source_hash = 01f1f0cd0398743c18f33d07ae36ad410bd7f4a1e90683b508504de897d6e629
source_fallback_url = https://wrapdb.mesonbuild.com/v2/nanobind_2.12.0-1/get_source/nanobind-2.12.0.tar.gz
patch_filename = nanobind_2.12.0-1_patch.zip
patch_url = https://wrapdb.mesonbuild.com/v2/nanobind_2.12.0-1/get_patch
patch_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/nanobind_2.12.0-1/nanobind_2.12.0-1_patch.zip
patch_hash = 4b8158bdb359218bcfb7a5b4459fbfa5919171d9ea2400de3a84cf9ab7c7308b
wrapdb_version = 2.12.0-1
directory = nanobind-2.15.0
source_url = https://github.com/wjakob/nanobind/archive/refs/tags/v2.15.0.tar.gz
source_filename = nanobind-2.15.0.tar.gz
source_hash = 36c8760b3acb25643cd89d549782d8c67bd82ce54c6238608787c22a34fd490f
source_fallback_url = https://wrapdb.mesonbuild.com/v2/nanobind_2.15.0-1/get_source/nanobind-2.15.0.tar.gz
patch_filename = nanobind_2.15.0-1_patch.zip
patch_url = https://wrapdb.mesonbuild.com/v2/nanobind_2.15.0-1/get_patch
patch_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/nanobind_2.15.0-1/nanobind_2.15.0-1_patch.zip
patch_hash = 4a3956c67792830e0c85dff3cf88b30a5547bd087c33842fa5f1596ca86ccabf
wrapdb_version = 2.15.0-1

[provide]
nanobind = nanobind_dep
21 changes: 11 additions & 10 deletions subprojects/robin-map.wrap
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
[wrap-file]
directory = robin-map-1.4.0
source_url = https://github.com/Tessil/robin-map/archive/refs/tags/v1.4.0.tar.gz
source_filename = robin-map-1.4.0.tar.gz
source_hash = 7930dbf9634acfc02686d87f615c0f4f33135948130b8922331c16d90a03250c
patch_filename = robin-map_1.4.0-1_patch.zip
patch_url = https://wrapdb.mesonbuild.com/v2/robin-map_1.4.0-1/get_patch
patch_hash = feb14b6752b7d439fb2f3ee968e595a9a3de00ef8cb029488af2ceb4f504b95d
source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/robin-map_1.4.0-1/robin-map-1.4.0.tar.gz
wrapdb_version = 1.4.0-1
directory = robin-map-1.4.1
source_url = https://github.com/Tessil/robin-map/archive/refs/tags/v1.4.1.tar.gz
source_filename = robin-map-1.4.1.tar.gz
source_hash = 0e3f53a377fdcdc5f9fed7a4c0d4f99e82bbb64175233bd13427fef9a771f4a1
source_fallback_url = https://wrapdb.mesonbuild.com/v2/robin-map_1.4.1-1/get_source/robin-map-1.4.1.tar.gz
patch_filename = robin-map_1.4.1-1_patch.zip
patch_url = https://wrapdb.mesonbuild.com/v2/robin-map_1.4.1-1/get_patch
patch_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/robin-map_1.4.1-1/robin-map_1.4.1-1_patch.zip
patch_hash = 8137d5283004db445d744d603e600119047fed865cbd740f8fec4421dc1a45b6
wrapdb_version = 1.4.1-1

[provide]
robin-map = robin_map_dep
dependency_names = tsl-robin-map
155 changes: 155 additions & 0 deletions tests/test_callback_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Exceptions raised in the user-supplied callbacks (model function and Jacobians).

The callbacks are invoked from Fortran. A C++ exception must never be unwound through
Fortran frames (not guaranteed to work, and fatal without unwind tables), so the extension
stores the exception, aborts the regression through the `istop` flag, and rethrows after
the Fortran routine has returned normally. These tests check the observable consequences.
"""
import traceback

import numpy as np
import pytest

from odrpack import OdrStop, odr_fit

XDATA = np.linspace(0, 2, 20)
YDATA = 1.5*np.exp(0.7*XDATA)
BETA0 = np.array([1., 1.])


def f_ok(x, beta):
return beta[0]*np.exp(beta[1]*x)


def jb_ok(x, beta):
return np.stack([np.exp(beta[1]*x), beta[0]*x*np.exp(beta[1]*x)])[:, None, :]


def jx_ok(x, beta):
return (beta[0]*beta[1]*np.exp(beta[1]*x))[None, None, :]


class Counter:
"""Callable wrapper that counts calls and fails from the `after`-th one on.

The first calls are done by `odr_fit` itself (argument checks), before entering
Fortran, so `after` must be large enough for the failure to happen inside
the iterations.
"""

def __init__(self, fn, exc=None, after=6, value=None):
self.fn, self.exc, self.after, self.value = fn, exc, after, value
self.calls = 0

def __call__(self, x, beta):
self.calls += 1
if self.calls > self.after:
if self.exc is not None:
raise self.exc
return self.value
return self.fn(x, beta)


class CustomError(Exception):
pass


def _fit(f=f_ok, **kwargs):
return odr_fit(f, XDATA, YDATA, BETA0, **kwargs)


def test_exception_type_message_and_traceback_are_preserved():
def failing_model(x, beta):
return model(x, beta)

model = Counter(f_ok, CustomError("custom message"))
with pytest.raises(CustomError, match="custom message") as excinfo:
_fit(failing_model)
tb = "".join(traceback.format_tb(excinfo.value.__traceback__))
assert "failing_model" in tb # the user's frame is in the traceback


@pytest.mark.parametrize("exc", [ValueError("v"), ZeroDivisionError("z"), KeyError("k"),
KeyboardInterrupt(), CustomError("c")])
def test_various_exception_types_propagate(exc):
with pytest.raises(type(exc)):
_fit(Counter(f_ok, exc))


def test_no_python_callback_after_the_first_failure():
"""The optimiser must not keep calling the user's function once it has failed."""
model = Counter(f_ok, CustomError("boom"))
with pytest.raises(CustomError):
_fit(model)
assert model.calls == model.after + 1


def test_exception_in_jacobian_beta():
jb = Counter(jb_ok, CustomError("jb"), after=3)
with pytest.raises(CustomError, match="jb"):
_fit(f_ok, jac_beta=jb, jac_x=jx_ok)
assert jb.calls == jb.after + 1


def test_exception_in_jacobian_x():
jx = Counter(jx_ok, CustomError("jx"), after=3)
with pytest.raises(CustomError, match="jx"):
_fit(f_ok, jac_beta=jb_ok, jac_x=jx)
assert jx.calls == jx.after + 1


def test_wrong_return_type_in_callback_raises_instead_of_crashing():
"""A conversion error (a C++ exception that is not a python_error) in the callback."""
with pytest.raises(Exception) as excinfo:
_fit(Counter(f_ok, value="not an array"))
assert not isinstance(excinfo.value, OdrStop)


def test_files_are_closed_after_an_exception(tmp_path):
"""The Fortran units opened for report/error files must be released.

Reusing the same file name would fail if the previous unit were still open.
"""
rpt, err = str(tmp_path / "x.rpt"), str(tmp_path / "x.err")
with pytest.raises(CustomError):
_fit(Counter(f_ok, CustomError("boom")), report="short", rptfile=rpt, errfile=err)
sol = _fit(report="short", rptfile=rpt, errfile=err)
assert sol.success
(tmp_path / "x.rpt").unlink() # not locked (matters on Windows)


def test_library_is_healthy_after_an_exception():
ref = _fit()
for exc in (CustomError("a"), ValueError("b")):
with pytest.raises(type(exc)):
_fit(Counter(f_ok, exc))
sol = _fit()
assert np.array_equal(sol.beta, ref.beta)
assert sol.info == ref.info and sol.nfev == ref.nfev


def test_odrstop_is_still_graceful(capfd):
model = Counter(f_ok, OdrStop("stop"))
sol = _fit(model)
assert not sol.success
assert sol.info // 10000 == 5


def test_odrstop_then_other_exception_in_same_fit_is_not_swallowed():
"""`OdrStop` first, anything else later: a later, different exception must still propagate
(if the optimiser calls the function again after a graceful stop)."""
state = {"n": 0}

def model(x, beta):
state["n"] += 1
if state["n"] == 7:
raise OdrStop("stop")
if state["n"] > 7:
raise CustomError("later")
return f_ok(x, beta)

try:
sol = _fit(model)
except CustomError:
return
assert not sol.success
Loading
Loading