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.
Files changed (40) hide show
  1. mlflow_optimization/__init__.py +50 -0
  2. mlflow_optimization/_bootstrap.py +129 -0
  3. mlflow_optimization/common/diagnostics.py +19 -0
  4. mlflow_optimization/common/patching.py +82 -0
  5. mlflow_optimization/features/annotations/annotate.py +97 -0
  6. mlflow_optimization/features/annotations/types.py +22 -0
  7. mlflow_optimization/features/artifacts/policy.py +16 -0
  8. mlflow_optimization/features/artifacts/ports.py +17 -0
  9. mlflow_optimization/features/autolog/ports.py +49 -0
  10. mlflow_optimization/features/autolog/service.py +147 -0
  11. mlflow_optimization/features/autolog/types.py +31 -0
  12. mlflow_optimization/features/metadata/collect.py +52 -0
  13. mlflow_optimization/features/metadata/types.py +42 -0
  14. mlflow_optimization/features/metrics/normalize.py +121 -0
  15. mlflow_optimization/features/metrics/types.py +54 -0
  16. mlflow_optimization/features/outcomes/classify.py +53 -0
  17. mlflow_optimization/features/outcomes/types.py +40 -0
  18. mlflow_optimization/features/runs/ports.py +30 -0
  19. mlflow_optimization/features/runs/record.py +231 -0
  20. mlflow_optimization/features/runs/types.py +43 -0
  21. mlflow_optimization/integrations/cp_sat/__init__.py +5 -0
  22. mlflow_optimization/integrations/cp_sat/adapter.py +464 -0
  23. mlflow_optimization/integrations/gurobipy/__init__.py +5 -0
  24. mlflow_optimization/integrations/gurobipy/adapter.py +474 -0
  25. mlflow_optimization/integrations/mathopt/__init__.py +1 -0
  26. mlflow_optimization/integrations/mathopt/adapter.py +939 -0
  27. mlflow_optimization/integrations/mlflow/artifacts.py +44 -0
  28. mlflow_optimization/integrations/mlflow/runs.py +480 -0
  29. mlflow_optimization/integrations/pyomo/__init__.py +5 -0
  30. mlflow_optimization/integrations/pyomo/adapter.py +1499 -0
  31. mlflow_optimization/integrations/pyscipopt/__init__.py +5 -0
  32. mlflow_optimization/integrations/pyscipopt/adapter.py +657 -0
  33. mlflow_optimization/integrations/routing/__init__.py +5 -0
  34. mlflow_optimization/integrations/routing/adapter.py +429 -0
  35. mlflow_optimization/py.typed +0 -0
  36. mlflow_optimization-0.1.0.dist-info/METADATA +52 -0
  37. mlflow_optimization-0.1.0.dist-info/RECORD +40 -0
  38. mlflow_optimization-0.1.0.dist-info/WHEEL +4 -0
  39. mlflow_optimization-0.1.0.dist-info/licenses/LICENSE-APACHE +176 -0
  40. 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