Skip to content

Commit 96cb9d9

Browse files
committed
Add Knitro solver support
- Add Knitro detection to available_solvers list - Implement Knitro solver class with MPS/LP file support - Add solver capabilities for Knitro (quadratic, LP names, no solution file) - Add tests for Knitro solver functionality - Map Knitro status codes to linopy Status system
1 parent 3807adb commit 96cb9d9

File tree

3 files changed

+269
-0
lines changed

3 files changed

+269
-0
lines changed

linopy/solver_capabilities.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ def supports(self, feature: SolverFeature) -> bool:
111111
}
112112
),
113113
),
114+
"knitro": SolverInfo(
115+
name="knitro",
116+
display_name="Artelys Knitro",
117+
features=frozenset(
118+
{
119+
SolverFeature.QUADRATIC_OBJECTIVE,
120+
SolverFeature.LP_FILE_NAMES,
121+
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
122+
}
123+
),
124+
),
114125
"scip": SolverInfo(
115126
name="scip",
116127
display_name="SCIP",

linopy/solvers.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@
9696

9797
available_solvers.append("xpress")
9898

99+
with contextlib.suppress(ModuleNotFoundError, ImportError):
100+
import knitro
101+
102+
available_solvers.append("knitro")
103+
99104
# xpress.Namespaces was added in xpress 9.6
100105
try:
101106
from xpress import Namespaces as xpress_Namespaces
@@ -160,6 +165,7 @@ class SolverName(enum.Enum):
160165
Gurobi = "gurobi"
161166
SCIP = "scip"
162167
Xpress = "xpress"
168+
Knitro = "knitro"
163169
Mosek = "mosek"
164170
COPT = "copt"
165171
MindOpt = "mindopt"
@@ -1625,6 +1631,220 @@ def get_solver_solution() -> Solution:
16251631
return Result(status, solution, m)
16261632

16271633

1634+
class Knitro(Solver[None]):
1635+
"""
1636+
Solver subclass for the Knitro solver.
1637+
1638+
Knitro is a powerful nonlinear optimization solver that also handles
1639+
linear and quadratic problems efficiently.
1640+
1641+
For more information on solver options, see
1642+
https://www.artelys.com/app/docs/knitro/3_referenceManual/knitroPythonReference.html
1643+
1644+
Attributes
1645+
----------
1646+
**solver_options
1647+
options for the given solver
1648+
"""
1649+
1650+
def __init__(
1651+
self,
1652+
**solver_options: Any,
1653+
) -> None:
1654+
super().__init__(**solver_options)
1655+
1656+
def solve_problem_from_model(
1657+
self,
1658+
model: Model,
1659+
solution_fn: Path | None = None,
1660+
log_fn: Path | None = None,
1661+
warmstart_fn: Path | None = None,
1662+
basis_fn: Path | None = None,
1663+
env: None = None,
1664+
explicit_coordinate_names: bool = False,
1665+
) -> Result:
1666+
msg = "Direct API not implemented for Knitro"
1667+
raise NotImplementedError(msg)
1668+
1669+
def solve_problem_from_file(
1670+
self,
1671+
problem_fn: Path,
1672+
solution_fn: Path | None = None,
1673+
log_fn: Path | None = None,
1674+
warmstart_fn: Path | None = None,
1675+
basis_fn: Path | None = None,
1676+
env: None = None,
1677+
) -> Result:
1678+
"""
1679+
Solve a linear problem from a problem file using the Knitro solver.
1680+
1681+
This function reads the linear problem file and passes it to the Knitro
1682+
solver. If the solution is successful it returns variable solutions and
1683+
constraint dual values.
1684+
1685+
Parameters
1686+
----------
1687+
problem_fn : Path
1688+
Path to the problem file.
1689+
solution_fn : Path, optional
1690+
Path to the solution file.
1691+
log_fn : Path, optional
1692+
Path to the log file.
1693+
warmstart_fn : Path, optional
1694+
Path to the warmstart file.
1695+
basis_fn : Path, optional
1696+
Path to the basis file.
1697+
env : None, optional
1698+
Environment for the solver
1699+
1700+
Returns
1701+
-------
1702+
Result
1703+
"""
1704+
# Knitro status codes: https://www.artelys.com/app/docs/knitro/3_referenceManual/knitroReturnCodes.html
1705+
CONDITION_MAP = {
1706+
0: "optimal",
1707+
-100: "feasible_point",
1708+
-101: "infeasible",
1709+
-102: "feasible_point",
1710+
-200: "unbounded",
1711+
-201: "infeasible_or_unbounded",
1712+
-202: "iteration_limit",
1713+
-203: "time_limit",
1714+
-204: "function_evaluation_limit",
1715+
-300: "unbounded",
1716+
-400: "iteration_limit",
1717+
-401: "time_limit",
1718+
-410: "mip_node_limit",
1719+
-411: "mip_solution_limit",
1720+
}
1721+
1722+
io_api = read_io_api_from_problem_file(problem_fn)
1723+
sense = read_sense_from_problem_file(problem_fn)
1724+
1725+
try:
1726+
kc = knitro.KN_new()
1727+
except Exception as e:
1728+
msg = f"Failed to create Knitro solver instance: {e}"
1729+
raise RuntimeError(msg)
1730+
1731+
try:
1732+
# Read the problem file
1733+
ret = knitro.KN_load_mps_file(kc, path_to_string(problem_fn))
1734+
if ret != 0:
1735+
msg = f"Failed to load problem file: Knitro error code {ret}"
1736+
raise RuntimeError(msg)
1737+
1738+
# Set log file if specified
1739+
if log_fn is not None:
1740+
knitro.KN_set_param_by_name(kc, "outlev", 6) # Enable detailed output
1741+
knitro.KN_set_param_by_name(kc, "outmode", path_to_string(log_fn))
1742+
1743+
# Set solver options
1744+
for k, v in self.solver_options.items():
1745+
if isinstance(v, int | float):
1746+
knitro.KN_set_param_by_name(kc, k, v)
1747+
elif isinstance(v, str):
1748+
knitro.KN_set_char_param_by_name(kc, k, v)
1749+
1750+
# Load warmstart if provided
1751+
if warmstart_fn is not None:
1752+
try:
1753+
# Knitro doesn't have direct basis loading, but we can set initial values
1754+
logger.info(
1755+
"Warmstart not directly supported by Knitro LP interface"
1756+
)
1757+
except Exception as err:
1758+
logger.info("Warmstart could not be loaded. Error: %s", err)
1759+
1760+
# Solve the problem
1761+
ret = knitro.KN_solve(kc)
1762+
1763+
# Get termination condition
1764+
termination_condition = CONDITION_MAP.get(ret, "unknown")
1765+
status = Status.from_termination_condition(termination_condition)
1766+
status.legacy_status = ret
1767+
1768+
def get_solver_solution() -> Solution:
1769+
# Get objective value
1770+
try:
1771+
obj_ptr = knitro.KN_get_obj_value(kc)
1772+
objective = obj_ptr[0] if obj_ptr[1] == 0 else np.nan
1773+
except Exception:
1774+
objective = np.nan
1775+
1776+
# Get variable values
1777+
try:
1778+
n_vars = knitro.KN_get_number_vars(kc)
1779+
x_ptr = knitro.KN_get_var_primal_values(kc, n_vars)
1780+
if x_ptr[1] == 0:
1781+
# Get variable names
1782+
var_names = []
1783+
for i in range(n_vars):
1784+
name_ptr = knitro.KN_get_var_name(kc, i)
1785+
if name_ptr[1] == 0:
1786+
var_names.append(name_ptr[0])
1787+
else:
1788+
var_names.append(f"x{i}")
1789+
sol = pd.Series(x_ptr[0], index=var_names, dtype=float)
1790+
else:
1791+
sol = pd.Series(dtype=float)
1792+
except Exception as e:
1793+
logger.warning(f"Could not extract primal solution: {e}")
1794+
sol = pd.Series(dtype=float)
1795+
1796+
# Get dual values (constraint multipliers)
1797+
try:
1798+
n_cons = knitro.KN_get_number_cons(kc)
1799+
if n_cons > 0:
1800+
dual_ptr = knitro.KN_get_con_dual_values(kc, n_cons)
1801+
if dual_ptr[1] == 0:
1802+
# Get constraint names
1803+
con_names = []
1804+
for i in range(n_cons):
1805+
name_ptr = knitro.KN_get_con_name(kc, i)
1806+
if name_ptr[1] == 0:
1807+
con_names.append(name_ptr[0])
1808+
else:
1809+
con_names.append(f"c{i}")
1810+
dual = pd.Series(dual_ptr[0], index=con_names, dtype=float)
1811+
else:
1812+
dual = pd.Series(dtype=float)
1813+
else:
1814+
dual = pd.Series(dtype=float)
1815+
except Exception as e:
1816+
logger.warning(f"Could not extract dual solution: {e}")
1817+
dual = pd.Series(dtype=float)
1818+
1819+
return Solution(sol, dual, objective)
1820+
1821+
solution = self.safe_get_solution(status=status, func=get_solver_solution)
1822+
solution = maybe_adjust_objective_sign(solution, io_api, sense)
1823+
1824+
# Save basis if requested
1825+
if basis_fn is not None:
1826+
try:
1827+
# Knitro doesn't have direct basis export for LP files
1828+
logger.info(
1829+
"Basis export not directly supported by Knitro LP interface"
1830+
)
1831+
except Exception as err:
1832+
logger.info("No basis stored. Error: %s", err)
1833+
1834+
# Save solution if requested
1835+
if solution_fn is not None:
1836+
try:
1837+
knitro.KN_write_mps_file(kc, path_to_string(solution_fn))
1838+
except Exception as err:
1839+
logger.info("Could not write solution file. Error: %s", err)
1840+
1841+
return Result(status, solution, kc)
1842+
1843+
finally:
1844+
# Clean up Knitro context
1845+
knitro.KN_free(kc)
1846+
1847+
16281848
mosek_bas_re = re.compile(r" (XL|XU)\s+([^ \t]+)\s+([^ \t]+)| (LL|UL|BS)\s+([^ \t]+)")
16291849

16301850

test/test_solvers.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,44 @@ def test_free_mps_solution_parsing(solver: str, tmp_path: Path) -> None:
6767
assert result.solution.objective == 30.0
6868

6969

70+
@pytest.mark.skipif(
71+
"knitro" not in set(solvers.available_solvers), reason="Knitro is not installed"
72+
)
73+
def test_knitro_solver(tmp_path: Path) -> None:
74+
"""Test Knitro solver with a simple MPS problem."""
75+
knitro = solvers.Knitro()
76+
77+
mps_file = tmp_path / "problem.mps"
78+
mps_file.write_text(free_mps_problem)
79+
sol_file = tmp_path / "solution.sol"
80+
81+
result = knitro.solve_problem(problem_fn=mps_file, solution_fn=sol_file)
82+
83+
assert result.status.is_ok
84+
assert result.solution.objective == 30.0
85+
86+
87+
@pytest.mark.skipif(
88+
"knitro" not in set(solvers.available_solvers), reason="Knitro is not installed"
89+
)
90+
def test_knitro_solver_with_options(tmp_path: Path) -> None:
91+
"""Test Knitro solver with custom options."""
92+
# Set some common Knitro options
93+
knitro = solvers.Knitro(maxit=100, feastol=1e-6)
94+
95+
mps_file = tmp_path / "problem.mps"
96+
mps_file.write_text(free_mps_problem)
97+
sol_file = tmp_path / "solution.sol"
98+
log_file = tmp_path / "knitro.log"
99+
100+
result = knitro.solve_problem(
101+
problem_fn=mps_file, solution_fn=sol_file, log_fn=log_file
102+
)
103+
104+
assert result.status.is_ok
105+
assert log_file.exists()
106+
107+
70108
@pytest.mark.skipif(
71109
"gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed"
72110
)

0 commit comments

Comments
 (0)