Skip to content
Closed
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
9 changes: 7 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,16 @@ jobs:
run: echo "$(pwd)/.pixi/envs/default/bin" >> $GITHUB_PATH
shell: bash
- name: Install dependencies
run: pixi add --platform linux-64 scipy numpy pip pytest meson meson-python openblas python==${{ matrix.python-version }}
run: pixi add --platform linux-64 scipy numpy pip pytest pytest-cov meson meson-python openblas python==${{ matrix.python-version }}
- name: Test
# This job is the coverage gate for the Python layer. The threshold and
# the source resolution both live in pyproject.toml ([tool.coverage.*]),
# so `--cov` takes no inline flags: `source_pkgs` is what makes coverage
# follow the import to the installed package instead of measuring the
# repo-root `scs/` directory, which has no __init__.py and reports 0%.
run: |
pixi r python -m pip install -v . --no-build-isolation -Csetup-args=-Duse_openmp=true
pixi r pytest
pixi r pytest --cov
rm -rf build/

build_mkl:
Expand Down
47 changes: 47 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: quality

on:
pull_request:
push:
branches:
- master

jobs:
quality:
# Static checks only — no submodules, no compilation, no pixi, so this
# reports in well under a minute and is the fastest signal on a PR.
#
# Every rule set and threshold lives in pyproject.toml, so a contributor
# running these locally gets the same verdict as CI. The tool versions are
# pinned for the same reason: ruff in particular enables new rules by
# default in each release, which would otherwise turn an unrelated ruff
# release into a red build here.
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: "3.12"

- name: Lint (ruff)
run: uvx ruff@0.16.6 check scs/py test

- name: Type check (mypy)
# numpy ships its own stubs, so installing it means arrays are really
# checked rather than silently degraded to Any.
run: uvx --with numpy --with scipy mypy@2.3.1

- name: Docstring coverage (interrogate)
run: uvx interrogate@1.7.0 -v scs/py

- name: Dependency hygiene (deptry)
run: uvx deptry@0.25.1 scs/py

- name: Resolve the declared dependency closure
run: uv pip compile pyproject.toml --python-version 3.12 -o /tmp/audit-requirements.txt

- name: Audit dependencies (pip-audit)
run: uvx pip-audit@2.10.1 -r /tmp/audit-requirements.txt
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,9 @@ out
# pixi environments
.pixi
*.egg-info

# coverage
.coverage
.coverage.*
htmlcov/
coverage.xml
54 changes: 54 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,57 @@ markers = [
# pytest-run-parallel: functions that are not thread-safe
# (capsys/capfd are detected automatically by pytest-run-parallel)
thread_unsafe_functions = ["numpy.random.seed"]

# `--cov=scs` measures nothing when pytest runs from the repo root: the source
# directory `scs/` has no `__init__.py`, so coverage resolves the name to that
# namespace-package directory rather than to the installed `scs` package, and
# reports 0% with "No data was collected". `source_pkgs` states that `scs` is an
# importable package name, so coverage follows the import to site-packages.
[tool.coverage.run]
source_pkgs = ["scs"]

[tool.coverage.report]
show_missing = true
fail_under = 100

[tool.ruff]
extend-exclude = ["scs_source", "scs/pythoncapi-compat", "legacy_setup.py"]

[tool.ruff.lint]
# Pyflakes, the error subset of pycodestyle, and import sorting. Deliberately
# not ruff's rolling default set: that set grows with each release, so a ruff
# upgrade would turn into a CI failure unrelated to any change here.
select = ["E4", "E7", "E9", "F", "I"]
# `l` is the SCS cone key for the non-negative cone. It is the domain's name
# for that quantity, not a careless one-letter variable.
ignore = ["E741"]

[tool.ruff.lint.per-file-ignores]
# The backend test modules import an extension solely to discover whether this
# build has it, inside a try/except that skips the module when it does not.
# The unused import *is* the test.
# I001 too: these modules' import order is left as the maintainers wrote it.
# `_scs_mkl` sets the MKL interface layer at import time, so import order
# around numpy/scipy carries meaning here that a sorter cannot see.
"test/*" = ["F401", "I001"]

[tool.mypy]
files = ["scs/py"]
warn_redundant_casts = true
warn_unused_ignores = true
no_implicit_optional = true

[[tool.mypy.overrides]]
# scipy ships no inline types and its stub package is a separate, partial
# project; numpy's own stubs are used, so arrays are still checked.
module = ["scipy.*"]
ignore_missing_imports = true

[tool.interrogate]
fail-under = 100

[tool.deptry]
# `scs` is this project's own package: `scs/py/__init__.py` imports its
# compiled siblings via `from scs import _scs_direct`, which is not a
# third-party dependency.
known_first_party = ["scs"]
151 changes: 137 additions & 14 deletions scs/py/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
#!/usr/bin/env python
"""Python interface to SCS, the splitting conic solver.

Exposes the `SCS` class, the legacy `solve` helper, and the `LinearSolver`
enum naming the linear-system backends a given build can dispatch to.
"""

import enum
import sys
import warnings
from types import ModuleType
from typing import Any, Optional

import numpy as np
from scipy import sparse
from scs import _scs_direct
import warnings

# The `_scs_*` extensions are produced by the meson build, so they do not
# exist in the source tree for a static checker to resolve.
from scs import _scs_direct # type: ignore[attr-defined]

__version__ = _scs_direct.version()
__sizeof_int__ = _scs_direct.sizeof_int()
Expand All @@ -26,7 +37,20 @@


class LinearSolver(enum.Enum):
"""Linear system solver backend for SCS."""
"""Linear system solver backend for SCS.

Pass a member (or its string value) as the `linear_solver` setting. Which
members are importable depends on the build; `AUTO` picks the best one
available on the platform.

Example:

>>> import scs
>>> scs.LinearSolver.QDLDL.value
'qdldl'
>>> scs.LinearSolver("cpu_indirect") is scs.LinearSolver.CPU_INDIRECT
True
"""
AUTO = "auto"
QDLDL = "qdldl"
CPU_INDIRECT = "cpu_indirect"
Expand All @@ -37,12 +61,13 @@ class LinearSolver(enum.Enum):
CUDSS = "cudss"


def _load_module(name):
def _load_module(name: str) -> ModuleType:
"""Import one of the sibling `_scs_*` C extension modules by name."""
from importlib import import_module
return import_module(f"scs.{name}")


def _resolve_auto():
def _resolve_auto() -> ModuleType:
"""Auto-detect the best available direct solver for this platform."""
if sys.platform == "darwin":
# Prefer the bundled QDLDL on macOS over Apple Accelerate.
Expand All @@ -66,15 +91,15 @@ def _resolve_auto():
}


def _select_scs_module(stgs):
def _select_scs_module(stgs: dict) -> ModuleType:
"""Choose which SCS C extension to import based on settings."""
linear_solver = stgs.pop("linear_solver", LinearSolver.AUTO)
if isinstance(linear_solver, str):
linear_solver = LinearSolver(linear_solver)
return _SOLVER_DISPATCH[linear_solver]()


def _has_lower_tri(P):
def _has_lower_tri(P: Any) -> bool:
"""Fast check for strictly lower triangular entries in a sorted CSC matrix."""
nnz_per_col = np.diff(P.indptr)
nonempty = nnz_per_col > 0
Expand All @@ -85,8 +110,28 @@ def _has_lower_tri(P):


class SCS(object):

def __init__(self, data, cone, **settings):
"""A solver instance bound to one problem's data and cone.

Construct it once, then call `solve` as often as needed; `update` swaps in
a new `b` or `c` without rebuilding the workspace.

Example:

>>> import numpy as np
>>> import scipy.sparse as sp
>>> import scs
>>> # maximize x subject to 0 <= x <= 1
>>> data = {
... "A": sp.csc_matrix(np.array([[1.0], [-1.0]])),
... "b": np.array([1.0, 0.0]),
... "c": np.array([-1.0]),
... }
>>> solver = scs.SCS(data, {"l": 2}, verbose=False)
>>> solver.solve()["info"]["status"]
'solved'
"""

def __init__(self, data: dict, cone: dict, **settings: Any) -> None:
"""Initialize the SCS solver.

@param data Dictionary containing keys `P`, `A`, `b`, `c`.
Expand Down Expand Up @@ -183,7 +228,13 @@ def __init__(self, data, cone, **settings):
**self._settings,
)

def solve(self, warm_start=True, x=None, y=None, s=None):
def solve(
self,
warm_start: bool = True,
x: Optional[np.ndarray] = None,
y: Optional[np.ndarray] = None,
s: Optional[np.ndarray] = None,
) -> dict:
"""Solve the optimization problem.

@param warm_start Whether to warm-start. By default the solution of
Expand All @@ -199,23 +250,95 @@ def solve(self, warm_start=True, x=None, y=None, s=None):
's' - primal slack solution
'y' - dual solution
'info' - information dictionary (see docs)

Example:

>>> import numpy as np
>>> import scipy.sparse as sp
>>> import scs
>>> # maximize x subject to 0 <= x <= 1
>>> data = {
... "A": sp.csc_matrix(np.array([[1.0], [-1.0]])),
... "b": np.array([1.0, 0.0]),
... "c": np.array([-1.0]),
... }
>>> solver = scs.SCS(data, {"l": 2}, eps_abs=1e-9, eps_rel=1e-9,
... verbose=False)
>>> sol = solver.solve()
>>> sorted(sol)
['info', 's', 'x', 'y']
>>> sol["info"]["status"]
'solved'
>>> float(np.round(sol["x"][0], 6))
1.0
"""
return self._solver.solve(warm_start, x, y, s)

def update(self, b=None, c=None):
def update(
self,
b: Optional[np.ndarray] = None,
c: Optional[np.ndarray] = None,
) -> None:
"""Update the `b` vector, `c` vector, or both, before another solve.

After a solve we can reuse the SCS workspace in another solve if the
only problem data that has changed are the `b` and `c` vectors.

@param b New `b` vector.
@param c New `c` vector.

Example:

>>> import numpy as np
>>> import scipy.sparse as sp
>>> import scs
>>> data = {
... "A": sp.csc_matrix(np.array([[1.0], [-1.0]])),
... "b": np.array([1.0, 0.0]),
... "c": np.array([-1.0]),
... }
>>> solver = scs.SCS(data, {"l": 2}, eps_abs=1e-9, eps_rel=1e-9,
... verbose=False)
>>> float(np.round(solver.solve()["x"][0], 6)) # maximize x
1.0
>>> solver.update(c=np.array([1.0])) # now minimize x
>>> float(np.round(abs(solver.solve()["x"][0]), 6))
0.0
"""
self._solver.update(b, c)


# Backwards compatible helper function that simply calls the main API.
def solve(data, cone, **settings):
def solve(data: dict, cone: dict, **settings: Any) -> dict:
"""Solve a problem in one call, without holding on to a solver instance.

Backwards compatible helper that simply calls the main API. Warm-start
vectors are read out of `data` under the keys `x`, `y` and `s`, which is
how the pre-`SCS`-class API passed them.

@param data Dictionary containing keys `A`, `b`, `c`, optionally `P`,
and optionally the warm-start vectors `x`, `y`, `s`.
@param cone Dictionary containing cone information.
@param settings Settings as kwargs, see docs.

@return the same dictionary `SCS.solve` returns.

Example:

>>> import numpy as np
>>> import scipy.sparse as sp
>>> import scs
>>> data = {
... "A": sp.csc_matrix(np.array([[1.0], [-1.0]])),
... "b": np.array([1.0, 0.0]),
... "c": np.array([-1.0]),
... }
>>> sol = scs.solve(data, {"l": 2}, eps_abs=1e-9, eps_rel=1e-9,
... verbose=False)
>>> sol["info"]["status"]
'solved'
>>> float(np.round(sol["x"][0], 6))
1.0
"""
solver = SCS(data, cone, **settings)

# Hack out the warm start data from old API
Expand Down
4 changes: 2 additions & 2 deletions test/solve_random_cone_prob.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def solve_infeasible(linear_solver, rng):
m = tools.get_scs_cone_dims(K)
data = tools.gen_infeasible(K, n=m // 3, rng=rng)
params = {"normalize": True, "scale": 0.5}
sol = scs.solve(data, K, linear_solver=linear_solver, **params)
scs.solve(data, K, linear_solver=linear_solver, **params)


def solve_unbounded(linear_solver, rng):
Expand All @@ -76,7 +76,7 @@ def solve_unbounded(linear_solver, rng):
m = tools.get_scs_cone_dims(K)
data = tools.gen_unbounded(K, n=m // 3, rng=rng)
params = {"normalize": True, "scale": 0.5}
sol = scs.solve(data, K, linear_solver=linear_solver, **params)
scs.solve(data, K, linear_solver=linear_solver, **params)


if __name__ == "__main__":
Expand Down
Loading
Loading