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,939 @@
|
|
|
1
|
+
"""The MathOpt solver integration."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from inspect import Signature, signature
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
import ortools
|
|
13
|
+
from ortools.math_opt.python import mathopt
|
|
14
|
+
from ortools.math_opt.python import solve as mathopt_solve
|
|
15
|
+
from ortools.math_opt.python.model import Model
|
|
16
|
+
from ortools.math_opt.python.objectives import Objective
|
|
17
|
+
from ortools.math_opt.python.parameters import SolveParameters, SolverType
|
|
18
|
+
from ortools.math_opt.python.result import SolveResult
|
|
19
|
+
|
|
20
|
+
from mlflow_optimization.common.patching import (
|
|
21
|
+
Patch,
|
|
22
|
+
rebind_module_names,
|
|
23
|
+
receiver_of,
|
|
24
|
+
restore_module_names,
|
|
25
|
+
)
|
|
26
|
+
from mlflow_optimization.features.autolog.ports import ObservationError, SolveObserver
|
|
27
|
+
from mlflow_optimization.features.autolog.types import SolveObservation
|
|
28
|
+
from mlflow_optimization.features.metadata.types import (
|
|
29
|
+
ParameterOrigin,
|
|
30
|
+
ReportedMetadata,
|
|
31
|
+
SolverParameter,
|
|
32
|
+
)
|
|
33
|
+
from mlflow_optimization.features.metrics.types import (
|
|
34
|
+
MissingReason,
|
|
35
|
+
ObjectiveSense,
|
|
36
|
+
ReportedMetrics,
|
|
37
|
+
)
|
|
38
|
+
from mlflow_optimization.features.outcomes.types import ReportedOutcome, Termination
|
|
39
|
+
|
|
40
|
+
ParameterValue = int | float | str | bool
|
|
41
|
+
_SDK_EXCEPTION: type[Exception] = Exception
|
|
42
|
+
_UNAVAILABLE: object = object()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MathOptAdapter:
|
|
46
|
+
"""The MathOpt integration hook."""
|
|
47
|
+
|
|
48
|
+
api: str = "mathopt"
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self._patches: list[tuple[Patch, str]] = [
|
|
52
|
+
(Patch(mathopt, "solve"), "function"),
|
|
53
|
+
(Patch(mathopt_solve.IncrementalSolver, "solve"), "incremental"),
|
|
54
|
+
]
|
|
55
|
+
self._rebound_module_names: list[tuple[ModuleType, str]] = []
|
|
56
|
+
self._module_solve_original: object | None = None
|
|
57
|
+
self._module_solve_replacement: object | None = None
|
|
58
|
+
|
|
59
|
+
def install(self, observer: SolveObserver) -> None:
|
|
60
|
+
"""The installed MathOpt solve hooks."""
|
|
61
|
+
installed_patches: list[Patch] = []
|
|
62
|
+
original_module_solve: object = mathopt.solve
|
|
63
|
+
try:
|
|
64
|
+
for patch, entry_point in self._patches:
|
|
65
|
+
|
|
66
|
+
def create_wrapper(
|
|
67
|
+
original: object,
|
|
68
|
+
current_entry_point: str = entry_point,
|
|
69
|
+
) -> object:
|
|
70
|
+
return self._wrap(original, observer, current_entry_point)
|
|
71
|
+
|
|
72
|
+
patch.install(create_wrapper)
|
|
73
|
+
installed_patches.append(patch)
|
|
74
|
+
replacement_module_solve: object = mathopt.solve
|
|
75
|
+
if replacement_module_solve is not original_module_solve:
|
|
76
|
+
self._rebound_module_names = rebind_module_names(
|
|
77
|
+
original_module_solve,
|
|
78
|
+
replacement_module_solve,
|
|
79
|
+
)
|
|
80
|
+
self._module_solve_original = original_module_solve
|
|
81
|
+
self._module_solve_replacement = replacement_module_solve
|
|
82
|
+
except (AttributeError, TypeError):
|
|
83
|
+
if self._module_solve_original is not None:
|
|
84
|
+
assert self._module_solve_replacement is not None
|
|
85
|
+
restore_module_names(
|
|
86
|
+
self._rebound_module_names,
|
|
87
|
+
self._module_solve_original,
|
|
88
|
+
self._module_solve_replacement,
|
|
89
|
+
)
|
|
90
|
+
self._rebound_module_names = []
|
|
91
|
+
self._module_solve_original = None
|
|
92
|
+
self._module_solve_replacement = None
|
|
93
|
+
for patch in reversed(installed_patches):
|
|
94
|
+
patch.uninstall()
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
def uninstall(self) -> None:
|
|
98
|
+
"""The restored MathOpt solve hooks."""
|
|
99
|
+
for patch, _ in reversed(self._patches):
|
|
100
|
+
patch.uninstall()
|
|
101
|
+
if self._module_solve_original is not None:
|
|
102
|
+
assert self._module_solve_replacement is not None
|
|
103
|
+
restore_module_names(
|
|
104
|
+
self._rebound_module_names,
|
|
105
|
+
self._module_solve_original,
|
|
106
|
+
self._module_solve_replacement,
|
|
107
|
+
)
|
|
108
|
+
self._rebound_module_names = []
|
|
109
|
+
self._module_solve_original = None
|
|
110
|
+
self._module_solve_replacement = None
|
|
111
|
+
|
|
112
|
+
def _wrap(
|
|
113
|
+
self, original: object, observer: SolveObserver, entry_point: str
|
|
114
|
+
) -> object:
|
|
115
|
+
original_solve: Callable[..., SolveResult] = cast(
|
|
116
|
+
Callable[..., SolveResult], original
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@wraps(original_solve)
|
|
120
|
+
def wrapped_solve(*args: object, **kwargs: object) -> SolveResult:
|
|
121
|
+
active_observer: SolveObserver = observer
|
|
122
|
+
receiver: object = (
|
|
123
|
+
receiver_of(original_solve, args, kwargs)
|
|
124
|
+
if entry_point == "incremental"
|
|
125
|
+
else _UNAVAILABLE
|
|
126
|
+
)
|
|
127
|
+
if entry_point == "incremental" and receiver is None:
|
|
128
|
+
return original_solve(*args, **kwargs)
|
|
129
|
+
call_signature: Signature = signature(original_solve)
|
|
130
|
+
call_signature.bind(*args, **kwargs)
|
|
131
|
+
|
|
132
|
+
def call() -> SolveResult:
|
|
133
|
+
return original_solve(*args, **kwargs)
|
|
134
|
+
|
|
135
|
+
def extract(
|
|
136
|
+
result: SolveResult | None,
|
|
137
|
+
error: BaseException | None,
|
|
138
|
+
) -> SolveObservation:
|
|
139
|
+
return self._extract(
|
|
140
|
+
result,
|
|
141
|
+
error,
|
|
142
|
+
entry_point,
|
|
143
|
+
args,
|
|
144
|
+
cast(dict[str, object], kwargs),
|
|
145
|
+
receiver,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return active_observer.observe(self.api, call, extract)
|
|
149
|
+
|
|
150
|
+
return wrapped_solve
|
|
151
|
+
|
|
152
|
+
def _extract(
|
|
153
|
+
self,
|
|
154
|
+
result: SolveResult | None,
|
|
155
|
+
solve_error: BaseException | None,
|
|
156
|
+
entry_point: str,
|
|
157
|
+
call_arguments: tuple[object, ...],
|
|
158
|
+
call_keywords: Mapping[str, object],
|
|
159
|
+
receiver: object,
|
|
160
|
+
) -> SolveObservation:
|
|
161
|
+
"""The solver-independent snapshot of one MathOpt result."""
|
|
162
|
+
try:
|
|
163
|
+
safe_result: SolveResult | None = (
|
|
164
|
+
None if solve_error is not None else result
|
|
165
|
+
)
|
|
166
|
+
solver_type: SolverType | None = self._solver_type(
|
|
167
|
+
entry_point,
|
|
168
|
+
call_arguments,
|
|
169
|
+
call_keywords,
|
|
170
|
+
receiver,
|
|
171
|
+
)
|
|
172
|
+
model: Model | None = self._model(
|
|
173
|
+
entry_point, call_arguments, call_keywords, receiver
|
|
174
|
+
)
|
|
175
|
+
solve_parameters: SolveParameters | None = self._solve_parameters(
|
|
176
|
+
call_keywords
|
|
177
|
+
)
|
|
178
|
+
outcome: ReportedOutcome = self._outcome(safe_result, solve_error)
|
|
179
|
+
metrics: ReportedMetrics = self._metrics(
|
|
180
|
+
safe_result,
|
|
181
|
+
model,
|
|
182
|
+
solver_type,
|
|
183
|
+
solve_parameters,
|
|
184
|
+
outcome.has_solution,
|
|
185
|
+
)
|
|
186
|
+
metadata: ReportedMetadata = self._metadata(
|
|
187
|
+
solver_type, model, solve_parameters
|
|
188
|
+
)
|
|
189
|
+
return SolveObservation(self.api, outcome, metrics, metadata)
|
|
190
|
+
except _SDK_EXCEPTION as error:
|
|
191
|
+
raise ObservationError("MathOpt observation could not be built") from error
|
|
192
|
+
|
|
193
|
+
def _solver_type(
|
|
194
|
+
self,
|
|
195
|
+
entry_point: str,
|
|
196
|
+
call_arguments: tuple[object, ...],
|
|
197
|
+
call_keywords: Mapping[str, object],
|
|
198
|
+
receiver: object,
|
|
199
|
+
) -> SolverType | None:
|
|
200
|
+
"""The solver type supplied to a MathOpt entry point."""
|
|
201
|
+
if entry_point == "incremental":
|
|
202
|
+
incremental_solver_type: object = self._guarded_attribute(
|
|
203
|
+
receiver, "_solver_type"
|
|
204
|
+
)
|
|
205
|
+
return (
|
|
206
|
+
incremental_solver_type
|
|
207
|
+
if isinstance(incremental_solver_type, SolverType)
|
|
208
|
+
else None
|
|
209
|
+
)
|
|
210
|
+
solver_type: object = call_keywords.get("solver_type", _UNAVAILABLE)
|
|
211
|
+
if solver_type is _UNAVAILABLE and len(call_arguments) > 1:
|
|
212
|
+
solver_type = call_arguments[1]
|
|
213
|
+
return solver_type if isinstance(solver_type, SolverType) else None
|
|
214
|
+
|
|
215
|
+
def _model(
|
|
216
|
+
self,
|
|
217
|
+
entry_point: str,
|
|
218
|
+
call_arguments: tuple[object, ...],
|
|
219
|
+
call_keywords: Mapping[str, object],
|
|
220
|
+
receiver: object,
|
|
221
|
+
) -> Model | None:
|
|
222
|
+
"""The model supplied to a MathOpt entry point."""
|
|
223
|
+
if entry_point == "incremental":
|
|
224
|
+
incremental_model: object = self._guarded_attribute(receiver, "_model")
|
|
225
|
+
return incremental_model if isinstance(incremental_model, Model) else None
|
|
226
|
+
model: object = call_keywords.get("opt_model", _UNAVAILABLE)
|
|
227
|
+
if model is _UNAVAILABLE and call_arguments:
|
|
228
|
+
model = call_arguments[0]
|
|
229
|
+
return model if isinstance(model, Model) else None
|
|
230
|
+
|
|
231
|
+
def _solve_parameters(
|
|
232
|
+
self, call_keywords: Mapping[str, object]
|
|
233
|
+
) -> SolveParameters | None:
|
|
234
|
+
"""The explicit MathOpt solve parameters."""
|
|
235
|
+
solve_parameters: object = call_keywords.get("params")
|
|
236
|
+
return (
|
|
237
|
+
solve_parameters if isinstance(solve_parameters, SolveParameters) else None
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _outcome(
|
|
241
|
+
self,
|
|
242
|
+
result: SolveResult | None,
|
|
243
|
+
error: BaseException | None,
|
|
244
|
+
) -> ReportedOutcome:
|
|
245
|
+
"""The MathOpt termination status and primal solution availability."""
|
|
246
|
+
if error is not None:
|
|
247
|
+
error_termination: Termination = (
|
|
248
|
+
Termination.INTERRUPTED
|
|
249
|
+
if isinstance(error, KeyboardInterrupt)
|
|
250
|
+
else Termination.ERROR
|
|
251
|
+
)
|
|
252
|
+
return ReportedOutcome(type(error).__name__, error_termination, None)
|
|
253
|
+
if result is None:
|
|
254
|
+
return ReportedOutcome("unknown", Termination.UNKNOWN, None)
|
|
255
|
+
termination_value: object = self._guarded_attribute(result, "termination")
|
|
256
|
+
reason: object = self._guarded_attribute(termination_value, "reason")
|
|
257
|
+
limit: object = self._guarded_attribute(termination_value, "limit")
|
|
258
|
+
reason_name: str | None = self._enum_name(reason)
|
|
259
|
+
limit_name: str | None = self._enum_name(limit)
|
|
260
|
+
raw_status: str = self._raw_status(reason, limit, reason_name, limit_name)
|
|
261
|
+
termination: Termination = self._termination(reason_name, limit_name)
|
|
262
|
+
has_solution: bool | None = self._has_solution(result)
|
|
263
|
+
return ReportedOutcome(raw_status, termination, has_solution)
|
|
264
|
+
|
|
265
|
+
def _has_solution(self, result: SolveResult) -> bool | None:
|
|
266
|
+
"""Whether a MathOpt result contains a primal feasible solution."""
|
|
267
|
+
try:
|
|
268
|
+
return result.has_primal_feasible_solution()
|
|
269
|
+
except _SDK_EXCEPTION:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
def _raw_status(
|
|
273
|
+
self,
|
|
274
|
+
reason: object,
|
|
275
|
+
limit: object,
|
|
276
|
+
reason_name: str | None,
|
|
277
|
+
limit_name: str | None,
|
|
278
|
+
) -> str:
|
|
279
|
+
"""The native MathOpt reason and limit names."""
|
|
280
|
+
if reason_name is None:
|
|
281
|
+
return "unknown"
|
|
282
|
+
reason_value: object = self._guarded_attribute(reason, "value")
|
|
283
|
+
raw_status: str = reason_name
|
|
284
|
+
if isinstance(reason_value, int):
|
|
285
|
+
raw_status = f"{raw_status} ({reason_value})"
|
|
286
|
+
if limit_name is not None:
|
|
287
|
+
limit_value: object = self._guarded_attribute(limit, "value")
|
|
288
|
+
raw_status = f"{raw_status}; limit={limit_name}"
|
|
289
|
+
if isinstance(limit_value, int):
|
|
290
|
+
raw_status = f"{raw_status} ({limit_value})"
|
|
291
|
+
return raw_status
|
|
292
|
+
|
|
293
|
+
def _termination(
|
|
294
|
+
self, reason_name: str | None, limit_name: str | None
|
|
295
|
+
) -> Termination:
|
|
296
|
+
"""The solver-independent termination of a MathOpt result."""
|
|
297
|
+
limit_terminations: dict[str, Termination] = {
|
|
298
|
+
"UNDETERMINED": Termination.OTHER_LIMIT,
|
|
299
|
+
"ITERATION": Termination.ITERATION_LIMIT,
|
|
300
|
+
"TIME": Termination.TIME_LIMIT,
|
|
301
|
+
"NODE": Termination.NODE_LIMIT,
|
|
302
|
+
"SOLUTION": Termination.SOLUTION_LIMIT,
|
|
303
|
+
"MEMORY": Termination.OTHER_LIMIT,
|
|
304
|
+
"OBJECTIVE": Termination.OTHER_LIMIT,
|
|
305
|
+
"NORM": Termination.OTHER_LIMIT,
|
|
306
|
+
"INTERRUPTED": Termination.INTERRUPTED,
|
|
307
|
+
"SLOW_PROGRESS": Termination.OTHER_LIMIT,
|
|
308
|
+
"OTHER": Termination.OTHER_LIMIT,
|
|
309
|
+
}
|
|
310
|
+
if reason_name in {"FEASIBLE", "NO_SOLUTION_FOUND"} and limit_name is not None:
|
|
311
|
+
return limit_terminations.get(limit_name, Termination.UNKNOWN)
|
|
312
|
+
reason_terminations: dict[str, Termination] = {
|
|
313
|
+
"OPTIMAL": Termination.OPTIMAL,
|
|
314
|
+
"INFEASIBLE": Termination.INFEASIBLE,
|
|
315
|
+
"UNBOUNDED": Termination.UNBOUNDED,
|
|
316
|
+
"INFEASIBLE_OR_UNBOUNDED": Termination.INFEASIBLE_OR_UNBOUNDED,
|
|
317
|
+
"IMPRECISE": Termination.UNKNOWN,
|
|
318
|
+
"FEASIBLE": Termination.FEASIBLE,
|
|
319
|
+
"NO_SOLUTION_FOUND": Termination.UNKNOWN,
|
|
320
|
+
"NUMERICAL_ERROR": Termination.ERROR,
|
|
321
|
+
"OTHER_ERROR": Termination.ERROR,
|
|
322
|
+
}
|
|
323
|
+
return reason_terminations.get(reason_name or "", Termination.UNKNOWN)
|
|
324
|
+
|
|
325
|
+
def _metrics(
|
|
326
|
+
self,
|
|
327
|
+
result: SolveResult | None,
|
|
328
|
+
model: Model | None,
|
|
329
|
+
solver_type: SolverType | None,
|
|
330
|
+
solve_parameters: SolveParameters | None,
|
|
331
|
+
has_solution: bool | None,
|
|
332
|
+
) -> ReportedMetrics:
|
|
333
|
+
"""The objective, bound, time, and statistics of a MathOpt result."""
|
|
334
|
+
has_objective: bool | None = self._has_objective(model)
|
|
335
|
+
sense: ObjectiveSense | None = self._objective_sense(model, has_objective)
|
|
336
|
+
if result is None:
|
|
337
|
+
objective_value: float | MissingReason = MissingReason.UNSUPPORTED
|
|
338
|
+
dual_bound: float | MissingReason = MissingReason.UNSUPPORTED
|
|
339
|
+
solver_time_seconds: float | MissingReason = MissingReason.UNSUPPORTED
|
|
340
|
+
else:
|
|
341
|
+
objective_value = self._objective_value(result, has_solution)
|
|
342
|
+
dual_bound = self._numeric_result_value(result)
|
|
343
|
+
solver_time_seconds = self._solver_time(result)
|
|
344
|
+
statistics: dict[str, float | MissingReason] = self._statistics(
|
|
345
|
+
result,
|
|
346
|
+
model,
|
|
347
|
+
solver_type,
|
|
348
|
+
solve_parameters,
|
|
349
|
+
)
|
|
350
|
+
native: dict[str, float] = self._native_statistics(result, solver_type)
|
|
351
|
+
return ReportedMetrics(
|
|
352
|
+
has_objective=has_objective,
|
|
353
|
+
sense=sense,
|
|
354
|
+
objective_value=objective_value,
|
|
355
|
+
dual_bound=dual_bound,
|
|
356
|
+
solver_time_seconds=solver_time_seconds,
|
|
357
|
+
statistics=statistics,
|
|
358
|
+
native=native,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def _has_objective(self, model: Model | None) -> bool | None:
|
|
362
|
+
"""Whether a MathOpt objective has nonzero terms or an offset."""
|
|
363
|
+
if model is None:
|
|
364
|
+
return None
|
|
365
|
+
objective: Objective = cast(
|
|
366
|
+
Objective, self._guarded_attribute(model, "objective")
|
|
367
|
+
)
|
|
368
|
+
try:
|
|
369
|
+
linear_terms: object = tuple(objective.linear_terms())
|
|
370
|
+
except _SDK_EXCEPTION:
|
|
371
|
+
linear_terms = _UNAVAILABLE
|
|
372
|
+
try:
|
|
373
|
+
quadratic_terms: object = tuple(objective.quadratic_terms())
|
|
374
|
+
except _SDK_EXCEPTION:
|
|
375
|
+
quadratic_terms = _UNAVAILABLE
|
|
376
|
+
offset: object = self._guarded_attribute(objective, "offset")
|
|
377
|
+
if not isinstance(linear_terms, tuple) or not isinstance(
|
|
378
|
+
quadratic_terms, tuple
|
|
379
|
+
):
|
|
380
|
+
return None
|
|
381
|
+
if not isinstance(offset, (int, float)):
|
|
382
|
+
return None
|
|
383
|
+
return bool(linear_terms or quadratic_terms or offset != 0.0)
|
|
384
|
+
|
|
385
|
+
def _objective_sense(
|
|
386
|
+
self,
|
|
387
|
+
model: Model | None,
|
|
388
|
+
has_objective: bool | None,
|
|
389
|
+
) -> ObjectiveSense | None:
|
|
390
|
+
"""The direction of a defined MathOpt objective."""
|
|
391
|
+
if model is None or has_objective is not True:
|
|
392
|
+
return None
|
|
393
|
+
objective: object = self._guarded_attribute(model, "objective")
|
|
394
|
+
is_maximize: object = self._guarded_attribute(objective, "is_maximize")
|
|
395
|
+
if not isinstance(is_maximize, bool):
|
|
396
|
+
return None
|
|
397
|
+
return ObjectiveSense.MAXIMIZE if is_maximize else ObjectiveSense.MINIMIZE
|
|
398
|
+
|
|
399
|
+
def _objective_value(
|
|
400
|
+
self,
|
|
401
|
+
result: SolveResult,
|
|
402
|
+
has_solution: bool | None,
|
|
403
|
+
) -> float | MissingReason:
|
|
404
|
+
"""The best primal feasible objective value in a MathOpt result."""
|
|
405
|
+
if has_solution is False:
|
|
406
|
+
return MissingReason.NO_SOLUTION
|
|
407
|
+
try:
|
|
408
|
+
value: object = result.objective_value()
|
|
409
|
+
except _SDK_EXCEPTION:
|
|
410
|
+
return MissingReason.FAILED
|
|
411
|
+
if isinstance(value, (int, float)):
|
|
412
|
+
return float(value)
|
|
413
|
+
return MissingReason.FAILED
|
|
414
|
+
|
|
415
|
+
def _numeric_result_value(
|
|
416
|
+
self,
|
|
417
|
+
result: SolveResult,
|
|
418
|
+
) -> float | MissingReason:
|
|
419
|
+
"""The dual bound from a MathOpt result."""
|
|
420
|
+
result_method: object = self._guarded_attribute(result, "dual_bound")
|
|
421
|
+
if not callable(result_method):
|
|
422
|
+
return MissingReason.UNSUPPORTED
|
|
423
|
+
try:
|
|
424
|
+
value: object = result_method()
|
|
425
|
+
except _SDK_EXCEPTION:
|
|
426
|
+
return MissingReason.FAILED
|
|
427
|
+
if isinstance(value, (int, float)):
|
|
428
|
+
return float(value)
|
|
429
|
+
return MissingReason.FAILED
|
|
430
|
+
|
|
431
|
+
def _solver_time(self, result: SolveResult) -> float | MissingReason:
|
|
432
|
+
"""The solve time reported by MathOpt."""
|
|
433
|
+
solve_time_method: object = self._guarded_attribute(result, "solve_time")
|
|
434
|
+
if not callable(solve_time_method):
|
|
435
|
+
return MissingReason.UNSUPPORTED
|
|
436
|
+
try:
|
|
437
|
+
duration: object = solve_time_method()
|
|
438
|
+
except _SDK_EXCEPTION:
|
|
439
|
+
return MissingReason.FAILED
|
|
440
|
+
if isinstance(duration, timedelta):
|
|
441
|
+
return duration.total_seconds()
|
|
442
|
+
return MissingReason.FAILED
|
|
443
|
+
|
|
444
|
+
def _statistics(
|
|
445
|
+
self,
|
|
446
|
+
result: SolveResult | None,
|
|
447
|
+
model: Model | None,
|
|
448
|
+
solver_type: SolverType | None,
|
|
449
|
+
solve_parameters: SolveParameters | None,
|
|
450
|
+
) -> dict[str, float | MissingReason]:
|
|
451
|
+
"""The common search statistics reported by MathOpt."""
|
|
452
|
+
statistics: dict[str, float | MissingReason] = {
|
|
453
|
+
"node_count": MissingReason.UNSUPPORTED,
|
|
454
|
+
"simplex_iterations": MissingReason.UNSUPPORTED,
|
|
455
|
+
"barrier_iterations": MissingReason.UNSUPPORTED,
|
|
456
|
+
}
|
|
457
|
+
if result is None:
|
|
458
|
+
return statistics
|
|
459
|
+
supported_statistics: set[str] = set()
|
|
460
|
+
if solver_type is SolverType.GLOP:
|
|
461
|
+
supported_statistics.add("simplex_iterations")
|
|
462
|
+
elif solver_type is SolverType.GSCIP:
|
|
463
|
+
supported_statistics.update(
|
|
464
|
+
("node_count", "simplex_iterations", "barrier_iterations")
|
|
465
|
+
)
|
|
466
|
+
elif solver_type is SolverType.HIGHS:
|
|
467
|
+
has_integer_variables: bool | None = self._has_integer_variables(model)
|
|
468
|
+
solves_relaxation: bool | None = self._highs_solves_relaxation(
|
|
469
|
+
solve_parameters
|
|
470
|
+
)
|
|
471
|
+
if solves_relaxation is True or (
|
|
472
|
+
solves_relaxation is False and has_integer_variables is False
|
|
473
|
+
):
|
|
474
|
+
supported_statistics.update(
|
|
475
|
+
("simplex_iterations", "barrier_iterations")
|
|
476
|
+
)
|
|
477
|
+
elif solves_relaxation is False and has_integer_variables is True:
|
|
478
|
+
supported_statistics.add("node_count")
|
|
479
|
+
zero_statistics_are_known: set[str] = (
|
|
480
|
+
supported_statistics
|
|
481
|
+
if self._statistics_availability_is_known(result, solve_parameters)
|
|
482
|
+
else set()
|
|
483
|
+
)
|
|
484
|
+
solve_stats: object = self._guarded_attribute(result, "solve_stats")
|
|
485
|
+
for statistic_name in statistics:
|
|
486
|
+
if statistic_name not in supported_statistics:
|
|
487
|
+
continue
|
|
488
|
+
value: object = self._guarded_attribute(solve_stats, statistic_name)
|
|
489
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
490
|
+
if value != 0 or statistic_name in zero_statistics_are_known:
|
|
491
|
+
statistics[statistic_name] = float(value)
|
|
492
|
+
elif value is _UNAVAILABLE and statistic_name in zero_statistics_are_known:
|
|
493
|
+
statistics[statistic_name] = MissingReason.FAILED
|
|
494
|
+
return statistics
|
|
495
|
+
|
|
496
|
+
def _statistics_availability_is_known(
|
|
497
|
+
self,
|
|
498
|
+
result: SolveResult,
|
|
499
|
+
solve_parameters: SolveParameters | None,
|
|
500
|
+
) -> bool:
|
|
501
|
+
"""The MathOpt solve states that establish work-count availability."""
|
|
502
|
+
termination: object = self._guarded_attribute(result, "termination")
|
|
503
|
+
reason: object = self._guarded_attribute(termination, "reason")
|
|
504
|
+
limit: object = self._guarded_attribute(termination, "limit")
|
|
505
|
+
reason_name: str | None = self._enum_name(reason)
|
|
506
|
+
limit_name: str | None = self._enum_name(limit)
|
|
507
|
+
if self._has_solution(result) is True:
|
|
508
|
+
return True
|
|
509
|
+
if limit is None and reason_name in {
|
|
510
|
+
"OPTIMAL",
|
|
511
|
+
"INFEASIBLE",
|
|
512
|
+
"UNBOUNDED",
|
|
513
|
+
"INFEASIBLE_OR_UNBOUNDED",
|
|
514
|
+
}:
|
|
515
|
+
return True
|
|
516
|
+
time_limit: object = self._guarded_attribute(solve_parameters, "time_limit")
|
|
517
|
+
return limit_name == "TIME" and time_limit == timedelta(0)
|
|
518
|
+
|
|
519
|
+
def _has_integer_variables(self, model: Model | None) -> bool | None:
|
|
520
|
+
"""Whether a MathOpt model contains integer variables."""
|
|
521
|
+
if model is None:
|
|
522
|
+
return None
|
|
523
|
+
variables_method: object = self._guarded_attribute(model, "variables")
|
|
524
|
+
if not callable(variables_method):
|
|
525
|
+
return None
|
|
526
|
+
try:
|
|
527
|
+
variables: tuple[object, ...] = tuple(
|
|
528
|
+
cast(Iterable[object], variables_method())
|
|
529
|
+
)
|
|
530
|
+
except _SDK_EXCEPTION:
|
|
531
|
+
return None
|
|
532
|
+
for variable in variables:
|
|
533
|
+
integer_flag: object = self._guarded_attribute(variable, "integer")
|
|
534
|
+
if not isinstance(integer_flag, bool):
|
|
535
|
+
return None
|
|
536
|
+
if integer_flag:
|
|
537
|
+
return True
|
|
538
|
+
return False
|
|
539
|
+
|
|
540
|
+
def _highs_solves_relaxation(
|
|
541
|
+
self, solve_parameters: SolveParameters | None
|
|
542
|
+
) -> bool | None:
|
|
543
|
+
"""Whether HiGHS is configured to solve the relaxation of a MIP."""
|
|
544
|
+
if solve_parameters is None:
|
|
545
|
+
return False
|
|
546
|
+
highs_parameters: object = self._guarded_attribute(
|
|
547
|
+
solve_parameters,
|
|
548
|
+
"highs",
|
|
549
|
+
)
|
|
550
|
+
boolean_options: object = self._guarded_attribute(
|
|
551
|
+
highs_parameters,
|
|
552
|
+
"bool_options",
|
|
553
|
+
)
|
|
554
|
+
if not isinstance(boolean_options, Mapping):
|
|
555
|
+
return None
|
|
556
|
+
try:
|
|
557
|
+
solves_relaxation: object = boolean_options.get("solve_relaxation", False)
|
|
558
|
+
except _SDK_EXCEPTION:
|
|
559
|
+
return None
|
|
560
|
+
return solves_relaxation if isinstance(solves_relaxation, bool) else None
|
|
561
|
+
|
|
562
|
+
def _native_statistics(
|
|
563
|
+
self,
|
|
564
|
+
result: SolveResult | None,
|
|
565
|
+
solver_type: SolverType | None,
|
|
566
|
+
) -> dict[str, float]:
|
|
567
|
+
"""The MathOpt backend-specific numeric statistics."""
|
|
568
|
+
native: dict[str, float] = {}
|
|
569
|
+
if result is None:
|
|
570
|
+
return native
|
|
571
|
+
solve_stats: object = self._guarded_attribute(result, "solve_stats")
|
|
572
|
+
if solver_type is SolverType.PDLP:
|
|
573
|
+
first_order_iterations: object = self._guarded_attribute(
|
|
574
|
+
solve_stats,
|
|
575
|
+
"first_order_iterations",
|
|
576
|
+
)
|
|
577
|
+
if isinstance(first_order_iterations, (int, float)) and not isinstance(
|
|
578
|
+
first_order_iterations,
|
|
579
|
+
bool,
|
|
580
|
+
):
|
|
581
|
+
native["first_order_iterations"] = float(first_order_iterations)
|
|
582
|
+
for output_name in (
|
|
583
|
+
"gscip_specific_output",
|
|
584
|
+
"osqp_specific_output",
|
|
585
|
+
"pdlp_specific_output",
|
|
586
|
+
):
|
|
587
|
+
output: object = self._guarded_attribute(result, output_name)
|
|
588
|
+
if output is not None and output is not _UNAVAILABLE:
|
|
589
|
+
self._collect_native_values(
|
|
590
|
+
output_name.removesuffix("_specific_output"), output, native
|
|
591
|
+
)
|
|
592
|
+
return native
|
|
593
|
+
|
|
594
|
+
def _collect_native_values(
|
|
595
|
+
self,
|
|
596
|
+
prefix: str,
|
|
597
|
+
message: object,
|
|
598
|
+
native: dict[str, float],
|
|
599
|
+
) -> None:
|
|
600
|
+
"""The numeric leaves in a MathOpt backend output message."""
|
|
601
|
+
list_fields_method: object = self._guarded_attribute(message, "ListFields")
|
|
602
|
+
if not callable(list_fields_method):
|
|
603
|
+
return
|
|
604
|
+
try:
|
|
605
|
+
fields: object = list_fields_method()
|
|
606
|
+
except _SDK_EXCEPTION:
|
|
607
|
+
return
|
|
608
|
+
if not isinstance(fields, list):
|
|
609
|
+
return
|
|
610
|
+
for field_descriptor, field_value in fields:
|
|
611
|
+
field_name: object = self._guarded_attribute(field_descriptor, "name")
|
|
612
|
+
enum_descriptor: object = self._guarded_attribute(
|
|
613
|
+
field_descriptor, "enum_type"
|
|
614
|
+
)
|
|
615
|
+
if not isinstance(field_name, str):
|
|
616
|
+
continue
|
|
617
|
+
if enum_descriptor is not None and enum_descriptor is not _UNAVAILABLE:
|
|
618
|
+
continue
|
|
619
|
+
if self._is_message(field_value):
|
|
620
|
+
self._collect_native_values(
|
|
621
|
+
f"{prefix}.{field_name}", field_value, native
|
|
622
|
+
)
|
|
623
|
+
elif isinstance(field_value, (int, float)) and not isinstance(
|
|
624
|
+
field_value, bool
|
|
625
|
+
):
|
|
626
|
+
native_name: str = f"{prefix}.{field_name}"
|
|
627
|
+
native[native_name] = float(field_value)
|
|
628
|
+
|
|
629
|
+
def _metadata(
|
|
630
|
+
self,
|
|
631
|
+
solver_type: SolverType | None,
|
|
632
|
+
model: Model | None,
|
|
633
|
+
solve_parameters: SolveParameters | None,
|
|
634
|
+
) -> ReportedMetadata:
|
|
635
|
+
"""The engine, version, problem size, and explicit parameters of a MathOpt solve."""
|
|
636
|
+
solver_names: dict[str, str] = {
|
|
637
|
+
"GSCIP": "scip",
|
|
638
|
+
"GUROBI": "gurobi",
|
|
639
|
+
"GLOP": "glop",
|
|
640
|
+
"CP_SAT": "cp_sat",
|
|
641
|
+
"PDLP": "pdlp",
|
|
642
|
+
"GLPK": "glpk",
|
|
643
|
+
"OSQP": "osqp",
|
|
644
|
+
"ECOS": "ecos",
|
|
645
|
+
"SCS": "scs",
|
|
646
|
+
"HIGHS": "highs",
|
|
647
|
+
"SANTORINI": "santorini",
|
|
648
|
+
}
|
|
649
|
+
solver_name: str | None = None
|
|
650
|
+
solver_type_name: str | None = self._enum_name(solver_type)
|
|
651
|
+
if solver_type_name is not None:
|
|
652
|
+
solver_name = solver_names.get(solver_type_name)
|
|
653
|
+
problem: dict[str, int | float | str | bool] = self._problem_size(model)
|
|
654
|
+
parameters: list[SolverParameter] = self._parameters(
|
|
655
|
+
solve_parameters,
|
|
656
|
+
solver_type,
|
|
657
|
+
)
|
|
658
|
+
api_version: object = self._guarded_attribute(ortools, "__version__")
|
|
659
|
+
return ReportedMetadata(
|
|
660
|
+
solver=solver_name,
|
|
661
|
+
api_version=api_version if isinstance(api_version, str) else None,
|
|
662
|
+
solver_version=None,
|
|
663
|
+
problem=problem,
|
|
664
|
+
parameters=parameters,
|
|
665
|
+
wall_time_includes_problem_build=False,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
def _problem_size(self, model: Model | None) -> dict[str, int | float | str | bool]:
|
|
669
|
+
"""The dimensions of a MathOpt model."""
|
|
670
|
+
problem: dict[str, int | float | str | bool] = {}
|
|
671
|
+
if model is None:
|
|
672
|
+
return problem
|
|
673
|
+
count_methods: tuple[tuple[str, str], ...] = (
|
|
674
|
+
("num_variables", "get_num_variables"),
|
|
675
|
+
("num_linear_constraints", "get_num_linear_constraints"),
|
|
676
|
+
("num_quadratic_constraints", "get_num_quadratic_constraints"),
|
|
677
|
+
("num_indicator_constraints", "get_num_indicator_constraints"),
|
|
678
|
+
)
|
|
679
|
+
constraint_counts: list[int] = []
|
|
680
|
+
for problem_name, method_name in count_methods:
|
|
681
|
+
count_method: object = self._guarded_attribute(model, method_name)
|
|
682
|
+
if not callable(count_method):
|
|
683
|
+
continue
|
|
684
|
+
try:
|
|
685
|
+
count: object = count_method()
|
|
686
|
+
except _SDK_EXCEPTION:
|
|
687
|
+
continue
|
|
688
|
+
if isinstance(count, int) and not isinstance(count, bool):
|
|
689
|
+
problem[problem_name] = count
|
|
690
|
+
if problem_name != "num_variables":
|
|
691
|
+
constraint_counts.append(count)
|
|
692
|
+
if len(constraint_counts) == 3:
|
|
693
|
+
problem["num_constraints"] = sum(constraint_counts)
|
|
694
|
+
variables_method: object = self._guarded_attribute(model, "variables")
|
|
695
|
+
if not callable(variables_method):
|
|
696
|
+
return problem
|
|
697
|
+
try:
|
|
698
|
+
variables: object = tuple(cast(Iterable[object], variables_method()))
|
|
699
|
+
except _SDK_EXCEPTION:
|
|
700
|
+
return problem
|
|
701
|
+
if isinstance(variables, tuple):
|
|
702
|
+
integer_count: int = 0
|
|
703
|
+
all_integer_flags_available: bool = True
|
|
704
|
+
for variable in variables:
|
|
705
|
+
integer_flag: object = self._guarded_attribute(variable, "integer")
|
|
706
|
+
if isinstance(integer_flag, bool):
|
|
707
|
+
integer_count += int(integer_flag)
|
|
708
|
+
else:
|
|
709
|
+
all_integer_flags_available = False
|
|
710
|
+
if all_integer_flags_available:
|
|
711
|
+
problem["num_integer_variables"] = integer_count
|
|
712
|
+
return problem
|
|
713
|
+
|
|
714
|
+
def _parameters(
|
|
715
|
+
self,
|
|
716
|
+
solve_parameters: SolveParameters | None,
|
|
717
|
+
solver_type: SolverType | None,
|
|
718
|
+
) -> list[SolverParameter]:
|
|
719
|
+
"""The solve parameters that differ from MathOpt defaults."""
|
|
720
|
+
parameters: list[SolverParameter] = []
|
|
721
|
+
if solve_parameters is None:
|
|
722
|
+
return parameters
|
|
723
|
+
backend_parameter_groups: dict[str, SolverType] = {
|
|
724
|
+
"gscip": SolverType.GSCIP,
|
|
725
|
+
"gurobi": SolverType.GUROBI,
|
|
726
|
+
"glop": SolverType.GLOP,
|
|
727
|
+
"cp_sat": SolverType.CP_SAT,
|
|
728
|
+
"pdlp": SolverType.PDLP,
|
|
729
|
+
"osqp": SolverType.OSQP,
|
|
730
|
+
"glpk": SolverType.GLPK,
|
|
731
|
+
"highs": SolverType.HIGHS,
|
|
732
|
+
}
|
|
733
|
+
default_parameters: SolveParameters = SolveParameters()
|
|
734
|
+
for parameter_field in dataclasses.fields(SolveParameters):
|
|
735
|
+
parameter_name: str = parameter_field.name
|
|
736
|
+
backend_solver_type: SolverType | None = backend_parameter_groups.get(
|
|
737
|
+
parameter_name
|
|
738
|
+
)
|
|
739
|
+
if (
|
|
740
|
+
backend_solver_type is not None
|
|
741
|
+
and backend_solver_type is not solver_type
|
|
742
|
+
):
|
|
743
|
+
continue
|
|
744
|
+
parameter_value: object = self._guarded_attribute(
|
|
745
|
+
solve_parameters, parameter_name
|
|
746
|
+
)
|
|
747
|
+
default_value: object = self._guarded_attribute(
|
|
748
|
+
default_parameters, parameter_name
|
|
749
|
+
)
|
|
750
|
+
if parameter_value is _UNAVAILABLE or default_value is _UNAVAILABLE:
|
|
751
|
+
continue
|
|
752
|
+
is_default: bool = True
|
|
753
|
+
try:
|
|
754
|
+
is_default = parameter_value == default_value
|
|
755
|
+
except _SDK_EXCEPTION:
|
|
756
|
+
is_default = True
|
|
757
|
+
if is_default or parameter_value is None:
|
|
758
|
+
continue
|
|
759
|
+
self._append_parameters(
|
|
760
|
+
parameter_name,
|
|
761
|
+
parameter_value,
|
|
762
|
+
default_value,
|
|
763
|
+
parameters,
|
|
764
|
+
parameter_name == "gurobi",
|
|
765
|
+
)
|
|
766
|
+
return parameters
|
|
767
|
+
|
|
768
|
+
def _append_parameters(
|
|
769
|
+
self,
|
|
770
|
+
parameter_name: str,
|
|
771
|
+
parameter_value: object,
|
|
772
|
+
default_value: object,
|
|
773
|
+
parameters: list[SolverParameter],
|
|
774
|
+
suppress_strings: bool,
|
|
775
|
+
) -> None:
|
|
776
|
+
"""The differing nested MathOpt parameter values."""
|
|
777
|
+
if parameter_value is None or parameter_value is _UNAVAILABLE:
|
|
778
|
+
return
|
|
779
|
+
try:
|
|
780
|
+
is_default: bool = parameter_value == default_value
|
|
781
|
+
except _SDK_EXCEPTION:
|
|
782
|
+
return
|
|
783
|
+
if is_default:
|
|
784
|
+
return
|
|
785
|
+
if suppress_strings and isinstance(parameter_value, str):
|
|
786
|
+
return
|
|
787
|
+
if isinstance(parameter_value, Mapping):
|
|
788
|
+
default_mapping: Mapping[object, object] = (
|
|
789
|
+
default_value if isinstance(default_value, Mapping) else {}
|
|
790
|
+
)
|
|
791
|
+
mapping_items: tuple[tuple[object, object], ...]
|
|
792
|
+
try:
|
|
793
|
+
mapping_items = tuple(parameter_value.items())
|
|
794
|
+
except _SDK_EXCEPTION:
|
|
795
|
+
return
|
|
796
|
+
for mapping_name, nested_value in mapping_items:
|
|
797
|
+
if not isinstance(mapping_name, str):
|
|
798
|
+
continue
|
|
799
|
+
try:
|
|
800
|
+
nested_default: object = default_mapping.get(
|
|
801
|
+
mapping_name,
|
|
802
|
+
_UNAVAILABLE,
|
|
803
|
+
)
|
|
804
|
+
except _SDK_EXCEPTION:
|
|
805
|
+
nested_default = _UNAVAILABLE
|
|
806
|
+
self._append_parameters(
|
|
807
|
+
f"{parameter_name}.{mapping_name}",
|
|
808
|
+
nested_value,
|
|
809
|
+
nested_default,
|
|
810
|
+
parameters,
|
|
811
|
+
suppress_strings,
|
|
812
|
+
)
|
|
813
|
+
return
|
|
814
|
+
if dataclasses.is_dataclass(parameter_value) and not isinstance(
|
|
815
|
+
parameter_value, type
|
|
816
|
+
):
|
|
817
|
+
default_object: object = default_value
|
|
818
|
+
try:
|
|
819
|
+
nested_fields: tuple[object, ...] = tuple(
|
|
820
|
+
dataclasses.fields(parameter_value)
|
|
821
|
+
)
|
|
822
|
+
except _SDK_EXCEPTION:
|
|
823
|
+
return
|
|
824
|
+
for parameter_field in nested_fields:
|
|
825
|
+
nested_name: str = cast(str, parameter_field.name)
|
|
826
|
+
nested_value: object = self._guarded_attribute(
|
|
827
|
+
parameter_value, nested_name
|
|
828
|
+
)
|
|
829
|
+
field_default: object = self._guarded_attribute(
|
|
830
|
+
default_object, nested_name
|
|
831
|
+
)
|
|
832
|
+
self._append_parameters(
|
|
833
|
+
f"{parameter_name}.{nested_name}",
|
|
834
|
+
nested_value,
|
|
835
|
+
field_default,
|
|
836
|
+
parameters,
|
|
837
|
+
suppress_strings,
|
|
838
|
+
)
|
|
839
|
+
return
|
|
840
|
+
if self._is_message(parameter_value):
|
|
841
|
+
list_fields_method: object = self._guarded_attribute(
|
|
842
|
+
parameter_value, "ListFields"
|
|
843
|
+
)
|
|
844
|
+
if not callable(list_fields_method):
|
|
845
|
+
return
|
|
846
|
+
try:
|
|
847
|
+
fields: object = list_fields_method()
|
|
848
|
+
except _SDK_EXCEPTION:
|
|
849
|
+
return
|
|
850
|
+
if not isinstance(fields, list):
|
|
851
|
+
return
|
|
852
|
+
for field_descriptor, field_value in fields:
|
|
853
|
+
field_name: object = self._guarded_attribute(field_descriptor, "name")
|
|
854
|
+
enum_descriptor: object = self._guarded_attribute(
|
|
855
|
+
field_descriptor, "enum_type"
|
|
856
|
+
)
|
|
857
|
+
if not isinstance(field_name, str):
|
|
858
|
+
continue
|
|
859
|
+
if enum_descriptor is not None and enum_descriptor is not _UNAVAILABLE:
|
|
860
|
+
enum_values: object = self._guarded_attribute(
|
|
861
|
+
enum_descriptor,
|
|
862
|
+
"values_by_number",
|
|
863
|
+
)
|
|
864
|
+
if not isinstance(enum_values, Mapping):
|
|
865
|
+
continue
|
|
866
|
+
enum_value: object = _UNAVAILABLE
|
|
867
|
+
try:
|
|
868
|
+
enum_value = enum_values[field_value]
|
|
869
|
+
except _SDK_EXCEPTION:
|
|
870
|
+
pass
|
|
871
|
+
if enum_value is _UNAVAILABLE:
|
|
872
|
+
continue
|
|
873
|
+
enum_name: object = self._guarded_attribute(enum_value, "name")
|
|
874
|
+
if isinstance(enum_name, str):
|
|
875
|
+
parameters.append(
|
|
876
|
+
SolverParameter(
|
|
877
|
+
f"{parameter_name}.{field_name}",
|
|
878
|
+
enum_name,
|
|
879
|
+
ParameterOrigin.EXPLICIT,
|
|
880
|
+
)
|
|
881
|
+
)
|
|
882
|
+
else:
|
|
883
|
+
self._append_parameters(
|
|
884
|
+
f"{parameter_name}.{field_name}",
|
|
885
|
+
field_value,
|
|
886
|
+
_UNAVAILABLE,
|
|
887
|
+
parameters,
|
|
888
|
+
suppress_strings,
|
|
889
|
+
)
|
|
890
|
+
return
|
|
891
|
+
scalar: ParameterValue | None = self._parameter_value(parameter_value)
|
|
892
|
+
if scalar is not None:
|
|
893
|
+
parameters.append(
|
|
894
|
+
SolverParameter(parameter_name, scalar, ParameterOrigin.EXPLICIT)
|
|
895
|
+
)
|
|
896
|
+
return
|
|
897
|
+
try:
|
|
898
|
+
serialized_value: str = str(parameter_value)
|
|
899
|
+
except _SDK_EXCEPTION:
|
|
900
|
+
return
|
|
901
|
+
if suppress_strings:
|
|
902
|
+
return
|
|
903
|
+
parameters.append(
|
|
904
|
+
SolverParameter(parameter_name, serialized_value, ParameterOrigin.EXPLICIT)
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
def _parameter_value(self, parameter_value: object) -> ParameterValue | None:
|
|
908
|
+
"""The scalar form of a MathOpt parameter."""
|
|
909
|
+
if isinstance(parameter_value, bool):
|
|
910
|
+
return parameter_value
|
|
911
|
+
if isinstance(parameter_value, (int, float, str)):
|
|
912
|
+
return parameter_value
|
|
913
|
+
if isinstance(parameter_value, timedelta):
|
|
914
|
+
return parameter_value.total_seconds()
|
|
915
|
+
if isinstance(parameter_value, Enum):
|
|
916
|
+
enum_name: object = self._guarded_attribute(parameter_value, "name")
|
|
917
|
+
return enum_name if isinstance(enum_name, str) else None
|
|
918
|
+
return None
|
|
919
|
+
|
|
920
|
+
def _is_message(self, candidate: object) -> bool:
|
|
921
|
+
"""Whether a value is a protobuf message."""
|
|
922
|
+
list_fields_method: object = self._guarded_attribute(candidate, "ListFields")
|
|
923
|
+
return callable(list_fields_method)
|
|
924
|
+
|
|
925
|
+
def _enum_name(self, candidate: object) -> str | None:
|
|
926
|
+
"""The name of a known MathOpt enum value."""
|
|
927
|
+
enum_name: object = self._guarded_attribute(candidate, "name")
|
|
928
|
+
return enum_name if isinstance(enum_name, str) else None
|
|
929
|
+
|
|
930
|
+
def _guarded_attribute(
|
|
931
|
+
self, attribute_owner: object, attribute_name: str
|
|
932
|
+
) -> object:
|
|
933
|
+
"""The optional attribute value when the read succeeds."""
|
|
934
|
+
if attribute_owner is None or attribute_owner is _UNAVAILABLE:
|
|
935
|
+
return _UNAVAILABLE
|
|
936
|
+
try:
|
|
937
|
+
return getattr(attribute_owner, attribute_name)
|
|
938
|
+
except _SDK_EXCEPTION:
|
|
939
|
+
return _UNAVAILABLE
|