mlflow-optimization 0.1.0__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.
- mlflow_optimization/__init__.py +50 -0
- mlflow_optimization/_bootstrap.py +129 -0
- mlflow_optimization/common/diagnostics.py +19 -0
- mlflow_optimization/common/patching.py +82 -0
- mlflow_optimization/features/annotations/annotate.py +97 -0
- mlflow_optimization/features/annotations/types.py +22 -0
- mlflow_optimization/features/artifacts/policy.py +16 -0
- mlflow_optimization/features/artifacts/ports.py +17 -0
- mlflow_optimization/features/autolog/ports.py +49 -0
- mlflow_optimization/features/autolog/service.py +147 -0
- mlflow_optimization/features/autolog/types.py +31 -0
- mlflow_optimization/features/metadata/collect.py +52 -0
- mlflow_optimization/features/metadata/types.py +42 -0
- mlflow_optimization/features/metrics/normalize.py +121 -0
- mlflow_optimization/features/metrics/types.py +54 -0
- mlflow_optimization/features/outcomes/classify.py +53 -0
- mlflow_optimization/features/outcomes/types.py +40 -0
- mlflow_optimization/features/runs/ports.py +30 -0
- mlflow_optimization/features/runs/record.py +231 -0
- mlflow_optimization/features/runs/types.py +43 -0
- mlflow_optimization/integrations/cp_sat/__init__.py +5 -0
- mlflow_optimization/integrations/cp_sat/adapter.py +464 -0
- mlflow_optimization/integrations/gurobipy/__init__.py +5 -0
- mlflow_optimization/integrations/gurobipy/adapter.py +474 -0
- mlflow_optimization/integrations/mathopt/__init__.py +1 -0
- mlflow_optimization/integrations/mathopt/adapter.py +939 -0
- mlflow_optimization/integrations/mlflow/artifacts.py +44 -0
- mlflow_optimization/integrations/mlflow/runs.py +480 -0
- mlflow_optimization/integrations/pyomo/__init__.py +5 -0
- mlflow_optimization/integrations/pyomo/adapter.py +1499 -0
- mlflow_optimization/integrations/pyscipopt/__init__.py +5 -0
- mlflow_optimization/integrations/pyscipopt/adapter.py +657 -0
- mlflow_optimization/integrations/routing/__init__.py +5 -0
- mlflow_optimization/integrations/routing/adapter.py +429 -0
- mlflow_optimization/py.typed +0 -0
- mlflow_optimization-0.1.0.dist-info/METADATA +52 -0
- mlflow_optimization-0.1.0.dist-info/RECORD +40 -0
- mlflow_optimization-0.1.0.dist-info/WHEEL +4 -0
- mlflow_optimization-0.1.0.dist-info/licenses/LICENSE-APACHE +176 -0
- mlflow_optimization-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
|
@@ -0,0 +1,1499 @@
|
|
|
1
|
+
"""Observation hooks for Pyomo solver calls."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import math
|
|
6
|
+
import pkgutil
|
|
7
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from mlflow_optimization.common.patching import Patch, receiver_of
|
|
13
|
+
from mlflow_optimization.features.autolog.ports import ObservationError, SolveObserver
|
|
14
|
+
from mlflow_optimization.features.autolog.types import SolveObservation
|
|
15
|
+
from mlflow_optimization.features.metadata.types import (
|
|
16
|
+
ParameterOrigin,
|
|
17
|
+
ReportedMetadata,
|
|
18
|
+
SolverParameter,
|
|
19
|
+
)
|
|
20
|
+
from mlflow_optimization.features.metrics.types import (
|
|
21
|
+
MissingReason,
|
|
22
|
+
ObjectiveSense,
|
|
23
|
+
ReportedMetrics,
|
|
24
|
+
)
|
|
25
|
+
from mlflow_optimization.features.outcomes.types import ReportedOutcome, Termination
|
|
26
|
+
from pyomo.core.base.block import BlockData
|
|
27
|
+
from pyomo.opt.base.solvers import OptSolver
|
|
28
|
+
|
|
29
|
+
PARAMETER_CONTROL_NAMES: frozenset[str] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"keepfiles",
|
|
32
|
+
"load_solution",
|
|
33
|
+
"load_solutions",
|
|
34
|
+
"logfile",
|
|
35
|
+
"model",
|
|
36
|
+
"options",
|
|
37
|
+
"raise_exception_on_nonoptimal_result",
|
|
38
|
+
"report_timing",
|
|
39
|
+
"solver_io",
|
|
40
|
+
"solver_options",
|
|
41
|
+
"solnfile",
|
|
42
|
+
"stream_solver",
|
|
43
|
+
"suffixes",
|
|
44
|
+
"symbolic_solver_labels",
|
|
45
|
+
"tee",
|
|
46
|
+
"timer",
|
|
47
|
+
"working_dir",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _import_solver_modules(package_name: str) -> None:
|
|
53
|
+
"""The importer for modules in a Pyomo solver package."""
|
|
54
|
+
try:
|
|
55
|
+
solver_package: Any = importlib.import_module(package_name)
|
|
56
|
+
except (ImportError, AttributeError, NameError):
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
def ignore_module_error(module_name: str) -> None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
solver_modules: tuple[pkgutil.ModuleInfo, ...] = tuple(
|
|
63
|
+
pkgutil.walk_packages(
|
|
64
|
+
solver_package.__path__,
|
|
65
|
+
f"{solver_package.__name__}.",
|
|
66
|
+
onerror=ignore_module_error,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
for module_info in solver_modules:
|
|
70
|
+
module_parts: tuple[str, ...] = tuple(module_info.name.split("."))
|
|
71
|
+
if "tests" in module_parts or module_parts[-1].startswith("test_"):
|
|
72
|
+
continue
|
|
73
|
+
try:
|
|
74
|
+
importlib.import_module(module_info.name)
|
|
75
|
+
except (ImportError, AttributeError, NameError):
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PyomoAdapter:
|
|
80
|
+
"""The adapter for solve calls made through Pyomo."""
|
|
81
|
+
|
|
82
|
+
api: str = "pyomo"
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self._patches: list[Patch] = []
|
|
86
|
+
self._engine_types: dict[type[Any], str] = {}
|
|
87
|
+
|
|
88
|
+
def install(self, observer: SolveObserver) -> None:
|
|
89
|
+
"""Installation of Pyomo solve hooks."""
|
|
90
|
+
if self._patches:
|
|
91
|
+
return
|
|
92
|
+
self._engine_types = {}
|
|
93
|
+
owner_modules: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
94
|
+
(
|
|
95
|
+
"pyomo.contrib.appsi.base",
|
|
96
|
+
("LegacySolverInterface", "PersistentSolver"),
|
|
97
|
+
),
|
|
98
|
+
(
|
|
99
|
+
"pyomo.contrib.solver.common.base",
|
|
100
|
+
("LegacySolverWrapper", "SolverBase"),
|
|
101
|
+
),
|
|
102
|
+
(
|
|
103
|
+
"pyomo.solvers.plugins.solvers.direct_or_persistent_solver",
|
|
104
|
+
("DirectOrPersistentSolver",),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
owners: list[type[Any]] = [OptSolver]
|
|
108
|
+
for module_name, class_names in owner_modules:
|
|
109
|
+
try:
|
|
110
|
+
owner_module: Any = importlib.import_module(module_name)
|
|
111
|
+
except (ImportError, NameError):
|
|
112
|
+
continue
|
|
113
|
+
for class_name in class_names:
|
|
114
|
+
owner_class: Any | None = getattr(owner_module, class_name, None)
|
|
115
|
+
if isinstance(owner_class, type):
|
|
116
|
+
owners.append(owner_class)
|
|
117
|
+
_import_solver_modules("pyomo.contrib.appsi.solvers")
|
|
118
|
+
_import_solver_modules("pyomo.contrib.solver.solvers")
|
|
119
|
+
specific_owners: list[type[Any]] = []
|
|
120
|
+
optional_modules: tuple[tuple[str, str, str], ...] = (
|
|
121
|
+
("pyomo.solvers.plugins.solvers.gurobi_direct", "GurobiDirect", "gurobi"),
|
|
122
|
+
(
|
|
123
|
+
"pyomo.solvers.plugins.solvers.gurobi_persistent",
|
|
124
|
+
"GurobiPersistent",
|
|
125
|
+
"gurobi",
|
|
126
|
+
),
|
|
127
|
+
("pyomo.contrib.appsi.solvers.highs", "Highs", "highs"),
|
|
128
|
+
("pyomo.contrib.appsi.solvers.gurobi", "Gurobi", "gurobi"),
|
|
129
|
+
("pyomo.contrib.solver.solvers.highs", "Highs", "highs"),
|
|
130
|
+
("pyomo.contrib.solver.solvers.scip.scip_direct", "ScipDirect", "scip"),
|
|
131
|
+
(
|
|
132
|
+
"pyomo.contrib.solver.solvers.scip.scip_persistent",
|
|
133
|
+
"ScipPersistent",
|
|
134
|
+
"scip",
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
for module_name, class_name, engine_name in optional_modules:
|
|
138
|
+
try:
|
|
139
|
+
solver_module: Any = importlib.import_module(module_name)
|
|
140
|
+
solver_class: Any = getattr(solver_module, class_name)
|
|
141
|
+
except (ImportError, AttributeError, NameError):
|
|
142
|
+
continue
|
|
143
|
+
if isinstance(solver_class, type):
|
|
144
|
+
specific_owners.append(solver_class)
|
|
145
|
+
self._engine_types[solver_class] = engine_name
|
|
146
|
+
native_engine_modules: tuple[tuple[str, str, str], ...] = (
|
|
147
|
+
("highspy.highs", "Highs", "highs"),
|
|
148
|
+
("gurobipy", "Model", "gurobi"),
|
|
149
|
+
("pyscipopt.scip", "Model", "scip"),
|
|
150
|
+
)
|
|
151
|
+
for module_name, class_name, engine_name in native_engine_modules:
|
|
152
|
+
try:
|
|
153
|
+
engine_module: Any = importlib.import_module(module_name)
|
|
154
|
+
engine_type: Any = getattr(engine_module, class_name)
|
|
155
|
+
except (ImportError, AttributeError):
|
|
156
|
+
continue
|
|
157
|
+
if isinstance(engine_type, type):
|
|
158
|
+
self._engine_types[engine_type] = engine_name
|
|
159
|
+
patches: list[Patch] = []
|
|
160
|
+
seen: set[type[Any]] = set()
|
|
161
|
+
solver_owners: list[type[Any]] = []
|
|
162
|
+
for owner in owners:
|
|
163
|
+
solver_owners.append(owner)
|
|
164
|
+
solver_owners.extend(self._solve_overrides(owner))
|
|
165
|
+
solver_owners.extend(specific_owners)
|
|
166
|
+
for owner in specific_owners:
|
|
167
|
+
solver_owners.extend(self._solve_overrides(owner))
|
|
168
|
+
try:
|
|
169
|
+
for owner in solver_owners:
|
|
170
|
+
if owner in seen:
|
|
171
|
+
continue
|
|
172
|
+
seen.add(owner)
|
|
173
|
+
patch: Patch = Patch(owner, "solve")
|
|
174
|
+
|
|
175
|
+
def factory(original: Callable[..., Any]) -> Callable[..., Any]:
|
|
176
|
+
original_signature: inspect.Signature = inspect.signature(original)
|
|
177
|
+
|
|
178
|
+
@wraps(original)
|
|
179
|
+
def wrapped(
|
|
180
|
+
*solve_arguments: Any,
|
|
181
|
+
**solve_keywords: Any,
|
|
182
|
+
) -> Any:
|
|
183
|
+
solve_observer: SolveObserver = observer
|
|
184
|
+
solver: Any = receiver_of(
|
|
185
|
+
original,
|
|
186
|
+
solve_arguments,
|
|
187
|
+
solve_keywords,
|
|
188
|
+
)
|
|
189
|
+
if solver is None:
|
|
190
|
+
return original(*solve_arguments, **solve_keywords)
|
|
191
|
+
try:
|
|
192
|
+
bound_arguments: inspect.BoundArguments = (
|
|
193
|
+
original_signature.bind(
|
|
194
|
+
*solve_arguments,
|
|
195
|
+
**solve_keywords,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
except TypeError:
|
|
199
|
+
return original(*solve_arguments, **solve_keywords)
|
|
200
|
+
model: Any | None = self._model_from_call(
|
|
201
|
+
solver,
|
|
202
|
+
bound_arguments,
|
|
203
|
+
)
|
|
204
|
+
solver_time_before: tuple[Any | None, float | None] | None = (
|
|
205
|
+
self._solver_time_snapshot(solver)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def extract(
|
|
209
|
+
result: Any | None,
|
|
210
|
+
error: BaseException | None,
|
|
211
|
+
) -> SolveObservation:
|
|
212
|
+
return self._extract(
|
|
213
|
+
solver,
|
|
214
|
+
model,
|
|
215
|
+
result,
|
|
216
|
+
error,
|
|
217
|
+
solve_keywords,
|
|
218
|
+
bound_arguments.arguments,
|
|
219
|
+
solver_time_before,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return solve_observer.observe(
|
|
223
|
+
self.api,
|
|
224
|
+
lambda: original(
|
|
225
|
+
*solve_arguments,
|
|
226
|
+
**solve_keywords,
|
|
227
|
+
),
|
|
228
|
+
extract,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return wrapped
|
|
232
|
+
|
|
233
|
+
patch.install(factory)
|
|
234
|
+
patches.append(patch)
|
|
235
|
+
except (AttributeError, TypeError):
|
|
236
|
+
for patch in reversed(patches):
|
|
237
|
+
patch.uninstall()
|
|
238
|
+
raise
|
|
239
|
+
self._patches = patches
|
|
240
|
+
|
|
241
|
+
def _solve_overrides(self, owner: type[Any]) -> list[type[Any]]:
|
|
242
|
+
overrides: list[type[Any]] = []
|
|
243
|
+
pending_owners: list[type[Any]] = list(owner.__subclasses__())
|
|
244
|
+
while pending_owners:
|
|
245
|
+
subclass: type[Any] = pending_owners.pop()
|
|
246
|
+
pending_owners.extend(subclass.__subclasses__())
|
|
247
|
+
if callable(vars(subclass).get("solve")):
|
|
248
|
+
overrides.append(subclass)
|
|
249
|
+
return overrides
|
|
250
|
+
|
|
251
|
+
def _model_from_call(
|
|
252
|
+
self,
|
|
253
|
+
solver: Any,
|
|
254
|
+
bound_arguments: inspect.BoundArguments,
|
|
255
|
+
) -> Any | None:
|
|
256
|
+
for argument_value in bound_arguments.arguments.values():
|
|
257
|
+
if self._is_pyomo_model(argument_value):
|
|
258
|
+
return argument_value
|
|
259
|
+
if isinstance(argument_value, Mapping):
|
|
260
|
+
for nested_value in argument_value.values():
|
|
261
|
+
if self._is_pyomo_model(nested_value):
|
|
262
|
+
return nested_value
|
|
263
|
+
if isinstance(argument_value, Sequence) and not isinstance(
|
|
264
|
+
argument_value,
|
|
265
|
+
(str, bytes),
|
|
266
|
+
):
|
|
267
|
+
for nested_value in argument_value:
|
|
268
|
+
if self._is_pyomo_model(nested_value):
|
|
269
|
+
return nested_value
|
|
270
|
+
for attribute_name in ("_pyomo_model", "_model"):
|
|
271
|
+
attached_model: Any | None = self._read(solver, attribute_name)[0]
|
|
272
|
+
if self._is_pyomo_model(attached_model):
|
|
273
|
+
return attached_model
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
def _is_pyomo_model(self, value: Any | None) -> bool:
|
|
277
|
+
return isinstance(value, BlockData)
|
|
278
|
+
|
|
279
|
+
def uninstall(self) -> None:
|
|
280
|
+
"""Restoration of Pyomo solve hooks."""
|
|
281
|
+
for patch in reversed(self._patches):
|
|
282
|
+
patch.uninstall()
|
|
283
|
+
self._patches = []
|
|
284
|
+
|
|
285
|
+
def _extract(
|
|
286
|
+
self,
|
|
287
|
+
solver: Any,
|
|
288
|
+
model: Any | None,
|
|
289
|
+
result: Any | None,
|
|
290
|
+
error: BaseException | None,
|
|
291
|
+
solve_keywords: Mapping[str, Any],
|
|
292
|
+
bound_arguments: Mapping[str, Any],
|
|
293
|
+
solver_time_before: tuple[Any | None, float | None] | None = None,
|
|
294
|
+
) -> SolveObservation:
|
|
295
|
+
try:
|
|
296
|
+
outcome: ReportedOutcome = self._outcome(solver, result, error)
|
|
297
|
+
metrics: ReportedMetrics = self._metrics(
|
|
298
|
+
solver,
|
|
299
|
+
result,
|
|
300
|
+
model,
|
|
301
|
+
error,
|
|
302
|
+
outcome,
|
|
303
|
+
solver_time_before,
|
|
304
|
+
)
|
|
305
|
+
metadata: ReportedMetadata = self._metadata(
|
|
306
|
+
solver,
|
|
307
|
+
model,
|
|
308
|
+
result,
|
|
309
|
+
solve_keywords,
|
|
310
|
+
bound_arguments,
|
|
311
|
+
error,
|
|
312
|
+
)
|
|
313
|
+
return SolveObservation(self.api, outcome, metrics, metadata)
|
|
314
|
+
except Exception as extraction_error:
|
|
315
|
+
raise ObservationError(
|
|
316
|
+
"Pyomo solve observation extraction failed."
|
|
317
|
+
) from extraction_error
|
|
318
|
+
|
|
319
|
+
def _outcome(
|
|
320
|
+
self,
|
|
321
|
+
solver: Any,
|
|
322
|
+
result: Any | None,
|
|
323
|
+
error: BaseException | None,
|
|
324
|
+
) -> ReportedOutcome:
|
|
325
|
+
if error is not None:
|
|
326
|
+
termination: Termination = (
|
|
327
|
+
Termination.INTERRUPTED
|
|
328
|
+
if isinstance(error, KeyboardInterrupt)
|
|
329
|
+
else Termination.ERROR
|
|
330
|
+
)
|
|
331
|
+
return ReportedOutcome(type(error).__name__, termination, None)
|
|
332
|
+
status: Any | None = None
|
|
333
|
+
native_termination: Any | None = None
|
|
334
|
+
result_sources: tuple[Any, ...] = self._result_sources(solver, result)
|
|
335
|
+
for result_source in result_sources:
|
|
336
|
+
status_source: Any | None = self._read(result_source, "solver")[0]
|
|
337
|
+
if status_source is not None and status is None:
|
|
338
|
+
status = self._read(status_source, "status")[0]
|
|
339
|
+
if status is None:
|
|
340
|
+
status = self._read(result_source, "status")[0]
|
|
341
|
+
if native_termination is None and status_source is not None:
|
|
342
|
+
native_termination = self._read(status_source, "termination_condition")[
|
|
343
|
+
0
|
|
344
|
+
]
|
|
345
|
+
if native_termination is None:
|
|
346
|
+
native_termination = self._read(result_source, "termination_condition")[
|
|
347
|
+
0
|
|
348
|
+
]
|
|
349
|
+
if status is None:
|
|
350
|
+
status = self._read(result, "status")[0]
|
|
351
|
+
raw_statuses: list[str] = []
|
|
352
|
+
for native_value in (status, native_termination):
|
|
353
|
+
native_name: str | None = self._native_name(native_value)
|
|
354
|
+
if native_name is not None and native_name not in raw_statuses:
|
|
355
|
+
raw_statuses.append(native_name)
|
|
356
|
+
raw_status: str = " / ".join(raw_statuses) if raw_statuses else "unknown"
|
|
357
|
+
termination = self._map_termination(
|
|
358
|
+
native_termination if native_termination is not None else status
|
|
359
|
+
)
|
|
360
|
+
native_termination_name: str | None = self._native_name(native_termination)
|
|
361
|
+
solution_status_name: str | None = self._native_name(
|
|
362
|
+
self._first_value(result_sources, "solution_status")
|
|
363
|
+
)
|
|
364
|
+
if (
|
|
365
|
+
native_termination_name is not None
|
|
366
|
+
and native_termination_name.rsplit(".", 1)[-1].casefold()
|
|
367
|
+
== "convergencecriteriasatisfied"
|
|
368
|
+
and solution_status_name is not None
|
|
369
|
+
):
|
|
370
|
+
normalized_solution_status: str = solution_status_name.rsplit(".", 1)[
|
|
371
|
+
-1
|
|
372
|
+
].casefold()
|
|
373
|
+
if normalized_solution_status == "optimal":
|
|
374
|
+
termination = Termination.OPTIMAL
|
|
375
|
+
elif normalized_solution_status == "feasible":
|
|
376
|
+
termination = Termination.FEASIBLE
|
|
377
|
+
native_solver: Any | None = self._read(solver, "_solver_model")[0]
|
|
378
|
+
has_solution: bool | None = self._has_solution(
|
|
379
|
+
result_sources,
|
|
380
|
+
native_solver,
|
|
381
|
+
)
|
|
382
|
+
if (
|
|
383
|
+
native_termination_name is not None
|
|
384
|
+
and native_termination_name.rsplit(".", 1)[-1].casefold() == "other"
|
|
385
|
+
and has_solution is True
|
|
386
|
+
):
|
|
387
|
+
termination = Termination.FEASIBLE
|
|
388
|
+
return ReportedOutcome(raw_status, termination, has_solution)
|
|
389
|
+
|
|
390
|
+
def _map_termination(self, value: Any | None) -> Termination:
|
|
391
|
+
native_name: str | None = self._native_name(value)
|
|
392
|
+
if native_name is None:
|
|
393
|
+
return Termination.UNKNOWN
|
|
394
|
+
normalized_name: str = native_name.rsplit(".", 1)[-1].casefold()
|
|
395
|
+
terminations: Mapping[str, Termination] = {
|
|
396
|
+
"convergencecriteriasatisfied": Termination.UNKNOWN,
|
|
397
|
+
"optimal": Termination.OPTIMAL,
|
|
398
|
+
"globallyoptimal": Termination.OPTIMAL,
|
|
399
|
+
"locallyoptimal": Termination.FEASIBLE,
|
|
400
|
+
"feasible": Termination.FEASIBLE,
|
|
401
|
+
"other": Termination.UNKNOWN,
|
|
402
|
+
"infeasible": Termination.INFEASIBLE,
|
|
403
|
+
"proveninfeasible": Termination.INFEASIBLE,
|
|
404
|
+
"unbounded": Termination.UNBOUNDED,
|
|
405
|
+
"infeasibleorunbounded": Termination.INFEASIBLE_OR_UNBOUNDED,
|
|
406
|
+
"maxTimeLimit".casefold(): Termination.TIME_LIMIT,
|
|
407
|
+
"timeLimit".casefold(): Termination.TIME_LIMIT,
|
|
408
|
+
"time_limit": Termination.TIME_LIMIT,
|
|
409
|
+
"maxIterations".casefold(): Termination.ITERATION_LIMIT,
|
|
410
|
+
"iterationLimit".casefold(): Termination.ITERATION_LIMIT,
|
|
411
|
+
"iteration_limit": Termination.ITERATION_LIMIT,
|
|
412
|
+
"maxNodes".casefold(): Termination.NODE_LIMIT,
|
|
413
|
+
"nodeLimit".casefold(): Termination.NODE_LIMIT,
|
|
414
|
+
"node_limit": Termination.NODE_LIMIT,
|
|
415
|
+
"maxSolutions".casefold(): Termination.SOLUTION_LIMIT,
|
|
416
|
+
"solutionLimit".casefold(): Termination.SOLUTION_LIMIT,
|
|
417
|
+
"solution_limit": Termination.SOLUTION_LIMIT,
|
|
418
|
+
"maxEvaluations".casefold(): Termination.OTHER_LIMIT,
|
|
419
|
+
"maxSubIterations".casefold(): Termination.OTHER_LIMIT,
|
|
420
|
+
"maxMemory".casefold(): Termination.OTHER_LIMIT,
|
|
421
|
+
"objectiveLimit".casefold(): Termination.OTHER_LIMIT,
|
|
422
|
+
"minFunctionValue".casefold(): Termination.UNKNOWN,
|
|
423
|
+
"minStepLength".casefold(): Termination.UNKNOWN,
|
|
424
|
+
"intermediateNonInteger".casefold(): Termination.UNKNOWN,
|
|
425
|
+
"locallyInfeasible".casefold(): Termination.UNKNOWN,
|
|
426
|
+
"emptyModel".casefold(): Termination.UNKNOWN,
|
|
427
|
+
"userinterrupt": Termination.INTERRUPTED,
|
|
428
|
+
"resourceinterrupt": Termination.INTERRUPTED,
|
|
429
|
+
"interrupted": Termination.INTERRUPTED,
|
|
430
|
+
"solverfailure": Termination.ERROR,
|
|
431
|
+
"internalsolvererror": Termination.ERROR,
|
|
432
|
+
"invalidproblem": Termination.ERROR,
|
|
433
|
+
"licensingproblems": Termination.ERROR,
|
|
434
|
+
"error": Termination.ERROR,
|
|
435
|
+
"unknown": Termination.UNKNOWN,
|
|
436
|
+
"nosolution": Termination.UNKNOWN,
|
|
437
|
+
}
|
|
438
|
+
return terminations.get(normalized_name, Termination.UNKNOWN)
|
|
439
|
+
|
|
440
|
+
def _has_solution(
|
|
441
|
+
self,
|
|
442
|
+
result_sources: Sequence[Any],
|
|
443
|
+
native_solver: Any | None,
|
|
444
|
+
) -> bool | None:
|
|
445
|
+
if not result_sources:
|
|
446
|
+
return None
|
|
447
|
+
has_feasible_legacy_solution: bool = False
|
|
448
|
+
has_infeasible_legacy_solution: bool = False
|
|
449
|
+
# NOTE: A legacy SOL reader can retain an infeasible final iterate.
|
|
450
|
+
for source in result_sources:
|
|
451
|
+
solutions: Any | None = self._read(source, "solution")[0]
|
|
452
|
+
if solutions is None:
|
|
453
|
+
continue
|
|
454
|
+
legacy_solution_count: int | None = None
|
|
455
|
+
try:
|
|
456
|
+
legacy_solution_count = len(solutions)
|
|
457
|
+
except Exception: # noqa: BLE001,S112
|
|
458
|
+
continue
|
|
459
|
+
if legacy_solution_count is None or legacy_solution_count == 0:
|
|
460
|
+
continue
|
|
461
|
+
try:
|
|
462
|
+
for solution in solutions:
|
|
463
|
+
legacy_solution_status: Any | None = self._read(solution, "status")[
|
|
464
|
+
0
|
|
465
|
+
]
|
|
466
|
+
legacy_status_name: str | None = self._native_name(
|
|
467
|
+
legacy_solution_status
|
|
468
|
+
)
|
|
469
|
+
if legacy_status_name is None:
|
|
470
|
+
continue
|
|
471
|
+
legacy_normalized_status: str = legacy_status_name.rsplit(".", 1)[
|
|
472
|
+
-1
|
|
473
|
+
].casefold()
|
|
474
|
+
if legacy_normalized_status in (
|
|
475
|
+
"feasible",
|
|
476
|
+
"optimal",
|
|
477
|
+
"globallyoptimal",
|
|
478
|
+
"locallyoptimal",
|
|
479
|
+
):
|
|
480
|
+
has_feasible_legacy_solution = True
|
|
481
|
+
elif legacy_normalized_status in ("infeasible", "error"):
|
|
482
|
+
has_infeasible_legacy_solution = True
|
|
483
|
+
except Exception: # noqa: BLE001,S112
|
|
484
|
+
continue
|
|
485
|
+
if has_feasible_legacy_solution:
|
|
486
|
+
return True
|
|
487
|
+
if has_infeasible_legacy_solution:
|
|
488
|
+
return False
|
|
489
|
+
for source in result_sources:
|
|
490
|
+
explicit_value: Any | None = self._read(source, "has_solution")[0]
|
|
491
|
+
if isinstance(explicit_value, bool):
|
|
492
|
+
return explicit_value
|
|
493
|
+
solution_status: Any | None = self._read(source, "solution_status")[0]
|
|
494
|
+
solution_status_name: str | None = self._native_name(solution_status)
|
|
495
|
+
if solution_status_name is not None:
|
|
496
|
+
normalized_status: str = solution_status_name.rsplit(".", 1)[
|
|
497
|
+
-1
|
|
498
|
+
].casefold()
|
|
499
|
+
if normalized_status == "nosolution":
|
|
500
|
+
return False
|
|
501
|
+
if normalized_status in ("feasible", "optimal"):
|
|
502
|
+
return True
|
|
503
|
+
solution_count: float | None = self._solution_count(source)
|
|
504
|
+
if solution_count is not None:
|
|
505
|
+
return solution_count > 0
|
|
506
|
+
for source in result_sources:
|
|
507
|
+
feasible_objective: Any | None
|
|
508
|
+
objective_reason: MissingReason | None
|
|
509
|
+
feasible_objective, objective_reason = self._read(
|
|
510
|
+
source,
|
|
511
|
+
"best_feasible_objective",
|
|
512
|
+
)
|
|
513
|
+
feasible_number: float | None = self._native_number(
|
|
514
|
+
feasible_objective,
|
|
515
|
+
native_solver,
|
|
516
|
+
)
|
|
517
|
+
if feasible_number is not None:
|
|
518
|
+
return math.isfinite(feasible_number)
|
|
519
|
+
if objective_reason is MissingReason.UNDEFINED:
|
|
520
|
+
return False
|
|
521
|
+
incumbent_objective: Any | None
|
|
522
|
+
incumbent_reason: MissingReason | None
|
|
523
|
+
incumbent_objective, incumbent_reason = self._read(
|
|
524
|
+
source, "incumbent_objective"
|
|
525
|
+
)
|
|
526
|
+
incumbent_number: float | None = self._native_number(
|
|
527
|
+
incumbent_objective,
|
|
528
|
+
native_solver,
|
|
529
|
+
)
|
|
530
|
+
if incumbent_number is not None:
|
|
531
|
+
return math.isfinite(incumbent_number)
|
|
532
|
+
if incumbent_reason is MissingReason.UNDEFINED:
|
|
533
|
+
return False
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
def _metrics(
|
|
537
|
+
self,
|
|
538
|
+
solver: Any,
|
|
539
|
+
result: Any | None,
|
|
540
|
+
model: Any | None,
|
|
541
|
+
error: BaseException | None,
|
|
542
|
+
outcome: ReportedOutcome,
|
|
543
|
+
solver_time_before: tuple[Any | None, float | None] | None,
|
|
544
|
+
) -> ReportedMetrics:
|
|
545
|
+
size: dict[str, int] = self._model_size(model)
|
|
546
|
+
objectives: tuple[Any, ...] | None = self._model_objects(model)
|
|
547
|
+
has_objective: bool | None = (
|
|
548
|
+
len(objectives) > 0 if objectives is not None else None
|
|
549
|
+
)
|
|
550
|
+
sense: ObjectiveSense | None = self._sense(objectives or ())
|
|
551
|
+
result_sources: tuple[Any, ...] = (
|
|
552
|
+
() if error is not None else self._result_sources(solver, result)
|
|
553
|
+
)
|
|
554
|
+
native_solver: Any | None = None
|
|
555
|
+
if error is None and result is not None:
|
|
556
|
+
native_solver = self._read(solver, "_solver_model")[0]
|
|
557
|
+
has_solution: bool | None = outcome.has_solution
|
|
558
|
+
objective_value: float | MissingReason = self._objective_value(
|
|
559
|
+
result_sources,
|
|
560
|
+
native_solver,
|
|
561
|
+
)
|
|
562
|
+
if objective_value is MissingReason.UNSUPPORTED:
|
|
563
|
+
objective_value = self._solution_objective(result_sources, native_solver)
|
|
564
|
+
if objective_value is MissingReason.UNSUPPORTED:
|
|
565
|
+
objective_value = self._problem_objective(
|
|
566
|
+
result_sources,
|
|
567
|
+
sense,
|
|
568
|
+
native_solver,
|
|
569
|
+
)
|
|
570
|
+
if has_solution is False and has_objective is not False:
|
|
571
|
+
objective_value = MissingReason.NO_SOLUTION
|
|
572
|
+
if objective_value is MissingReason.UNSUPPORTED and has_objective is False:
|
|
573
|
+
objective_value = MissingReason.UNDEFINED
|
|
574
|
+
dual_bound: float | MissingReason = self._dual_bound(
|
|
575
|
+
result_sources,
|
|
576
|
+
sense,
|
|
577
|
+
outcome.termination,
|
|
578
|
+
native_solver,
|
|
579
|
+
)
|
|
580
|
+
solver_time_seconds: float | MissingReason = self._solver_time(
|
|
581
|
+
result_sources,
|
|
582
|
+
native_solver,
|
|
583
|
+
None if error is not None else self._engine(solver),
|
|
584
|
+
solver_time_before,
|
|
585
|
+
)
|
|
586
|
+
statistics: dict[str, float | MissingReason] = self._statistics(result_sources)
|
|
587
|
+
native_metrics: dict[str, float] = self._native_metrics(
|
|
588
|
+
result_sources,
|
|
589
|
+
native_solver,
|
|
590
|
+
has_solution,
|
|
591
|
+
size.get("num_integer_variables"),
|
|
592
|
+
outcome.termination,
|
|
593
|
+
)
|
|
594
|
+
return ReportedMetrics(
|
|
595
|
+
has_objective=has_objective,
|
|
596
|
+
sense=sense,
|
|
597
|
+
objective_value=objective_value,
|
|
598
|
+
dual_bound=dual_bound,
|
|
599
|
+
solver_time_seconds=solver_time_seconds,
|
|
600
|
+
statistics=statistics,
|
|
601
|
+
native=native_metrics,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
def _metadata(
|
|
605
|
+
self,
|
|
606
|
+
solver: Any,
|
|
607
|
+
model: Any | None,
|
|
608
|
+
result: Any | None,
|
|
609
|
+
solve_keywords: Mapping[str, Any],
|
|
610
|
+
bound_arguments: Mapping[str, Any],
|
|
611
|
+
error: BaseException | None,
|
|
612
|
+
) -> ReportedMetadata:
|
|
613
|
+
problem: dict[str, int | float | str | bool] = {}
|
|
614
|
+
problem.update(self._model_size(model))
|
|
615
|
+
if not problem and error is None:
|
|
616
|
+
problem.update(self._result_size(result))
|
|
617
|
+
parameters: tuple[SolverParameter, ...] = self._parameters(
|
|
618
|
+
solver,
|
|
619
|
+
solve_keywords,
|
|
620
|
+
bound_arguments,
|
|
621
|
+
)
|
|
622
|
+
return ReportedMetadata(
|
|
623
|
+
solver=self._engine(solver),
|
|
624
|
+
api_version=self._api_version(),
|
|
625
|
+
solver_version=self._solver_version(
|
|
626
|
+
solver,
|
|
627
|
+
None if error is not None else result,
|
|
628
|
+
),
|
|
629
|
+
problem=problem,
|
|
630
|
+
parameters=parameters,
|
|
631
|
+
wall_time_includes_problem_build=True,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def _model_size(self, model: Any | None) -> dict[str, int]:
|
|
635
|
+
if model is None:
|
|
636
|
+
return {}
|
|
637
|
+
try:
|
|
638
|
+
variable_type: Any = importlib.import_module("pyomo.core.base.var").Var
|
|
639
|
+
variables: tuple[Any, ...] = tuple(
|
|
640
|
+
model.component_data_objects(variable_type, active=True)
|
|
641
|
+
)
|
|
642
|
+
except (ImportError, AttributeError):
|
|
643
|
+
variables = ()
|
|
644
|
+
variable_count_available: bool = False
|
|
645
|
+
except Exception: # noqa: BLE001
|
|
646
|
+
variables = ()
|
|
647
|
+
variable_count_available = False
|
|
648
|
+
else:
|
|
649
|
+
variable_count_available = True
|
|
650
|
+
try:
|
|
651
|
+
constraint_type: Any = importlib.import_module(
|
|
652
|
+
"pyomo.core.base.constraint"
|
|
653
|
+
).Constraint
|
|
654
|
+
constraints: tuple[Any, ...] = tuple(
|
|
655
|
+
model.component_data_objects(constraint_type, active=True)
|
|
656
|
+
)
|
|
657
|
+
except (ImportError, AttributeError):
|
|
658
|
+
constraints = ()
|
|
659
|
+
constraint_count_available: bool = False
|
|
660
|
+
except Exception: # noqa: BLE001
|
|
661
|
+
constraints = ()
|
|
662
|
+
constraint_count_available = False
|
|
663
|
+
else:
|
|
664
|
+
constraint_count_available = True
|
|
665
|
+
integer_count: int = 0
|
|
666
|
+
integer_count_available: bool = variable_count_available
|
|
667
|
+
if variable_count_available:
|
|
668
|
+
for variable in variables:
|
|
669
|
+
try:
|
|
670
|
+
is_integer: bool = bool(variable.is_integer())
|
|
671
|
+
except (AttributeError, TypeError):
|
|
672
|
+
integer_count_available = False
|
|
673
|
+
except Exception: # noqa: BLE001
|
|
674
|
+
integer_count_available = False
|
|
675
|
+
else:
|
|
676
|
+
integer_count += int(is_integer)
|
|
677
|
+
problem: dict[str, int] = {}
|
|
678
|
+
if variable_count_available:
|
|
679
|
+
problem["num_variables"] = len(variables)
|
|
680
|
+
if constraint_count_available:
|
|
681
|
+
problem["num_constraints"] = len(constraints)
|
|
682
|
+
if integer_count_available:
|
|
683
|
+
problem["num_integer_variables"] = integer_count
|
|
684
|
+
return problem
|
|
685
|
+
|
|
686
|
+
def _model_objects(self, model: Any | None) -> tuple[Any, ...] | None:
|
|
687
|
+
if model is None:
|
|
688
|
+
return None
|
|
689
|
+
try:
|
|
690
|
+
component_type: Any = importlib.import_module(
|
|
691
|
+
"pyomo.core.base.objective"
|
|
692
|
+
).Objective
|
|
693
|
+
objects: tuple[Any, ...] = tuple(
|
|
694
|
+
model.component_data_objects(component_type, active=True)
|
|
695
|
+
)
|
|
696
|
+
except Exception: # noqa: BLE001
|
|
697
|
+
return None
|
|
698
|
+
return objects
|
|
699
|
+
|
|
700
|
+
def _sense(self, objectives: Sequence[Any]) -> ObjectiveSense | None:
|
|
701
|
+
if len(objectives) != 1:
|
|
702
|
+
return None
|
|
703
|
+
objective_sense: Any | None = self._read(objectives[0], "sense")[0]
|
|
704
|
+
if objective_sense == 1:
|
|
705
|
+
return ObjectiveSense.MINIMIZE
|
|
706
|
+
if objective_sense == -1:
|
|
707
|
+
return ObjectiveSense.MAXIMIZE
|
|
708
|
+
return None
|
|
709
|
+
|
|
710
|
+
def _objective_value(
|
|
711
|
+
self,
|
|
712
|
+
result_sources: Sequence[Any],
|
|
713
|
+
native_solver: Any | None,
|
|
714
|
+
) -> float | MissingReason:
|
|
715
|
+
for result_source in result_sources:
|
|
716
|
+
for field_name in ("best_feasible_objective", "incumbent_objective"):
|
|
717
|
+
value: Any | None = self._read(result_source, field_name)[0]
|
|
718
|
+
number: float | None = self._native_number(value, native_solver)
|
|
719
|
+
if number is not None and math.isfinite(number):
|
|
720
|
+
return number
|
|
721
|
+
return MissingReason.UNSUPPORTED
|
|
722
|
+
|
|
723
|
+
def _solution_objective(
|
|
724
|
+
self,
|
|
725
|
+
result_sources: Sequence[Any],
|
|
726
|
+
native_solver: Any | None,
|
|
727
|
+
) -> float | MissingReason:
|
|
728
|
+
for result_source in result_sources:
|
|
729
|
+
solutions: Any | None = self._read(result_source, "solution")[0]
|
|
730
|
+
if solutions is None:
|
|
731
|
+
continue
|
|
732
|
+
first_solution: Any | None = None
|
|
733
|
+
try:
|
|
734
|
+
if len(solutions) > 0:
|
|
735
|
+
first_solution = solutions[0]
|
|
736
|
+
except Exception: # noqa: BLE001
|
|
737
|
+
first_solution = None
|
|
738
|
+
if first_solution is None:
|
|
739
|
+
continue
|
|
740
|
+
objectives: Any | None = self._read(first_solution, "objective")[0]
|
|
741
|
+
if isinstance(objectives, Mapping):
|
|
742
|
+
if len(objectives) != 1:
|
|
743
|
+
continue
|
|
744
|
+
objectives = next(iter(objectives.values()))
|
|
745
|
+
number: float | None = self._native_number(objectives, native_solver)
|
|
746
|
+
if number is not None and math.isfinite(number):
|
|
747
|
+
return number
|
|
748
|
+
return MissingReason.UNSUPPORTED
|
|
749
|
+
|
|
750
|
+
def _problem_objective(
|
|
751
|
+
self,
|
|
752
|
+
result_sources: Sequence[Any],
|
|
753
|
+
sense: ObjectiveSense | None,
|
|
754
|
+
native_solver: Any | None,
|
|
755
|
+
) -> float | MissingReason:
|
|
756
|
+
bound_name: str | None = (
|
|
757
|
+
"Upper bound"
|
|
758
|
+
if sense is ObjectiveSense.MINIMIZE
|
|
759
|
+
else "Lower bound"
|
|
760
|
+
if sense is ObjectiveSense.MAXIMIZE
|
|
761
|
+
else None
|
|
762
|
+
)
|
|
763
|
+
if bound_name is None:
|
|
764
|
+
return MissingReason.UNSUPPORTED
|
|
765
|
+
value: Any | None = self._section_value(
|
|
766
|
+
result_sources, "problem", (bound_name,)
|
|
767
|
+
)[0]
|
|
768
|
+
number: float | None = self._native_number(value, native_solver)
|
|
769
|
+
if number is not None and math.isfinite(number):
|
|
770
|
+
return number
|
|
771
|
+
return MissingReason.UNSUPPORTED
|
|
772
|
+
|
|
773
|
+
def _dual_bound(
|
|
774
|
+
self,
|
|
775
|
+
result_sources: Sequence[Any],
|
|
776
|
+
sense: ObjectiveSense | None,
|
|
777
|
+
termination: Termination,
|
|
778
|
+
native_solver: Any | None,
|
|
779
|
+
) -> float | MissingReason:
|
|
780
|
+
if termination in (
|
|
781
|
+
Termination.INFEASIBLE,
|
|
782
|
+
Termination.INFEASIBLE_OR_UNBOUNDED,
|
|
783
|
+
):
|
|
784
|
+
return MissingReason.UNSUPPORTED
|
|
785
|
+
for result_source in result_sources:
|
|
786
|
+
for field_name in ("best_objective_bound", "objective_bound"):
|
|
787
|
+
value: Any | None = self._read(result_source, field_name)[0]
|
|
788
|
+
bound_number: float | None = self._native_number(value, native_solver)
|
|
789
|
+
if bound_number is not None:
|
|
790
|
+
if (
|
|
791
|
+
math.isinf(bound_number)
|
|
792
|
+
and termination is not Termination.UNBOUNDED
|
|
793
|
+
):
|
|
794
|
+
continue
|
|
795
|
+
return bound_number
|
|
796
|
+
bound_name: str | None = (
|
|
797
|
+
"Lower bound"
|
|
798
|
+
if sense is ObjectiveSense.MINIMIZE
|
|
799
|
+
else "Upper bound"
|
|
800
|
+
if sense is ObjectiveSense.MAXIMIZE
|
|
801
|
+
else None
|
|
802
|
+
)
|
|
803
|
+
if bound_name is None:
|
|
804
|
+
return MissingReason.UNSUPPORTED
|
|
805
|
+
bound: Any | None = self._section_value(
|
|
806
|
+
result_sources, "problem", (bound_name,)
|
|
807
|
+
)[0]
|
|
808
|
+
number: float | None = self._native_number(bound, native_solver)
|
|
809
|
+
if (
|
|
810
|
+
number is not None
|
|
811
|
+
and math.isinf(number)
|
|
812
|
+
and termination is not Termination.UNBOUNDED
|
|
813
|
+
):
|
|
814
|
+
return MissingReason.UNSUPPORTED
|
|
815
|
+
return number if number is not None else MissingReason.UNSUPPORTED
|
|
816
|
+
|
|
817
|
+
def _solver_time_snapshot(
|
|
818
|
+
self, solver: Any
|
|
819
|
+
) -> tuple[Any | None, float | None] | None:
|
|
820
|
+
native_solver: Any | None
|
|
821
|
+
native_reason: MissingReason | None
|
|
822
|
+
native_solver, native_reason = self._read(solver, "_solver_model")
|
|
823
|
+
if native_reason is MissingReason.FAILED:
|
|
824
|
+
return None
|
|
825
|
+
previous_result: Any | None = self._read(solver, "_last_results_object")[0]
|
|
826
|
+
native_solver_time: float | None = self._method_number(
|
|
827
|
+
native_solver,
|
|
828
|
+
"getRunTime",
|
|
829
|
+
)
|
|
830
|
+
if native_solver_time is not None:
|
|
831
|
+
return native_solver, native_solver_time
|
|
832
|
+
if previous_result is None and native_solver is None:
|
|
833
|
+
# NOTE: No native instance or cached result means this solver has no prior run.
|
|
834
|
+
return None, 0.0
|
|
835
|
+
engine: str | None = self._engine(solver)
|
|
836
|
+
if engine != "highs":
|
|
837
|
+
return native_solver, None
|
|
838
|
+
previous_timing_information: Any | None = self._read(
|
|
839
|
+
previous_result,
|
|
840
|
+
"timing_info",
|
|
841
|
+
)[0]
|
|
842
|
+
previous_timing_value: Any | None = self._read(
|
|
843
|
+
previous_timing_information,
|
|
844
|
+
"highs_time",
|
|
845
|
+
)[0]
|
|
846
|
+
if isinstance(previous_timing_value, timedelta):
|
|
847
|
+
return native_solver, float(previous_timing_value.total_seconds())
|
|
848
|
+
previous_solver_time: float | None = self._number(previous_timing_value)
|
|
849
|
+
return native_solver, previous_solver_time
|
|
850
|
+
|
|
851
|
+
def _solver_time(
|
|
852
|
+
self,
|
|
853
|
+
result_sources: Sequence[Any],
|
|
854
|
+
native_solver: Any | None,
|
|
855
|
+
engine: str | None,
|
|
856
|
+
solver_time_before: tuple[Any | None, float | None] | None,
|
|
857
|
+
) -> float | MissingReason:
|
|
858
|
+
if engine == "highs":
|
|
859
|
+
if solver_time_before is None:
|
|
860
|
+
return MissingReason.UNSUPPORTED
|
|
861
|
+
previous_solver: Any | None
|
|
862
|
+
previous_time: float | None
|
|
863
|
+
previous_solver, previous_time = solver_time_before
|
|
864
|
+
if native_solver is not None and native_solver is not previous_solver:
|
|
865
|
+
previous_time = 0.0
|
|
866
|
+
elif native_solver is None and (
|
|
867
|
+
previous_solver is not None or previous_time != 0.0
|
|
868
|
+
):
|
|
869
|
+
return MissingReason.UNSUPPORTED
|
|
870
|
+
if previous_time is None:
|
|
871
|
+
return MissingReason.UNSUPPORTED
|
|
872
|
+
cumulative_solver_time: float | None = self._method_number(
|
|
873
|
+
native_solver,
|
|
874
|
+
"getRunTime",
|
|
875
|
+
)
|
|
876
|
+
if cumulative_solver_time is None:
|
|
877
|
+
for result_source in result_sources:
|
|
878
|
+
result_timing_information: Any | None = self._read(
|
|
879
|
+
result_source,
|
|
880
|
+
"timing_info",
|
|
881
|
+
)[0]
|
|
882
|
+
timing_value: Any | None = self._read(
|
|
883
|
+
result_timing_information,
|
|
884
|
+
"highs_time",
|
|
885
|
+
)[0]
|
|
886
|
+
if isinstance(timing_value, timedelta):
|
|
887
|
+
cumulative_solver_time = float(timing_value.total_seconds())
|
|
888
|
+
else:
|
|
889
|
+
cumulative_solver_time = self._number(timing_value)
|
|
890
|
+
if cumulative_solver_time is not None:
|
|
891
|
+
break
|
|
892
|
+
if cumulative_solver_time is None or cumulative_solver_time < previous_time:
|
|
893
|
+
return MissingReason.UNSUPPORTED
|
|
894
|
+
return cumulative_solver_time - previous_time
|
|
895
|
+
if engine == "gurobi":
|
|
896
|
+
runtime: float | None = self._number(
|
|
897
|
+
self._read(native_solver, "Runtime")[0]
|
|
898
|
+
)
|
|
899
|
+
if runtime is not None:
|
|
900
|
+
return runtime
|
|
901
|
+
for result_source in result_sources:
|
|
902
|
+
timing_information: Any | None = self._read(
|
|
903
|
+
result_source,
|
|
904
|
+
"timing_info",
|
|
905
|
+
)[0]
|
|
906
|
+
for owner, name in (
|
|
907
|
+
(timing_information, "gurobi_time"),
|
|
908
|
+
(timing_information, "scip_time"),
|
|
909
|
+
):
|
|
910
|
+
value: Any | None = self._read(owner, name)[0]
|
|
911
|
+
if value is None:
|
|
912
|
+
continue
|
|
913
|
+
if isinstance(value, timedelta):
|
|
914
|
+
return float(value.total_seconds())
|
|
915
|
+
number: float | None = self._number(value)
|
|
916
|
+
if number is not None:
|
|
917
|
+
return number
|
|
918
|
+
return MissingReason.UNSUPPORTED
|
|
919
|
+
|
|
920
|
+
def _statistics(
|
|
921
|
+
self,
|
|
922
|
+
result_sources: Sequence[Any],
|
|
923
|
+
) -> dict[str, float | MissingReason]:
|
|
924
|
+
common_paths: Mapping[str, tuple[tuple[str, ...], ...]] = {
|
|
925
|
+
"node_count": (
|
|
926
|
+
("node_count",),
|
|
927
|
+
("NodeCount",),
|
|
928
|
+
("mip_node_count",),
|
|
929
|
+
("statistics", "node_count"),
|
|
930
|
+
("statistics", "branch_and_bound", "number_of_nodes"),
|
|
931
|
+
("extra_info", "NNodes"),
|
|
932
|
+
),
|
|
933
|
+
"simplex_iterations": (
|
|
934
|
+
("simplex_iterations",),
|
|
935
|
+
("simplex_iteration_count",),
|
|
936
|
+
("IterCount",),
|
|
937
|
+
("statistics", "simplex_iterations"),
|
|
938
|
+
("statistics", "black_box", "simplex_iterations"),
|
|
939
|
+
),
|
|
940
|
+
"barrier_iterations": (
|
|
941
|
+
("barrier_iterations",),
|
|
942
|
+
("ipm_iteration_count",),
|
|
943
|
+
("BarIterCount",),
|
|
944
|
+
("statistics", "barrier_iterations"),
|
|
945
|
+
("statistics", "black_box", "barrier_iterations"),
|
|
946
|
+
),
|
|
947
|
+
}
|
|
948
|
+
statistics: dict[str, float | MissingReason] = {}
|
|
949
|
+
owners: list[Any | None] = list(result_sources)
|
|
950
|
+
for result_source in result_sources:
|
|
951
|
+
solver_result: Any | None = self._read(result_source, "solver")[0]
|
|
952
|
+
if solver_result is not None:
|
|
953
|
+
owners.append(solver_result)
|
|
954
|
+
for statistic_name, paths in common_paths.items():
|
|
955
|
+
value: float | None = self._first_number(owners, paths)
|
|
956
|
+
if value is not None and value >= 0:
|
|
957
|
+
statistics[statistic_name] = value
|
|
958
|
+
return statistics
|
|
959
|
+
|
|
960
|
+
def _native_metrics(
|
|
961
|
+
self,
|
|
962
|
+
result_sources: Sequence[Any],
|
|
963
|
+
native_solver: Any | None,
|
|
964
|
+
has_solution: bool | None,
|
|
965
|
+
integer_variable_count: int | None,
|
|
966
|
+
termination: Termination,
|
|
967
|
+
) -> dict[str, float]:
|
|
968
|
+
native_paths: Mapping[str, tuple[tuple[str, ...], ...]] = {
|
|
969
|
+
"reported_gap": (
|
|
970
|
+
("gap",),
|
|
971
|
+
("mip_gap",),
|
|
972
|
+
("MIPGap",),
|
|
973
|
+
("statistics", "gap"),
|
|
974
|
+
),
|
|
975
|
+
"solution_count": (
|
|
976
|
+
("solution_count",),
|
|
977
|
+
("number_of_solutions",),
|
|
978
|
+
("SolCount",),
|
|
979
|
+
),
|
|
980
|
+
"created_subproblems": (
|
|
981
|
+
("statistics", "branch_and_bound", "number_of_created_subproblems"),
|
|
982
|
+
),
|
|
983
|
+
}
|
|
984
|
+
native: dict[str, float] = {}
|
|
985
|
+
owners: list[Any | None] = list(result_sources)
|
|
986
|
+
for result_source in result_sources:
|
|
987
|
+
solver_result: Any | None = self._read(result_source, "solver")[0]
|
|
988
|
+
if solver_result is not None:
|
|
989
|
+
owners.append(solver_result)
|
|
990
|
+
for metric_name, paths in native_paths.items():
|
|
991
|
+
if metric_name == "reported_gap" and (
|
|
992
|
+
has_solution is not True
|
|
993
|
+
or integer_variable_count is None
|
|
994
|
+
or integer_variable_count == 0
|
|
995
|
+
or termination
|
|
996
|
+
in (
|
|
997
|
+
Termination.UNBOUNDED,
|
|
998
|
+
Termination.INFEASIBLE,
|
|
999
|
+
Termination.INFEASIBLE_OR_UNBOUNDED,
|
|
1000
|
+
)
|
|
1001
|
+
):
|
|
1002
|
+
continue
|
|
1003
|
+
value: float | None = self._first_number(owners, paths)
|
|
1004
|
+
if metric_name == "reported_gap":
|
|
1005
|
+
value = self._native_number(value, native_solver)
|
|
1006
|
+
if value is not None:
|
|
1007
|
+
native[metric_name] = value
|
|
1008
|
+
for result_source in result_sources:
|
|
1009
|
+
solution_count: float | None = self._solution_count(result_source)
|
|
1010
|
+
if solution_count is not None:
|
|
1011
|
+
native["solution_count"] = solution_count
|
|
1012
|
+
break
|
|
1013
|
+
return native
|
|
1014
|
+
|
|
1015
|
+
def _method_number(self, owner: Any | None, name: str) -> float | None:
|
|
1016
|
+
method: Any | None = self._read(owner, name)[0]
|
|
1017
|
+
if not callable(method):
|
|
1018
|
+
return None
|
|
1019
|
+
try:
|
|
1020
|
+
value: Any = method()
|
|
1021
|
+
except AttributeError:
|
|
1022
|
+
return None
|
|
1023
|
+
except Exception: # noqa: BLE001
|
|
1024
|
+
return None
|
|
1025
|
+
return self._number(value)
|
|
1026
|
+
|
|
1027
|
+
def _first_number(
|
|
1028
|
+
self,
|
|
1029
|
+
owners: Sequence[Any | None],
|
|
1030
|
+
paths: Sequence[tuple[str, ...]],
|
|
1031
|
+
) -> float | None:
|
|
1032
|
+
for owner in owners:
|
|
1033
|
+
for path in paths:
|
|
1034
|
+
value: Any | None = owner
|
|
1035
|
+
for name in path:
|
|
1036
|
+
value = self._read(value, name)[0]
|
|
1037
|
+
if value is None:
|
|
1038
|
+
break
|
|
1039
|
+
number: float | None = self._number(value)
|
|
1040
|
+
if number is not None:
|
|
1041
|
+
return number
|
|
1042
|
+
return None
|
|
1043
|
+
|
|
1044
|
+
def _result_sources(self, solver: Any, result: Any | None) -> tuple[Any, ...]:
|
|
1045
|
+
if result is None:
|
|
1046
|
+
return ()
|
|
1047
|
+
sources: list[Any] = []
|
|
1048
|
+
sources.append(result)
|
|
1049
|
+
cached_result: Any | None = self._read(solver, "_last_results_object")[0]
|
|
1050
|
+
if cached_result is not None and all(
|
|
1051
|
+
cached_result is not source for source in sources
|
|
1052
|
+
):
|
|
1053
|
+
sources.append(cached_result)
|
|
1054
|
+
return tuple(sources)
|
|
1055
|
+
|
|
1056
|
+
def _first_value(self, sources: Sequence[Any], field_name: str) -> Any | None:
|
|
1057
|
+
for source in sources:
|
|
1058
|
+
value: Any | None = self._read(source, field_name)[0]
|
|
1059
|
+
if value is not None:
|
|
1060
|
+
return value
|
|
1061
|
+
return None
|
|
1062
|
+
|
|
1063
|
+
def _solution_count(self, result: Any) -> float | None:
|
|
1064
|
+
count_names: tuple[str, ...] = (
|
|
1065
|
+
"Number of solutions",
|
|
1066
|
+
"number of solutions",
|
|
1067
|
+
"solution_count",
|
|
1068
|
+
"number_of_solutions",
|
|
1069
|
+
)
|
|
1070
|
+
value: Any | None
|
|
1071
|
+
value_reason: MissingReason | None
|
|
1072
|
+
value, value_reason = self._section_value((result,), "problem", count_names)
|
|
1073
|
+
if value_reason is None:
|
|
1074
|
+
return self._number(value)
|
|
1075
|
+
value, value_reason = self._section_value((result,), "solution", count_names)
|
|
1076
|
+
if value_reason is None:
|
|
1077
|
+
return self._number(value)
|
|
1078
|
+
return None
|
|
1079
|
+
|
|
1080
|
+
def _section_value(
|
|
1081
|
+
self,
|
|
1082
|
+
result_sources: Sequence[Any],
|
|
1083
|
+
section_name: str,
|
|
1084
|
+
value_names: Sequence[str],
|
|
1085
|
+
) -> tuple[Any | None, MissingReason | None]:
|
|
1086
|
+
for result_source in result_sources:
|
|
1087
|
+
section: Any | None = self._read(result_source, section_name)[0]
|
|
1088
|
+
if section is None:
|
|
1089
|
+
continue
|
|
1090
|
+
section_items: tuple[Any, ...] = self._section_items(section)
|
|
1091
|
+
for section_item in section_items:
|
|
1092
|
+
for value_name in value_names:
|
|
1093
|
+
value: Any | None
|
|
1094
|
+
value_reason: MissingReason | None
|
|
1095
|
+
value, value_reason = self._read_key(section_item, value_name)
|
|
1096
|
+
if value_reason is None or value_reason is MissingReason.UNDEFINED:
|
|
1097
|
+
return value, value_reason
|
|
1098
|
+
return None, MissingReason.UNSUPPORTED
|
|
1099
|
+
|
|
1100
|
+
def _section_items(self, section: Any) -> tuple[Any, ...]:
|
|
1101
|
+
if isinstance(section, Mapping):
|
|
1102
|
+
return (section,)
|
|
1103
|
+
item_count: int | None = None
|
|
1104
|
+
try:
|
|
1105
|
+
item_count = len(section)
|
|
1106
|
+
except Exception: # noqa: BLE001
|
|
1107
|
+
item_count = None
|
|
1108
|
+
if item_count is None:
|
|
1109
|
+
return ()
|
|
1110
|
+
items: list[Any] = []
|
|
1111
|
+
for index in range(item_count):
|
|
1112
|
+
item: Any | None = None
|
|
1113
|
+
try:
|
|
1114
|
+
item = section[index]
|
|
1115
|
+
except Exception: # noqa: BLE001
|
|
1116
|
+
item = None
|
|
1117
|
+
if item is not None:
|
|
1118
|
+
items.append(item)
|
|
1119
|
+
return tuple(items)
|
|
1120
|
+
|
|
1121
|
+
def _read_key(
|
|
1122
|
+
self,
|
|
1123
|
+
owner: Any,
|
|
1124
|
+
key: str,
|
|
1125
|
+
) -> tuple[Any | None, MissingReason | None]:
|
|
1126
|
+
if not isinstance(owner, Mapping):
|
|
1127
|
+
return None, MissingReason.UNSUPPORTED
|
|
1128
|
+
try:
|
|
1129
|
+
if key not in owner:
|
|
1130
|
+
return None, MissingReason.UNSUPPORTED
|
|
1131
|
+
value: Any = owner[key]
|
|
1132
|
+
except (AttributeError, KeyError, TypeError):
|
|
1133
|
+
return None, MissingReason.FAILED
|
|
1134
|
+
except Exception: # noqa: BLE001
|
|
1135
|
+
return None, MissingReason.FAILED
|
|
1136
|
+
if value is None:
|
|
1137
|
+
return None, MissingReason.UNDEFINED
|
|
1138
|
+
return value, None
|
|
1139
|
+
|
|
1140
|
+
def _result_size(self, result: Any | None) -> dict[str, int | float | str | bool]:
|
|
1141
|
+
result_keys: Mapping[str, tuple[str, ...]] = {
|
|
1142
|
+
"num_variables": ("Number of variables", "number_of_variables"),
|
|
1143
|
+
"num_constraints": ("Number of constraints", "number_of_constraints"),
|
|
1144
|
+
"num_integer_variables": (
|
|
1145
|
+
"Number of integer variables",
|
|
1146
|
+
"number_of_integer_variables",
|
|
1147
|
+
),
|
|
1148
|
+
"num_binary_variables": (
|
|
1149
|
+
"Number of binary variables",
|
|
1150
|
+
"number_of_binary_variables",
|
|
1151
|
+
),
|
|
1152
|
+
}
|
|
1153
|
+
problem: dict[str, int | float | str | bool] = {}
|
|
1154
|
+
for common_key, result_names in result_keys.items():
|
|
1155
|
+
value: Any | None
|
|
1156
|
+
value_reason: MissingReason | None
|
|
1157
|
+
value, value_reason = self._section_value(
|
|
1158
|
+
(result,), "problem", result_names
|
|
1159
|
+
)
|
|
1160
|
+
if value_reason is not None:
|
|
1161
|
+
continue
|
|
1162
|
+
number: float | None = self._number(value)
|
|
1163
|
+
if number is not None and math.isfinite(number) and number >= 0:
|
|
1164
|
+
problem[common_key] = int(number)
|
|
1165
|
+
return problem
|
|
1166
|
+
|
|
1167
|
+
def _parameters(
|
|
1168
|
+
self,
|
|
1169
|
+
solver: Any,
|
|
1170
|
+
solve_keywords: Mapping[str, Any],
|
|
1171
|
+
bound_arguments: Mapping[str, Any],
|
|
1172
|
+
) -> tuple[SolverParameter, ...]:
|
|
1173
|
+
values: dict[str, int | float | str | bool] = {}
|
|
1174
|
+
# NOTE: Unknown and Gurobi string options can contain credentials or paths.
|
|
1175
|
+
engine: str | None = self._engine(solver)
|
|
1176
|
+
include_string_values: bool = engine is not None and engine != "gurobi"
|
|
1177
|
+
options: Any | None = self._read(solver, "options")[0]
|
|
1178
|
+
self._add_parameter_mapping(
|
|
1179
|
+
values,
|
|
1180
|
+
options,
|
|
1181
|
+
include_string_values=include_string_values,
|
|
1182
|
+
)
|
|
1183
|
+
solver_options: Any | None = self._read(solver, "_solver_options")[0]
|
|
1184
|
+
self._add_parameter_mapping(
|
|
1185
|
+
values,
|
|
1186
|
+
solver_options,
|
|
1187
|
+
include_string_values=include_string_values,
|
|
1188
|
+
)
|
|
1189
|
+
configuration: Any | None = self._read(solver, "config")[0]
|
|
1190
|
+
configuration_template: Any | None = self._read(type(solver), "CONFIG")[0]
|
|
1191
|
+
if configuration_template is None and configuration is not None:
|
|
1192
|
+
try:
|
|
1193
|
+
configuration_template = type(configuration)()
|
|
1194
|
+
except TypeError:
|
|
1195
|
+
configuration_template = None
|
|
1196
|
+
user_values_member: Any | None = self._read(configuration, "user_values")[0]
|
|
1197
|
+
if callable(user_values_member):
|
|
1198
|
+
try:
|
|
1199
|
+
configuration_values: Any = tuple(
|
|
1200
|
+
cast(Iterable[Any], user_values_member())
|
|
1201
|
+
)
|
|
1202
|
+
except Exception: # noqa: BLE001
|
|
1203
|
+
configuration_values = None
|
|
1204
|
+
else:
|
|
1205
|
+
configuration_values = user_values_member
|
|
1206
|
+
self._add_parameter_mapping(
|
|
1207
|
+
values,
|
|
1208
|
+
configuration_values,
|
|
1209
|
+
PARAMETER_CONTROL_NAMES,
|
|
1210
|
+
configuration_template,
|
|
1211
|
+
include_string_values,
|
|
1212
|
+
)
|
|
1213
|
+
for keyword_name in ("options", "solver_options"):
|
|
1214
|
+
self._add_parameter_mapping(
|
|
1215
|
+
values,
|
|
1216
|
+
solve_keywords.get(keyword_name),
|
|
1217
|
+
include_string_values=include_string_values,
|
|
1218
|
+
)
|
|
1219
|
+
self._add_parameter_mapping(
|
|
1220
|
+
values,
|
|
1221
|
+
solve_keywords,
|
|
1222
|
+
PARAMETER_CONTROL_NAMES,
|
|
1223
|
+
include_string_values=include_string_values,
|
|
1224
|
+
)
|
|
1225
|
+
for argument_name, argument_value in bound_arguments.items():
|
|
1226
|
+
if argument_value is solver or argument_name in (
|
|
1227
|
+
"model",
|
|
1228
|
+
"args",
|
|
1229
|
+
"kwargs",
|
|
1230
|
+
"kwds",
|
|
1231
|
+
):
|
|
1232
|
+
continue
|
|
1233
|
+
if argument_name in ("options", "solver_options"):
|
|
1234
|
+
self._add_parameter_mapping(
|
|
1235
|
+
values,
|
|
1236
|
+
argument_value,
|
|
1237
|
+
include_string_values=include_string_values,
|
|
1238
|
+
)
|
|
1239
|
+
elif argument_name not in PARAMETER_CONTROL_NAMES:
|
|
1240
|
+
self._add_parameter_mapping(
|
|
1241
|
+
values,
|
|
1242
|
+
((argument_name, argument_value),),
|
|
1243
|
+
include_string_values=include_string_values,
|
|
1244
|
+
)
|
|
1245
|
+
return tuple(
|
|
1246
|
+
SolverParameter(name, value, ParameterOrigin.EXPLICIT)
|
|
1247
|
+
for name, value in sorted(values.items())
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
def _add_parameter_mapping(
|
|
1251
|
+
self,
|
|
1252
|
+
target: dict[str, int | float | str | bool],
|
|
1253
|
+
values: Any | None,
|
|
1254
|
+
excluded_names: frozenset[str] = frozenset(),
|
|
1255
|
+
configuration_template: Any | None = None,
|
|
1256
|
+
include_string_values: bool = True,
|
|
1257
|
+
) -> None:
|
|
1258
|
+
if isinstance(values, Mapping):
|
|
1259
|
+
parameter_items: Any = values.items()
|
|
1260
|
+
elif isinstance(values, Sequence) and not isinstance(values, (str, bytes)):
|
|
1261
|
+
parameter_items = values
|
|
1262
|
+
else:
|
|
1263
|
+
return
|
|
1264
|
+
for parameter_item in parameter_items:
|
|
1265
|
+
if (
|
|
1266
|
+
isinstance(parameter_item, Sequence)
|
|
1267
|
+
and not isinstance(
|
|
1268
|
+
parameter_item,
|
|
1269
|
+
(str, bytes),
|
|
1270
|
+
)
|
|
1271
|
+
and len(parameter_item) == 2
|
|
1272
|
+
):
|
|
1273
|
+
name: Any = parameter_item[0]
|
|
1274
|
+
value: Any = parameter_item[1]
|
|
1275
|
+
else:
|
|
1276
|
+
name = self._read(parameter_item, "_name")[0]
|
|
1277
|
+
if name is None:
|
|
1278
|
+
name = self._read(parameter_item, "name")[0]
|
|
1279
|
+
value = self._read(parameter_item, "value")[0]
|
|
1280
|
+
if callable(value):
|
|
1281
|
+
try:
|
|
1282
|
+
value = value(accessValue=False)
|
|
1283
|
+
except Exception: # noqa: BLE001
|
|
1284
|
+
value = None
|
|
1285
|
+
default: Any | None = self._read(parameter_item, "_default")[0]
|
|
1286
|
+
is_user_set: Any | None = self._read(parameter_item, "_userSet")[0]
|
|
1287
|
+
template_default: Any | None
|
|
1288
|
+
template_reason: MissingReason | None
|
|
1289
|
+
template_default, template_reason = self._read(
|
|
1290
|
+
configuration_template,
|
|
1291
|
+
str(name),
|
|
1292
|
+
)
|
|
1293
|
+
same_as_template_default: bool = (
|
|
1294
|
+
template_reason in (None, MissingReason.UNDEFINED)
|
|
1295
|
+
and value == template_default
|
|
1296
|
+
)
|
|
1297
|
+
same_as_config_default: bool = (
|
|
1298
|
+
default is not None and not callable(default) and value == default
|
|
1299
|
+
)
|
|
1300
|
+
if (
|
|
1301
|
+
is_user_set is False
|
|
1302
|
+
or same_as_template_default
|
|
1303
|
+
or same_as_config_default
|
|
1304
|
+
):
|
|
1305
|
+
continue
|
|
1306
|
+
parameter_name: str | None = str(name) if name is not None else None
|
|
1307
|
+
if (
|
|
1308
|
+
isinstance(value, (int, float, str, bool))
|
|
1309
|
+
and (include_string_values or not isinstance(value, str))
|
|
1310
|
+
and parameter_name is not None
|
|
1311
|
+
and parameter_name not in excluded_names
|
|
1312
|
+
and parameter_name.casefold() != "licenseid"
|
|
1313
|
+
):
|
|
1314
|
+
target[parameter_name] = value
|
|
1315
|
+
|
|
1316
|
+
def _engine(self, solver: Any) -> str | None:
|
|
1317
|
+
candidates: list[Any] = []
|
|
1318
|
+
for attribute_name in (
|
|
1319
|
+
"_solver_model",
|
|
1320
|
+
"_solver_instance",
|
|
1321
|
+
"_solver",
|
|
1322
|
+
"solver",
|
|
1323
|
+
"_opt",
|
|
1324
|
+
):
|
|
1325
|
+
nested_solver: Any | None = self._read(solver, attribute_name)[0]
|
|
1326
|
+
if nested_solver is not None and not isinstance(
|
|
1327
|
+
nested_solver, (str, int, float, bool)
|
|
1328
|
+
):
|
|
1329
|
+
candidates.append(nested_solver)
|
|
1330
|
+
candidates.append(solver)
|
|
1331
|
+
for candidate in candidates:
|
|
1332
|
+
for candidate_type in type(candidate).__mro__:
|
|
1333
|
+
engine: str | None = self._engine_types.get(candidate_type)
|
|
1334
|
+
if engine is not None:
|
|
1335
|
+
return engine
|
|
1336
|
+
return None
|
|
1337
|
+
|
|
1338
|
+
def _api_version(self) -> str | None:
|
|
1339
|
+
try:
|
|
1340
|
+
version_module: Any = importlib.import_module("pyomo.version")
|
|
1341
|
+
except ImportError:
|
|
1342
|
+
return None
|
|
1343
|
+
version: Any | None = self._read(version_module, "version")[0]
|
|
1344
|
+
return self._string(version)
|
|
1345
|
+
|
|
1346
|
+
def _solver_version(self, solver: Any, result: Any | None) -> str | None:
|
|
1347
|
+
native_solver: Any | None = self._read(solver, "_solver_model")[0]
|
|
1348
|
+
engine: str | None = self._engine(solver)
|
|
1349
|
+
if native_solver is not None and engine == "scip":
|
|
1350
|
+
version_parts: tuple[float | None, ...] = tuple(
|
|
1351
|
+
self._method_number(native_solver, method_name)
|
|
1352
|
+
for method_name in (
|
|
1353
|
+
"getMajorVersion",
|
|
1354
|
+
"getMinorVersion",
|
|
1355
|
+
"getTechVersion",
|
|
1356
|
+
)
|
|
1357
|
+
)
|
|
1358
|
+
if all(version_part is not None for version_part in version_parts):
|
|
1359
|
+
checked_version_parts = cast(tuple[float, float, float], version_parts)
|
|
1360
|
+
return ".".join(str(int(part)) for part in checked_version_parts)
|
|
1361
|
+
if native_solver is not None:
|
|
1362
|
+
for method_name in ("version", "getVersion"):
|
|
1363
|
+
version_member: Any | None = self._read(native_solver, method_name)[0]
|
|
1364
|
+
if callable(version_member):
|
|
1365
|
+
native_version: Any | None = None
|
|
1366
|
+
try:
|
|
1367
|
+
native_version = version_member()
|
|
1368
|
+
except Exception: # noqa: BLE001
|
|
1369
|
+
native_version = None
|
|
1370
|
+
if native_version is not None:
|
|
1371
|
+
native_version_string: str | None = self._string(native_version)
|
|
1372
|
+
if native_version_string is not None:
|
|
1373
|
+
return native_version_string
|
|
1374
|
+
if engine == "scip":
|
|
1375
|
+
return None
|
|
1376
|
+
solver_version: Any | None = self._read(solver, "_version")[0]
|
|
1377
|
+
if solver_version is not None:
|
|
1378
|
+
configured_solver_version: str | None = self._string(solver_version)
|
|
1379
|
+
if configured_solver_version is not None:
|
|
1380
|
+
return configured_solver_version
|
|
1381
|
+
engine_module: str | None = None
|
|
1382
|
+
if native_solver is not None:
|
|
1383
|
+
engine_module = type(native_solver).__module__.split(".", 1)[0]
|
|
1384
|
+
if engine_module is not None:
|
|
1385
|
+
try:
|
|
1386
|
+
module: Any = importlib.import_module(engine_module)
|
|
1387
|
+
except ImportError:
|
|
1388
|
+
module = None
|
|
1389
|
+
module_version: Any | None = self._read(module, "__version__")[0]
|
|
1390
|
+
if module_version is not None:
|
|
1391
|
+
module_solver_version: str | None = self._string(module_version)
|
|
1392
|
+
if module_solver_version is not None:
|
|
1393
|
+
return module_solver_version
|
|
1394
|
+
result_sources: tuple[Any, ...] = self._result_sources(solver, result)
|
|
1395
|
+
for result_source in result_sources:
|
|
1396
|
+
solver_result: Any | None = self._read(result_source, "solver")[0]
|
|
1397
|
+
for owner in (solver_result, result_source):
|
|
1398
|
+
for name in ("version", "solver_version"):
|
|
1399
|
+
value: Any | None = self._read(owner, name)[0]
|
|
1400
|
+
if value is None or callable(value):
|
|
1401
|
+
continue
|
|
1402
|
+
version = self._string(value)
|
|
1403
|
+
if version is not None:
|
|
1404
|
+
return version
|
|
1405
|
+
if engine == "highs":
|
|
1406
|
+
version_method: Any | None = self._read(solver, "version")[0]
|
|
1407
|
+
if callable(version_method):
|
|
1408
|
+
try:
|
|
1409
|
+
version_value: Any = version_method()
|
|
1410
|
+
except Exception: # noqa: BLE001
|
|
1411
|
+
return None
|
|
1412
|
+
return self._string(version_value)
|
|
1413
|
+
return None
|
|
1414
|
+
|
|
1415
|
+
def _read(
|
|
1416
|
+
self, owner: Any | None, name: str
|
|
1417
|
+
) -> tuple[Any | None, MissingReason | None]:
|
|
1418
|
+
if owner is None:
|
|
1419
|
+
return None, MissingReason.UNSUPPORTED
|
|
1420
|
+
try:
|
|
1421
|
+
value: Any = getattr(owner, name)
|
|
1422
|
+
except AttributeError:
|
|
1423
|
+
return None, MissingReason.UNSUPPORTED
|
|
1424
|
+
except Exception: # noqa: BLE001
|
|
1425
|
+
return None, MissingReason.FAILED
|
|
1426
|
+
if value is None:
|
|
1427
|
+
return None, MissingReason.UNDEFINED
|
|
1428
|
+
return value, None
|
|
1429
|
+
|
|
1430
|
+
def _native_name(self, value: Any | None) -> str | None:
|
|
1431
|
+
if value is None:
|
|
1432
|
+
return None
|
|
1433
|
+
native_name: Any | None = self._read(value, "name")[0]
|
|
1434
|
+
if isinstance(native_name, str):
|
|
1435
|
+
return native_name
|
|
1436
|
+
if isinstance(value, str):
|
|
1437
|
+
return value
|
|
1438
|
+
try:
|
|
1439
|
+
return str(value)
|
|
1440
|
+
except (TypeError, ValueError):
|
|
1441
|
+
return None
|
|
1442
|
+
except Exception: # noqa: BLE001
|
|
1443
|
+
return None
|
|
1444
|
+
|
|
1445
|
+
def _string(self, value: Any | None) -> str | None:
|
|
1446
|
+
if value is None:
|
|
1447
|
+
return None
|
|
1448
|
+
try:
|
|
1449
|
+
return str(value)
|
|
1450
|
+
except (TypeError, ValueError):
|
|
1451
|
+
return None
|
|
1452
|
+
except Exception: # noqa: BLE001
|
|
1453
|
+
return None
|
|
1454
|
+
|
|
1455
|
+
def _number(self, value: Any | None) -> float | None:
|
|
1456
|
+
if value is None or isinstance(value, bool):
|
|
1457
|
+
return None
|
|
1458
|
+
try:
|
|
1459
|
+
number: float = float(value)
|
|
1460
|
+
except Exception: # noqa: BLE001
|
|
1461
|
+
return None
|
|
1462
|
+
return number
|
|
1463
|
+
|
|
1464
|
+
def _native_number(
|
|
1465
|
+
self,
|
|
1466
|
+
value: Any | None,
|
|
1467
|
+
native_solver: Any | None,
|
|
1468
|
+
) -> float | None:
|
|
1469
|
+
number: float | None = self._number(value)
|
|
1470
|
+
if number is None or math.isinf(number):
|
|
1471
|
+
return number
|
|
1472
|
+
solver_infinity: float | None = self._solver_infinity(native_solver)
|
|
1473
|
+
if (
|
|
1474
|
+
solver_infinity is not None
|
|
1475
|
+
and math.isfinite(solver_infinity)
|
|
1476
|
+
and abs(number) == abs(solver_infinity)
|
|
1477
|
+
):
|
|
1478
|
+
return math.copysign(math.inf, number)
|
|
1479
|
+
return number
|
|
1480
|
+
|
|
1481
|
+
def _solver_infinity(self, native_solver: Any | None) -> float | None:
|
|
1482
|
+
if native_solver is None:
|
|
1483
|
+
return None
|
|
1484
|
+
engine: str | None = self._engine(native_solver)
|
|
1485
|
+
if engine == "highs":
|
|
1486
|
+
return self._method_number(native_solver, "getInfinity")
|
|
1487
|
+
if engine == "scip":
|
|
1488
|
+
return self._method_number(native_solver, "infinity")
|
|
1489
|
+
if engine == "gurobi":
|
|
1490
|
+
try:
|
|
1491
|
+
engine_module: Any = importlib.import_module("gurobipy")
|
|
1492
|
+
except ImportError:
|
|
1493
|
+
return None
|
|
1494
|
+
except Exception: # noqa: BLE001
|
|
1495
|
+
return None
|
|
1496
|
+
grb_namespace: Any | None = self._read(engine_module, "GRB")[0]
|
|
1497
|
+
infinity_value: Any | None = self._read(grb_namespace, "INFINITY")[0]
|
|
1498
|
+
return self._number(infinity_value)
|
|
1499
|
+
return None
|