solverpilot 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solverpilot/__init__.py +250 -0
- solverpilot/_immutability.py +27 -0
- solverpilot/_version.py +3 -0
- solverpilot/applications/__init__.py +3 -0
- solverpilot/applications/routing/__init__.py +19 -0
- solverpilot/applications/routing/compile_milp.py +321 -0
- solverpilot/applications/routing/diagnostics.py +78 -0
- solverpilot/applications/routing/errors.py +14 -0
- solverpilot/applications/routing/heuristics.py +167 -0
- solverpilot/applications/routing/model.py +259 -0
- solverpilot/applications/routing/reference.py +81 -0
- solverpilot/applications/routing/solution.py +89 -0
- solverpilot/applications/routing/timeline.py +79 -0
- solverpilot/applications/routing/validation.py +169 -0
- solverpilot/applications/tsp/__init__.py +41 -0
- solverpilot/applications/tsp/compile_cp.py +121 -0
- solverpilot/applications/tsp/compile_milp.py +139 -0
- solverpilot/applications/tsp/distance.py +60 -0
- solverpilot/applications/tsp/errors.py +17 -0
- solverpilot/applications/tsp/heuristics.py +112 -0
- solverpilot/applications/tsp/model.py +175 -0
- solverpilot/applications/tsp/reference.py +121 -0
- solverpilot/applications/tsp/solution.py +65 -0
- solverpilot/applications/tsp/validation.py +92 -0
- solverpilot/backends/__init__.py +51 -0
- solverpilot/backends/_casadi_highs_worker.py +92 -0
- solverpilot/backends/base.py +28 -0
- solverpilot/backends/bundled_capi.py +910 -0
- solverpilot/backends/casadi_conic.py +303 -0
- solverpilot/backends/health.py +240 -0
- solverpilot/backends/highspy_native.py +217 -0
- solverpilot/backends/nlopt_native.py +237 -0
- solverpilot/backends/osqp_native.py +297 -0
- solverpilot/backends/protocol_v2.py +119 -0
- solverpilot/backends/pyscipopt_native.py +200 -0
- solverpilot/backends/registry.py +38 -0
- solverpilot/backends/scipy_highs.py +138 -0
- solverpilot/backends/scipy_highs_lp.py +195 -0
- solverpilot/backends/scipy_slsqp_qp.py +133 -0
- solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
- solverpilot/benchmark/__init__.py +23 -0
- solverpilot/benchmark/acquire.py +199 -0
- solverpilot/benchmark/baselines.py +70 -0
- solverpilot/benchmark/bundle.py +37 -0
- solverpilot/benchmark/campaign.py +150 -0
- solverpilot/benchmark/environment.py +115 -0
- solverpilot/benchmark/integrity.py +46 -0
- solverpilot/benchmark/model.py +146 -0
- solverpilot/benchmark/policy.py +182 -0
- solverpilot/benchmark/registry.py +38 -0
- solverpilot/benchmark/report.py +44 -0
- solverpilot/benchmark/runner.py +516 -0
- solverpilot/benchmark/slurm.py +55 -0
- solverpilot/benchmark/specs.py +75 -0
- solverpilot/benchmark/splits.py +181 -0
- solverpilot/benchmark/summary.py +384 -0
- solverpilot/benchmark/verify.py +142 -0
- solverpilot/benchmark/worker.py +71 -0
- solverpilot/bridges/__init__.py +19 -0
- solverpilot/bridges/engine.py +44 -0
- solverpilot/bridges/indicator.py +232 -0
- solverpilot/bridges/registry.py +78 -0
- solverpilot/bridges/transformation-tape.schema.json +53 -0
- solverpilot/bridges/types.py +149 -0
- solverpilot/bridges/validation.py +110 -0
- solverpilot/capabilities/__init__.py +32 -0
- solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
- solverpilot/capabilities/conformance.py +151 -0
- solverpilot/capabilities/enums.py +27 -0
- solverpilot/capabilities/manifest.py +19 -0
- solverpilot/capabilities/ontology.py +73 -0
- solverpilot/capabilities/requirements.py +37 -0
- solverpilot/capabilities/resolve.py +30 -0
- solverpilot/capabilities/v2.py +711 -0
- solverpilot/cli/__init__.py +1 -0
- solverpilot/cli/backend_health.py +77 -0
- solverpilot/cli/benchmark.py +331 -0
- solverpilot/cli/benchmark_worker.py +33 -0
- solverpilot/cli/capabilities.py +65 -0
- solverpilot/conic/__init__.py +11 -0
- solverpilot/conic/backend.py +263 -0
- solverpilot/conic/compiler.py +310 -0
- solverpilot/conic/conformance.py +60 -0
- solverpilot/conic/conic-ir-v1.schema.json +24 -0
- solverpilot/conic/ir.py +299 -0
- solverpilot/conic/validation.py +149 -0
- solverpilot/cp/__init__.py +7 -0
- solverpilot/cp/conformance.py +32 -0
- solverpilot/cp/cp-ir-v1.schema.json +13 -0
- solverpilot/cp/ir.py +170 -0
- solverpilot/cp/model.py +143 -0
- solverpilot/cp/ortools_backend.py +423 -0
- solverpilot/cp/ortools_worker.py +75 -0
- solverpilot/cp/reference.py +51 -0
- solverpilot/cp/serialization.py +117 -0
- solverpilot/cp/validate.py +83 -0
- solverpilot/diagnose/__init__.py +33 -0
- solverpilot/diagnose/infeasibility.py +463 -0
- solverpilot/diagnose/model.py +87 -0
- solverpilot/evaluation/__init__.py +53 -0
- solverpilot/evaluation/metrics.py +150 -0
- solverpilot/evaluation/oracle.py +288 -0
- solverpilot/evaluation/proof.py +53 -0
- solverpilot/evaluation/references.py +73 -0
- solverpilot/evaluation/regret.py +281 -0
- solverpilot/exceptions.py +34 -0
- solverpilot/experimental/__init__.py +32 -0
- solverpilot/extensions/__init__.py +56 -0
- solverpilot/extensions/entrypoints.py +34 -0
- solverpilot/extensions/errors.py +35 -0
- solverpilot/extensions/manager.py +651 -0
- solverpilot/extensions/manifest.py +232 -0
- solverpilot/extensions/model.py +101 -0
- solverpilot/extensions/versioning.py +136 -0
- solverpilot/history/__init__.py +22 -0
- solverpilot/history/errors.py +15 -0
- solverpilot/history/model.py +129 -0
- solverpilot/history/sqlite.py +651 -0
- solverpilot/inspect/__init__.py +3 -0
- solverpilot/inspect/fingerprint.py +199 -0
- solverpilot/intelligence/__init__.py +36 -0
- solverpilot/intelligence/errors.py +16 -0
- solverpilot/intelligence/fingerprint.py +108 -0
- solverpilot/intelligence/leakage.py +69 -0
- solverpilot/intelligence/schema.py +314 -0
- solverpilot/intelligence/shift.py +208 -0
- solverpilot/io/__init__.py +41 -0
- solverpilot/io/binary64.py +49 -0
- solverpilot/io/csv.py +230 -0
- solverpilot/io/errors.py +25 -0
- solverpilot/io/hashing.py +111 -0
- solverpilot/io/json.py +90 -0
- solverpilot/io/mapping.py +195 -0
- solverpilot/io/model.py +407 -0
- solverpilot/io/numeric.py +108 -0
- solverpilot/io/provenance.py +114 -0
- solverpilot/io/result.py +45 -0
- solverpilot/io/source.py +104 -0
- solverpilot/io/strict_json.py +133 -0
- solverpilot/minlp/__init__.py +6 -0
- solverpilot/minlp/compiler.py +66 -0
- solverpilot/minlp/conformance.py +34 -0
- solverpilot/minlp/convexity.py +85 -0
- solverpilot/minlp/ir.py +35 -0
- solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
- solverpilot/minlp/orchestrator.py +220 -0
- solverpilot/minlp/validation.py +28 -0
- solverpilot/model/__init__.py +27 -0
- solverpilot/model/compiler.py +1263 -0
- solverpilot/model/errors.py +22 -0
- solverpilot/model/expression.py +373 -0
- solverpilot/model/model.py +716 -0
- solverpilot/model/sets.py +60 -0
- solverpilot/model/types.py +36 -0
- solverpilot/nlp/__init__.py +7 -0
- solverpilot/nlp/ad.py +166 -0
- solverpilot/nlp/backend.py +113 -0
- solverpilot/nlp/compiler.py +116 -0
- solverpilot/nlp/conformance.py +58 -0
- solverpilot/nlp/ir.py +70 -0
- solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
- solverpilot/nlp/validation.py +60 -0
- solverpilot/plan/__init__.py +33 -0
- solverpilot/plan/model.py +79 -0
- solverpilot/plan/planner.py +201 -0
- solverpilot/plan/production.py +627 -0
- solverpilot/plan/selective_lp.py +65 -0
- solverpilot/problem/__init__.py +16 -0
- solverpilot/problem/enums.py +20 -0
- solverpilot/problem/hashing.py +88 -0
- solverpilot/problem/linear.py +196 -0
- solverpilot/problem/mps.py +447 -0
- solverpilot/problem/quadratic.py +192 -0
- solverpilot/reporting/__init__.py +27 -0
- solverpilot/reporting/explain.py +327 -0
- solverpilot/reporting/model.py +264 -0
- solverpilot/reporting/render.py +55 -0
- solverpilot/runtime/__init__.py +19 -0
- solverpilot/runtime/auto.py +193 -0
- solverpilot/runtime/budgeting.py +43 -0
- solverpilot/runtime/executor.py +126 -0
- solverpilot/runtime/portfolio.py +197 -0
- solverpilot/runtime/result.py +72 -0
- solverpilot/session/__init__.py +29 -0
- solverpilot/session/conformance_v2.py +239 -0
- solverpilot/session/mutations.py +115 -0
- solverpilot/session/persistent-session-trace.schema.json +45 -0
- solverpilot/session/persistent.py +491 -0
- solverpilot/session/reuse.py +81 -0
- solverpilot/session/session.py +236 -0
- solverpilot/trace/__init__.py +3 -0
- solverpilot/trace/schema.py +47 -0
- solverpilot/validate/__init__.py +10 -0
- solverpilot/validate/core.py +179 -0
- solverpilot/validate/result.py +55 -0
- solverpilot-0.1.dist-info/METADATA +554 -0
- solverpilot-0.1.dist-info/RECORD +201 -0
- solverpilot-0.1.dist-info/WHEEL +5 -0
- solverpilot-0.1.dist-info/entry_points.txt +4 -0
- solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
- solverpilot-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import importlib.util
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from solverpilot.capabilities import BackendManifest, Capability, SupportLevel
|
|
9
|
+
from solverpilot.problem import LinearProblem, ObjectiveSense, QuadraticProblem, VariableDomain
|
|
10
|
+
|
|
11
|
+
from .base import BackendSolveResult, BackendUnavailableError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class ScipyHighsBackend:
|
|
16
|
+
"""Development bridge to the HiGHS implementation bundled by SciPy.
|
|
17
|
+
|
|
18
|
+
This is intentionally not the final native highspy adapter. It exists to exercise
|
|
19
|
+
the backend protocol, status normalization, and end-to-end validation in environments
|
|
20
|
+
where highspy itself is unavailable.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
time_limit_s: float | None = None
|
|
24
|
+
mip_rel_gap: float | None = None
|
|
25
|
+
presolve: bool | None = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def manifest(self) -> BackendManifest:
|
|
29
|
+
version = None
|
|
30
|
+
if self.is_available():
|
|
31
|
+
import scipy
|
|
32
|
+
version = scipy.__version__
|
|
33
|
+
return BackendManifest(
|
|
34
|
+
name="scipy-highs-bridge",
|
|
35
|
+
version=version,
|
|
36
|
+
capabilities={
|
|
37
|
+
Capability.LP: SupportLevel.NATIVE,
|
|
38
|
+
Capability.MILP: SupportLevel.NATIVE,
|
|
39
|
+
Capability.CONVEX_QP: SupportLevel.UNSUPPORTED,
|
|
40
|
+
Capability.MIP_START: SupportLevel.UNSUPPORTED,
|
|
41
|
+
Capability.BASIS_START: SupportLevel.UNSUPPORTED,
|
|
42
|
+
Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.UNSUPPORTED,
|
|
43
|
+
Capability.STRUCTURAL_INCREMENTAL_UPDATE: SupportLevel.UNSUPPORTED,
|
|
44
|
+
Capability.NATIVE_REOPTIMIZATION: SupportLevel.UNSUPPORTED,
|
|
45
|
+
Capability.CALLBACK_PROGRESS: SupportLevel.UNSUPPORTED,
|
|
46
|
+
Capability.IIS: SupportLevel.UNSUPPORTED,
|
|
47
|
+
},
|
|
48
|
+
metadata={
|
|
49
|
+
"development_bridge": True,
|
|
50
|
+
"underlying_solver": "HiGHS via scipy.optimize.milp",
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def is_available(self) -> bool:
|
|
55
|
+
return importlib.util.find_spec("scipy.optimize") is not None
|
|
56
|
+
|
|
57
|
+
def solve(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
|
|
58
|
+
if not self.is_available():
|
|
59
|
+
raise BackendUnavailableError("SciPy optimize is not available")
|
|
60
|
+
if isinstance(problem, QuadraticProblem):
|
|
61
|
+
raise ValueError("scipy-highs-bridge does not support QuadraticProblem")
|
|
62
|
+
if not isinstance(problem, LinearProblem):
|
|
63
|
+
raise TypeError(f"unsupported problem type: {type(problem)!r}")
|
|
64
|
+
|
|
65
|
+
from scipy.optimize import Bounds, LinearConstraint, milp
|
|
66
|
+
|
|
67
|
+
c = np.array(problem.c, copy=True)
|
|
68
|
+
if problem.objective_sense is ObjectiveSense.MAXIMIZE:
|
|
69
|
+
c = -c
|
|
70
|
+
|
|
71
|
+
integrality = np.zeros(problem.n_variables, dtype=np.int32)
|
|
72
|
+
integer_mask = np.isin(
|
|
73
|
+
problem.domains,
|
|
74
|
+
[VariableDomain.INTEGER.value, VariableDomain.BINARY.value],
|
|
75
|
+
)
|
|
76
|
+
integrality[integer_mask] = 1
|
|
77
|
+
|
|
78
|
+
options: dict[str, object] = {"disp": False}
|
|
79
|
+
if self.time_limit_s is not None:
|
|
80
|
+
if self.time_limit_s <= 0:
|
|
81
|
+
raise ValueError("time_limit_s must be positive")
|
|
82
|
+
options["time_limit"] = float(self.time_limit_s)
|
|
83
|
+
if self.mip_rel_gap is not None:
|
|
84
|
+
if self.mip_rel_gap < 0:
|
|
85
|
+
raise ValueError("mip_rel_gap must be non-negative")
|
|
86
|
+
options["mip_rel_gap"] = float(self.mip_rel_gap)
|
|
87
|
+
if self.presolve is not None:
|
|
88
|
+
options["presolve"] = bool(self.presolve)
|
|
89
|
+
|
|
90
|
+
result = milp(
|
|
91
|
+
c=c,
|
|
92
|
+
integrality=integrality,
|
|
93
|
+
bounds=Bounds(problem.variable_lower, problem.variable_upper),
|
|
94
|
+
constraints=LinearConstraint(
|
|
95
|
+
problem.A,
|
|
96
|
+
problem.constraint_lower,
|
|
97
|
+
problem.constraint_upper,
|
|
98
|
+
),
|
|
99
|
+
options=options,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
status = self._status_name(int(result.status), result.x is not None)
|
|
103
|
+
x = None if result.x is None else np.asarray(result.x, dtype=np.float64)
|
|
104
|
+
objective = None
|
|
105
|
+
if x is not None:
|
|
106
|
+
objective = float(problem.c @ x + problem.objective_offset)
|
|
107
|
+
|
|
108
|
+
raw = {
|
|
109
|
+
"scipy_status": int(result.status),
|
|
110
|
+
"message": str(result.message),
|
|
111
|
+
"success": bool(result.success),
|
|
112
|
+
}
|
|
113
|
+
for attr in ("mip_node_count", "mip_dual_bound", "mip_gap"):
|
|
114
|
+
if hasattr(result, attr):
|
|
115
|
+
value = getattr(result, attr)
|
|
116
|
+
if value is not None:
|
|
117
|
+
raw[attr] = float(value)
|
|
118
|
+
|
|
119
|
+
return BackendSolveResult(
|
|
120
|
+
backend_status=status,
|
|
121
|
+
x=x,
|
|
122
|
+
objective_reported=objective,
|
|
123
|
+
raw_statistics=raw,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _status_name(status: int, has_solution: bool) -> str:
|
|
128
|
+
if status == 0:
|
|
129
|
+
return "optimal"
|
|
130
|
+
if status == 1:
|
|
131
|
+
return "limit_feasible" if has_solution else "limit_no_solution"
|
|
132
|
+
if status == 2:
|
|
133
|
+
return "infeasible"
|
|
134
|
+
if status == 3:
|
|
135
|
+
return "unbounded"
|
|
136
|
+
if status == 4:
|
|
137
|
+
return "solver_error"
|
|
138
|
+
return "unknown"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import importlib.util
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy import sparse
|
|
8
|
+
|
|
9
|
+
from solverpilot.capabilities import BackendManifest, Capability, SupportLevel
|
|
10
|
+
from solverpilot.problem import LinearProblem, ObjectiveSense, QuadraticProblem
|
|
11
|
+
|
|
12
|
+
from .base import BackendSolveResult, BackendUnavailableError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class ScipyHighsLPBackend:
|
|
17
|
+
"""Development LP backend using ``scipy.optimize.linprog`` + HiGHS.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
method:
|
|
22
|
+
``"highs-ds"`` for HiGHS dual simplex or ``"highs-ipm"`` for the
|
|
23
|
+
HiGHS interior-point method. This backend intentionally supports only
|
|
24
|
+
continuous LPs; integer and quadratic models must be routed elsewhere.
|
|
25
|
+
|
|
26
|
+
Notes
|
|
27
|
+
-----
|
|
28
|
+
This is a development bridge, not the eventual native ``highspy`` adapter.
|
|
29
|
+
It exists so the adaptive runtime can exercise real algorithm alternatives
|
|
30
|
+
without pretending SciPy exposes native HiGHS state reuse.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
method: str = "highs-ds"
|
|
34
|
+
time_limit_s: float | None = None
|
|
35
|
+
presolve: bool | None = None
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if self.method not in {"highs-ds", "highs-ipm"}:
|
|
39
|
+
raise ValueError("method must be 'highs-ds' or 'highs-ipm'")
|
|
40
|
+
if self.time_limit_s is not None and self.time_limit_s <= 0:
|
|
41
|
+
raise ValueError("time_limit_s must be positive")
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def manifest(self) -> BackendManifest:
|
|
45
|
+
version = None
|
|
46
|
+
if self.is_available():
|
|
47
|
+
import scipy
|
|
48
|
+
|
|
49
|
+
version = scipy.__version__
|
|
50
|
+
return BackendManifest(
|
|
51
|
+
name=f"scipy-{self.method}",
|
|
52
|
+
version=version,
|
|
53
|
+
capabilities={
|
|
54
|
+
Capability.LP: SupportLevel.NATIVE,
|
|
55
|
+
Capability.MILP: SupportLevel.UNSUPPORTED,
|
|
56
|
+
Capability.CONVEX_QP: SupportLevel.UNSUPPORTED,
|
|
57
|
+
Capability.MIP_START: SupportLevel.UNSUPPORTED,
|
|
58
|
+
Capability.PRIMAL_START: SupportLevel.UNSUPPORTED,
|
|
59
|
+
Capability.DUAL_START: SupportLevel.UNSUPPORTED,
|
|
60
|
+
Capability.BASIS_START: SupportLevel.UNSUPPORTED,
|
|
61
|
+
Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.UNSUPPORTED,
|
|
62
|
+
Capability.STRUCTURAL_INCREMENTAL_UPDATE: SupportLevel.UNSUPPORTED,
|
|
63
|
+
Capability.NATIVE_REOPTIMIZATION: SupportLevel.UNSUPPORTED,
|
|
64
|
+
Capability.CALLBACK_PROGRESS: SupportLevel.UNSUPPORTED,
|
|
65
|
+
Capability.IIS: SupportLevel.UNSUPPORTED,
|
|
66
|
+
Capability.INFEASIBILITY_CERTIFICATE: SupportLevel.UNSUPPORTED,
|
|
67
|
+
},
|
|
68
|
+
metadata={
|
|
69
|
+
"development_bridge": True,
|
|
70
|
+
"underlying_solver": f"HiGHS {self.method} via scipy.optimize.linprog",
|
|
71
|
+
"algorithm": "dual_simplex" if self.method == "highs-ds" else "interior_point",
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def is_available(self) -> bool:
|
|
76
|
+
return importlib.util.find_spec("scipy.optimize") is not None
|
|
77
|
+
|
|
78
|
+
def solve(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
|
|
79
|
+
if not self.is_available():
|
|
80
|
+
raise BackendUnavailableError("SciPy optimize is not available")
|
|
81
|
+
if isinstance(problem, QuadraticProblem):
|
|
82
|
+
raise ValueError(f"{self.manifest.name} does not support QuadraticProblem")
|
|
83
|
+
if not isinstance(problem, LinearProblem):
|
|
84
|
+
raise TypeError(f"unsupported problem type: {type(problem)!r}")
|
|
85
|
+
if problem.has_integer_variables:
|
|
86
|
+
raise ValueError(f"{self.manifest.name} supports continuous LPs only")
|
|
87
|
+
|
|
88
|
+
from scipy.optimize import linprog
|
|
89
|
+
|
|
90
|
+
c = np.array(problem.c, copy=True)
|
|
91
|
+
if problem.objective_sense is ObjectiveSense.MAXIMIZE:
|
|
92
|
+
c = -c
|
|
93
|
+
|
|
94
|
+
A_ub, b_ub, A_eq, b_eq = _to_linprog_constraints(problem)
|
|
95
|
+
bounds = list(zip(problem.variable_lower.tolist(), problem.variable_upper.tolist()))
|
|
96
|
+
|
|
97
|
+
options: dict[str, object] = {"disp": False}
|
|
98
|
+
if self.time_limit_s is not None:
|
|
99
|
+
options["time_limit"] = float(self.time_limit_s)
|
|
100
|
+
if self.presolve is not None:
|
|
101
|
+
options["presolve"] = bool(self.presolve)
|
|
102
|
+
|
|
103
|
+
result = linprog(
|
|
104
|
+
c,
|
|
105
|
+
A_ub=A_ub,
|
|
106
|
+
b_ub=b_ub,
|
|
107
|
+
A_eq=A_eq,
|
|
108
|
+
b_eq=b_eq,
|
|
109
|
+
bounds=bounds,
|
|
110
|
+
method=self.method,
|
|
111
|
+
options=options,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
status = self._status_name(int(result.status), result.x is not None)
|
|
115
|
+
x = None if result.x is None else np.asarray(result.x, dtype=np.float64)
|
|
116
|
+
objective = None
|
|
117
|
+
if x is not None:
|
|
118
|
+
objective = float(problem.c @ x + problem.objective_offset)
|
|
119
|
+
|
|
120
|
+
raw: dict[str, object] = {
|
|
121
|
+
"scipy_status": int(result.status),
|
|
122
|
+
"message": str(result.message),
|
|
123
|
+
"success": bool(result.success),
|
|
124
|
+
"nit": int(getattr(result, "nit", 0) or 0),
|
|
125
|
+
"method": self.method,
|
|
126
|
+
}
|
|
127
|
+
if hasattr(result, "crossover_nit") and result.crossover_nit is not None:
|
|
128
|
+
raw["crossover_nit"] = int(result.crossover_nit)
|
|
129
|
+
|
|
130
|
+
return BackendSolveResult(
|
|
131
|
+
backend_status=status,
|
|
132
|
+
x=x,
|
|
133
|
+
objective_reported=objective,
|
|
134
|
+
raw_statistics=raw,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _status_name(status: int, has_solution: bool) -> str:
|
|
139
|
+
if status == 0:
|
|
140
|
+
return "optimal"
|
|
141
|
+
if status == 1:
|
|
142
|
+
return "limit_feasible" if has_solution else "limit_no_solution"
|
|
143
|
+
if status == 2:
|
|
144
|
+
return "infeasible"
|
|
145
|
+
if status == 3:
|
|
146
|
+
return "unbounded"
|
|
147
|
+
if status == 4:
|
|
148
|
+
return "solver_error"
|
|
149
|
+
return "unknown"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _to_linprog_constraints(
|
|
153
|
+
problem: LinearProblem,
|
|
154
|
+
) -> tuple[
|
|
155
|
+
sparse.csr_matrix | None,
|
|
156
|
+
np.ndarray | None,
|
|
157
|
+
sparse.csr_matrix | None,
|
|
158
|
+
np.ndarray | None,
|
|
159
|
+
]:
|
|
160
|
+
"""Translate ranged rows into linprog equality/upper-bound form.
|
|
161
|
+
|
|
162
|
+
A row ``l <= a @ x <= u`` becomes:
|
|
163
|
+
- equality if finite ``l == u``;
|
|
164
|
+
- ``a @ x <= u`` when ``u`` is finite;
|
|
165
|
+
- ``-a @ x <= -l`` when ``l`` is finite.
|
|
166
|
+
|
|
167
|
+
The canonical problem remains the source of truth for final validation.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
ub_rows: list[sparse.csr_matrix] = []
|
|
171
|
+
ub_rhs: list[float] = []
|
|
172
|
+
eq_rows: list[sparse.csr_matrix] = []
|
|
173
|
+
eq_rhs: list[float] = []
|
|
174
|
+
|
|
175
|
+
for i in range(problem.n_constraints):
|
|
176
|
+
lo = float(problem.constraint_lower[i])
|
|
177
|
+
hi = float(problem.constraint_upper[i])
|
|
178
|
+
row = problem.A.getrow(i)
|
|
179
|
+
|
|
180
|
+
if np.isfinite(lo) and np.isfinite(hi) and lo == hi:
|
|
181
|
+
eq_rows.append(row)
|
|
182
|
+
eq_rhs.append(lo)
|
|
183
|
+
continue
|
|
184
|
+
if np.isfinite(hi):
|
|
185
|
+
ub_rows.append(row)
|
|
186
|
+
ub_rhs.append(hi)
|
|
187
|
+
if np.isfinite(lo):
|
|
188
|
+
ub_rows.append(-row)
|
|
189
|
+
ub_rhs.append(-lo)
|
|
190
|
+
|
|
191
|
+
A_ub = sparse.vstack(ub_rows, format="csr") if ub_rows else None
|
|
192
|
+
b_ub = np.asarray(ub_rhs, dtype=np.float64) if ub_rows else None
|
|
193
|
+
A_eq = sparse.vstack(eq_rows, format="csr") if eq_rows else None
|
|
194
|
+
b_eq = np.asarray(eq_rhs, dtype=np.float64) if eq_rows else None
|
|
195
|
+
return A_ub, b_ub, A_eq, b_eq
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import importlib.util
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from solverpilot.capabilities import BackendManifest, Capability, SupportLevel
|
|
9
|
+
from solverpilot.problem import LinearProblem, QuadraticProblem
|
|
10
|
+
from solverpilot.problem.quadratic import require_confirmed_convexity
|
|
11
|
+
|
|
12
|
+
from .base import BackendSolveResult, BackendUnavailableError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class ScipySLSQPQPBackend:
|
|
17
|
+
"""Small-problem development bridge for convex QP via SciPy SLSQP.
|
|
18
|
+
|
|
19
|
+
This backend exists only to exercise the full QP runtime path when OSQP is not
|
|
20
|
+
installed. It deliberately advertises ``EMULATED_SAFE`` rather than native QP
|
|
21
|
+
support and does not claim an optimality certificate. A successful SLSQP exit
|
|
22
|
+
is normalized as a validated feasible candidate by the runtime.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
maxiter: int = 1000
|
|
26
|
+
ftol: float = 1e-10
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if self.maxiter <= 0:
|
|
30
|
+
raise ValueError("maxiter must be positive")
|
|
31
|
+
if self.ftol <= 0:
|
|
32
|
+
raise ValueError("ftol must be positive")
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def manifest(self) -> BackendManifest:
|
|
36
|
+
version = None
|
|
37
|
+
if self.is_available():
|
|
38
|
+
import scipy
|
|
39
|
+
|
|
40
|
+
version = scipy.__version__
|
|
41
|
+
return BackendManifest(
|
|
42
|
+
name="scipy-slsqp-qp-bridge",
|
|
43
|
+
version=version,
|
|
44
|
+
capabilities={
|
|
45
|
+
Capability.LP: SupportLevel.UNSUPPORTED,
|
|
46
|
+
Capability.MILP: SupportLevel.UNSUPPORTED,
|
|
47
|
+
Capability.CONVEX_QP: SupportLevel.EMULATED_SAFE,
|
|
48
|
+
Capability.PRIMAL_START: SupportLevel.UNSUPPORTED,
|
|
49
|
+
Capability.DUAL_START: SupportLevel.UNSUPPORTED,
|
|
50
|
+
Capability.SAME_SPARSITY_DATA_UPDATE: SupportLevel.UNSUPPORTED,
|
|
51
|
+
Capability.NATIVE_REOPTIMIZATION: SupportLevel.UNSUPPORTED,
|
|
52
|
+
Capability.INFEASIBILITY_CERTIFICATE: SupportLevel.UNSUPPORTED,
|
|
53
|
+
},
|
|
54
|
+
metadata={
|
|
55
|
+
"development_bridge": True,
|
|
56
|
+
"underlying_solver": "scipy.optimize.minimize(method='SLSQP')",
|
|
57
|
+
"no_optimality_certificate": True,
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def is_available(self) -> bool:
|
|
62
|
+
return importlib.util.find_spec("scipy.optimize") is not None
|
|
63
|
+
|
|
64
|
+
def solve(self, problem: LinearProblem | QuadraticProblem) -> BackendSolveResult:
|
|
65
|
+
if not self.is_available():
|
|
66
|
+
raise BackendUnavailableError("SciPy optimize is not available")
|
|
67
|
+
if not isinstance(problem, QuadraticProblem):
|
|
68
|
+
raise ValueError("scipy-slsqp-qp-bridge supports QuadraticProblem only")
|
|
69
|
+
|
|
70
|
+
from scipy.optimize import Bounds, LinearConstraint, minimize
|
|
71
|
+
|
|
72
|
+
linear = problem.linear
|
|
73
|
+
P = ((problem.P + problem.P.T) * 0.5).tocsr()
|
|
74
|
+
q = linear.c
|
|
75
|
+
|
|
76
|
+
def fun(x: np.ndarray) -> float:
|
|
77
|
+
return float(0.5 * x @ (P @ x) + q @ x + linear.objective_offset)
|
|
78
|
+
|
|
79
|
+
def jac(x: np.ndarray) -> np.ndarray:
|
|
80
|
+
return np.asarray(P @ x + q, dtype=np.float64)
|
|
81
|
+
|
|
82
|
+
x0 = _initial_point(linear.variable_lower, linear.variable_upper)
|
|
83
|
+
constraints = []
|
|
84
|
+
if linear.n_constraints:
|
|
85
|
+
constraints = [
|
|
86
|
+
LinearConstraint(
|
|
87
|
+
linear.A,
|
|
88
|
+
linear.constraint_lower,
|
|
89
|
+
linear.constraint_upper,
|
|
90
|
+
)
|
|
91
|
+
]
|
|
92
|
+
result = minimize(
|
|
93
|
+
fun,
|
|
94
|
+
x0,
|
|
95
|
+
jac=jac,
|
|
96
|
+
method="SLSQP",
|
|
97
|
+
bounds=Bounds(linear.variable_lower, linear.variable_upper),
|
|
98
|
+
constraints=constraints,
|
|
99
|
+
options={
|
|
100
|
+
"disp": False,
|
|
101
|
+
"maxiter": int(self.maxiter),
|
|
102
|
+
"ftol": float(self.ftol),
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
x = None if result.x is None else np.asarray(result.x, dtype=np.float64)
|
|
107
|
+
status = "converged_candidate" if bool(result.success) else (
|
|
108
|
+
"limit_feasible" if x is not None else "solver_error"
|
|
109
|
+
)
|
|
110
|
+
objective = None if x is None else fun(x)
|
|
111
|
+
return BackendSolveResult(
|
|
112
|
+
backend_status=status,
|
|
113
|
+
x=x,
|
|
114
|
+
objective_reported=objective,
|
|
115
|
+
raw_statistics={
|
|
116
|
+
"scipy_status": int(result.status),
|
|
117
|
+
"message": str(result.message),
|
|
118
|
+
"success": bool(result.success),
|
|
119
|
+
"nit": int(getattr(result, "nit", 0) or 0),
|
|
120
|
+
"development_only": True,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _initial_point(lower: np.ndarray, upper: np.ndarray) -> np.ndarray:
|
|
126
|
+
x = np.zeros_like(lower, dtype=np.float64)
|
|
127
|
+
both = np.isfinite(lower) & np.isfinite(upper)
|
|
128
|
+
x[both] = 0.5 * (lower[both] + upper[both])
|
|
129
|
+
only_lower = np.isfinite(lower) & ~np.isfinite(upper)
|
|
130
|
+
x[only_lower] = np.maximum(0.0, lower[only_lower])
|
|
131
|
+
only_upper = ~np.isfinite(lower) & np.isfinite(upper)
|
|
132
|
+
x[only_upper] = np.minimum(0.0, upper[only_upper])
|
|
133
|
+
return x
|