Skip to content

Commit 0db52e5

Browse files
committed
package
1 parent 907118e commit 0db52e5

6 files changed

Lines changed: 352 additions & 7 deletions

File tree

.github/workflows/continuous.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
test:
13+
runs-on: ${{ matrix.os }}
14+
name: ${{ matrix.os }} (py${{ matrix.python-version }})
15+
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
os: [ubuntu-latest, macos-latest, windows-latest]
20+
python-version: ["3.9", "3.11", "3.14"]
21+
22+
steps:
23+
- uses: actions/checkout@v6
24+
25+
- uses: actions/setup-python@v6
26+
with:
27+
python-version: ${{ matrix.python-version }}
28+
29+
- name: Install build tooling
30+
run: |
31+
python -m pip install --upgrade pip
32+
python -m pip install build
33+
34+
- name: Build wheel to prove binary build works
35+
run: |
36+
python -m build --wheel --outdir dist
37+
38+
- name: Install wheel with test extras
39+
run: |
40+
python -c "import pathlib, subprocess, sys; wheel = next(pathlib.Path('dist').glob('*.whl')); subprocess.check_call([sys.executable, '-m', 'pip', 'install', f'{wheel}[test]'])"
41+
42+
- name: Run test suite
43+
run: |
44+
python -m pytest -q

.github/workflows/release.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: Build & publish
2+
3+
on:
4+
push:
5+
tags: ["v[0-9]+.[0-9]+.[0-9]+*"]
6+
7+
permissions:
8+
contents: read
9+
id-token: write
10+
11+
jobs:
12+
build-wheels:
13+
runs-on: ${{ matrix.os }}
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
os: [ ubuntu-22.04, macos-14, windows-2022 ]
18+
19+
steps:
20+
- uses: actions/checkout@v6
21+
with:
22+
fetch-depth: 0
23+
24+
- name: Set up QEMU for ARM64 emulation
25+
if: runner.os == 'Linux'
26+
uses: docker/setup-qemu-action@v4
27+
with:
28+
platforms: linux/arm64
29+
30+
- name: Build wheels with cibuildwheel
31+
uses: pypa/cibuildwheel@v3.4.1
32+
33+
- name: Upload wheel artifacts
34+
uses: actions/upload-artifact@v7
35+
with:
36+
name: wheels-${{ matrix.os }}
37+
path: wheelhouse/*
38+
if-no-files-found: error
39+
40+
sdist:
41+
needs: build-wheels
42+
runs-on: ubuntu-latest
43+
44+
steps:
45+
- uses: actions/checkout@v6
46+
- uses: actions/setup-python@v6
47+
with:
48+
python-version: '3.11'
49+
50+
- run: python -m pip install --upgrade pip build
51+
- run: python -m build --sdist
52+
53+
- uses: actions/upload-artifact@v7
54+
with:
55+
name: sdist
56+
path: dist/*.tar.gz
57+
58+
publish:
59+
needs: [ build-wheels, sdist ]
60+
runs-on: ubuntu-latest
61+
62+
steps:
63+
- uses: actions/download-artifact@v8
64+
with:
65+
pattern: wheels-*
66+
path: dist
67+
merge-multiple: true
68+
69+
- uses: actions/download-artifact@v8
70+
with:
71+
name: sdist
72+
path: dist
73+
74+
- name: Publish to PyPI
75+
uses: pypa/gh-action-pypi-publish@release/v1
76+
with:
77+
packages_dir: dist

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
build
2+
_skbuild
3+
__pycache__/
4+
.pytest_cache/

pyproject.toml

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,68 @@
11
[build-system]
22
requires = [
3-
"scikit-build-core>=0.8.0"
3+
"scikit-build-core>=0.8.0",
44
]
55
build-backend = "scikit_build_core.build"
66

77
[project]
88
name = "polysolve"
9-
version = "v0.0.1"
10-
description = "A Python binding for PolySolve"
9+
version = "0.0.1"
10+
description = "Python bindings for PolySolve nonlinear optimization and linear solvers"
1111
authors = [
1212
{ name = "Teseo Schneider", email = "teseo@uvic.ca" }
1313
]
1414
license = { text = "MIT" }
1515
readme = { file = "README.md", content-type = "text/markdown" }
16-
requires-python = ">=3.8"
17-
keywords = ["Optimization", "Newton", "Python Bindings"]
16+
requires-python = ">=3.9"
17+
keywords = ["Optimization", "Newton", "Linear Solvers", "Python Bindings"]
1818
classifiers = [
19+
"Development Status :: 3 - Alpha",
20+
"Intended Audience :: Science/Research",
1921
"License :: OSI Approved :: MIT License",
2022
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
2123
"Programming Language :: C++",
2224
"Programming Language :: Python :: 3",
23-
"Programming Language :: Python :: 3.8",
25+
"Programming Language :: Python :: 3.9",
26+
"Programming Language :: Python :: 3.10",
27+
"Programming Language :: Python :: 3.11",
28+
"Programming Language :: Python :: 3.12",
29+
"Programming Language :: Python :: 3.13",
30+
"Programming Language :: Python :: 3.14",
2431
"Operating System :: OS Independent"
2532
]
2633
dependencies = [
2734
"numpy",
2835
"scipy",
2936
]
3037

38+
[project.optional-dependencies]
39+
test = [
40+
"pytest>=8",
41+
]
42+
43+
[project.urls]
44+
Homepage = "https://github.com/polyfem/polysolve-python"
45+
Repository = "https://github.com/polyfem/polysolve-python"
46+
Issues = "https://github.com/polyfem/polysolve-python/issues"
47+
3148
# ---------- scikit-build-core ----------
3249
[tool.scikit-build]
3350
cmake.version = ">=3.18"
34-
build-dir = "build/{wheel_tag}"
51+
build-dir = "_skbuild/{wheel_tag}"
3552
cmake.args = ["-DPYBIND11_FINDPYTHON=ON", "-DCMAKE_BUILD_TYPE=Release", "-DPOLYSOLVE_WITH_AMGCL=OFF"]
3653
wheel.exclude = [
3754
"**/.git/**/*" # no embedded Git repos
3855
]
56+
57+
[tool.pytest.ini_options]
58+
testpaths = ["tests"]
59+
addopts = "-ra"
60+
61+
[tool.cibuildwheel]
62+
build = "cp39-* cp310-* cp311-* cp312-* cp313-* cp314-*"
63+
skip = "pp* *-musllinux*"
64+
test-extras = ["test"]
65+
test-command = "pytest {project}/tests -q"
66+
67+
[tool.cibuildwheel.linux]
68+
archs = ["x86_64", "aarch64"]

tests/test_linear.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import numpy as np
2+
import pytest
3+
import scipy.sparse
4+
5+
import polysolve
6+
7+
8+
SPARSE_SOLVER = {"solver": "Eigen::SimplicialLDLT"}
9+
DENSE_SOLVER = {"solver": "Eigen::PartialPivLU"}
10+
11+
12+
def sparse_system():
13+
A = scipy.sparse.csc_matrix(
14+
[
15+
[4.0, 1.0, 0.0],
16+
[1.0, 3.0, 1.0],
17+
[0.0, 1.0, 2.0],
18+
]
19+
)
20+
b = np.array([1.0, 2.0, 3.0])
21+
return A, b
22+
23+
24+
def assert_solution(A, x, b):
25+
np.testing.assert_allclose(A @ x, b, atol=1e-10, rtol=1e-10)
26+
27+
28+
def test_solve_sparse_system_one_shot():
29+
A, b = sparse_system()
30+
31+
x = polysolve.solve(
32+
A,
33+
b,
34+
SPARSE_SOLVER,
35+
log_level=polysolve.LogLevel.off,
36+
)
37+
38+
assert_solution(A, x, b)
39+
40+
41+
def test_linear_solver_reuses_factorization_for_multiple_rhs():
42+
A, b = sparse_system()
43+
solver = polysolve.LinearSolver(SPARSE_SOLVER, log_level=polysolve.LogLevel.off)
44+
45+
solver.analyse_pattern(A)
46+
with pytest.raises(RuntimeError, match="factorize"):
47+
solver.solve(b)
48+
49+
solver.factorise(A)
50+
assert solver.name == "Eigen::SimplicialLDLT"
51+
assert not solver.is_dense
52+
53+
assert_solution(A, solver.solve(b), b)
54+
55+
b2 = np.array([2.0, -1.0, 0.5])
56+
assert_solution(A, solver.solve(b2), b2)
57+
assert solver.info()["solver_info"] == "Success"
58+
59+
60+
def test_solve_dense_system_one_shot():
61+
A = np.array(
62+
[
63+
[4.0, 1.0, 0.0],
64+
[1.0, 3.0, 1.0],
65+
[0.0, 1.0, 2.0],
66+
]
67+
)
68+
b = np.array([1.0, 2.0, 3.0])
69+
70+
x = polysolve.solve(
71+
A,
72+
b,
73+
DENSE_SOLVER,
74+
log_level=polysolve.LogLevel.off,
75+
)
76+
77+
assert_solution(A, x, b)
78+
79+
80+
def test_dense_linear_solver_factorize_alias():
81+
A = np.array([[4.0, 1.0], [1.0, 3.0]])
82+
b = np.array([1.0, 2.0])
83+
solver = polysolve.LinearSolver(DENSE_SOLVER, log_level=polysolve.LogLevel.off)
84+
85+
solver.factorize(A)
86+
87+
assert solver.is_dense
88+
assert_solution(A, solver.solve(b), b)
89+
90+
91+
def test_linear_solver_rejects_invalid_shapes():
92+
with pytest.raises(RuntimeError, match="square"):
93+
polysolve.solve(
94+
np.ones((2, 3)),
95+
np.ones(2),
96+
DENSE_SOLVER,
97+
log_level=polysolve.LogLevel.off,
98+
)
99+
100+
solver = polysolve.LinearSolver(DENSE_SOLVER, log_level=polysolve.LogLevel.off)
101+
solver.factorize(np.eye(2))
102+
103+
with pytest.raises(RuntimeError, match="right-hand side"):
104+
solver.solve(np.ones(3))
105+
106+
with pytest.raises(RuntimeError, match="initial guess"):
107+
solver.solve(np.ones(2), x0=np.ones(3))
108+
109+
110+
def test_linear_solver_introspection_helpers():
111+
available = polysolve.available_linear_solvers()
112+
113+
assert "Eigen::SimplicialLDLT" in available
114+
assert polysolve.default_linear_solver() in available
115+
assert polysolve.LinearSolver.available_solvers() == available
116+
assert polysolve.LinearSolver.default_solver() == polysolve.default_linear_solver()
117+
assert isinstance(polysolve.available_linear_preconditioners(), list)
118+
assert isinstance(polysolve.default_linear_preconditioner(), str)

tests/test_nonlinear.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import numpy as np
2+
import pytest
3+
import scipy.sparse
4+
5+
import polysolve
6+
7+
8+
TARGET = np.array([-2.0, 3.0, 1.0])
9+
NONLINEAR_SOLVER = {
10+
"solver": "Newton",
11+
"line_search": {"method": "Backtracking"},
12+
"max_iterations": 25,
13+
}
14+
LINEAR_SOLVER = {"solver": "Eigen::SimplicialLDLT"}
15+
16+
17+
class Quadratic(polysolve.Problem):
18+
def __init__(self, *, dense_hessian=False):
19+
super().__init__()
20+
self.dense_hessian = dense_hessian
21+
self.steps = []
22+
23+
def value(self, x):
24+
y = x - TARGET
25+
return float(y @ y)
26+
27+
def gradient(self, x):
28+
return 2.0 * (x - TARGET)
29+
30+
def hessian(self, x):
31+
if self.dense_hessian:
32+
return 2.0 * np.eye(x.size)
33+
return 2.0 * scipy.sparse.eye(x.size, format="csc")
34+
35+
def post_step(self, iter_num, solver_info, x, grad):
36+
self.steps.append((iter_num, solver_info, x.copy(), grad.copy()))
37+
38+
39+
def minimize(problem):
40+
return polysolve.minimize(
41+
problem,
42+
np.zeros(3),
43+
NONLINEAR_SOLVER,
44+
LINEAR_SOLVER,
45+
log_level=polysolve.LogLevel.off,
46+
)
47+
48+
49+
def test_minimize_sparse_hessian_quadratic():
50+
problem = Quadratic()
51+
52+
x, info = minimize(problem)
53+
54+
np.testing.assert_allclose(x, TARGET, atol=1e-10, rtol=1e-10)
55+
assert info["energy"] == pytest.approx(0.0, abs=1e-12)
56+
assert info["iterations"] <= 2
57+
assert problem.steps
58+
59+
60+
def test_minimize_accepts_dense_hessian_return_value():
61+
x, info = minimize(Quadratic(dense_hessian=True))
62+
63+
np.testing.assert_allclose(x, TARGET, atol=1e-10, rtol=1e-10)
64+
assert info["energy"] == pytest.approx(0.0, abs=1e-12)
65+
66+
67+
def test_minimize_validates_gradient_shape():
68+
class BadGradient(Quadratic):
69+
def gradient(self, x):
70+
return np.zeros(x.size - 1)
71+
72+
with pytest.raises(RuntimeError, match="gradient returned vector"):
73+
minimize(BadGradient())

0 commit comments

Comments
 (0)