herculesabqp 0.1.2

A convex box-constrained quadratic programming solver with warm starts and active-set polishing.
Documentation
import math

import numpy as np
import pytest
import scipy.sparse as sp

import herculesabqp


class DiagonalOperator:
    def __init__(self, diag):
        self.diag = np.asarray(diag, dtype=np.float64)
        self.n = int(self.diag.size)

    def matvec(self, x):
        x = np.asarray(x, dtype=np.float64)
        return self.diag * x

    def diagonal(self):
        return self.diag

    def gershgorin_upper_bound(self):
        return float(np.max(self.diag))


class IdentityOperator:
    def __init__(self, n):
        self.n = int(n)

    def matvec(self, x):
        return np.asarray(x, dtype=np.float64)


@pytest.fixture
def explicit_problem():
    q = np.array([[4.0, 1.0], [1.0, 3.0]], dtype=np.float64)
    c = np.array([-1.0, -0.5], dtype=np.float64)
    lb = np.zeros(2, dtype=np.float64)
    ub = np.ones(2, dtype=np.float64)
    return q, c, lb, ub


@pytest.fixture
def sparse_explicit_problem(explicit_problem):
    q, c, lb, ub = explicit_problem
    return sp.csr_matrix(q), c, lb, ub


@pytest.fixture
def implicit_problem():
    op = DiagonalOperator([2.0, 2.0, 2.0])
    c = np.array([-1.0, 0.5, -3.0], dtype=np.float64)
    lb = np.zeros(3, dtype=np.float64)
    ub = np.ones(3, dtype=np.float64)
    return op, c, lb, ub


@pytest.fixture
def prepared_implicit_problem():
    op = DiagonalOperator([4.0, 9.0])
    c = np.array([-2.0, 3.0], dtype=np.float64)
    lb = np.zeros(2, dtype=np.float64)
    ub = np.ones(2, dtype=np.float64)
    return op, c, lb, ub


def test_explicit_dense_solve(explicit_problem):
    q, c, lb, ub = explicit_problem

    result = herculesabqp.solve_box_qp(
        q,
        c,
        lb,
        ub,
        assume_symmetric=True,
        scaling="none",
    )

    assert result["kkt_inf"] < 1e-5
    assert result["gap"] >= 0.0
    assert math.isfinite(result["certified_lower_bound"])
    assert abs(
        result["objective"] - (result["certified_lower_bound"] + result["gap"])
    ) < 1e-8
    assert 0.0 <= result["x"][0] <= 1.0
    assert 0.0 <= result["x"][1] <= 1.0


def test_explicit_prepared_sparse_solve(sparse_explicit_problem):
    q, c, lb, ub = sparse_explicit_problem

    solver = herculesabqp.PreparedSolver(
        q,
        c,
        assume_symmetric=True,
        scaling="none",
    )
    result = solver.solve(lb, ub)

    assert result["kkt_inf"] < 1e-5
    assert len(result["x"]) == 2


def test_implicit_solve(implicit_problem):
    op, c, lb, ub = implicit_problem

    result = herculesabqp.solve_box_qp_implicit(
        op,
        c,
        lb,
        ub,
        assume_symmetric=True,
        scaling="hessian_diag",
    )

    assert result["kkt_inf"] < 1e-5
    assert result["scaling_applied"]
    assert result["scaling_name"] == "hessian_diag"


def test_implicit_falls_back_to_unscaled_without_diagonal():
    op = IdentityOperator(2)
    c = np.array([-0.25, 0.5], dtype=np.float64)
    lb = np.zeros(2, dtype=np.float64)
    ub = np.ones(2, dtype=np.float64)

    result = herculesabqp.solve_box_qp_implicit(
        op,
        c,
        lb,
        ub,
        assume_symmetric=True,
        scaling="hessian_diag",
    )

    assert not result["scaling_applied"]
    assert result["scaling_name"] == "none"


def test_prepared_implicit_solve(prepared_implicit_problem):
    op, c, lb, ub = prepared_implicit_problem

    solver = herculesabqp.PreparedImplicitSolver(
        op,
        c,
        assume_symmetric=True,
        scaling="hessian_diag",
    )
    result = solver.solve(lb, ub)

    assert math.isfinite(result["objective"])
    assert result["scaling_applied"]