ommx_pyscipopt_adapter.adapter#
Classes#
Solve OMMX problems with SCIP through PySCIPOpt. |
|
Pandas-like post-processor for PySCIPOpt diagnostics. |
|
SCIP solve progress observed during a solve. |
|
SCIP-side termination summary recorded after |
Module Contents#
- class OMMXPySCIPOptAdapter(ommx_instance: Instance, *, initial_state: ToState | NamedState | None = None)#
Solve OMMX problems with SCIP through PySCIPOpt.
Install the
ommx-pyscipopt-adapterpackage alongside a compatible OMMX SDK to use these examples.INPUT_CLASSdefines the exact supported input. Nonconstant linear Indicator bodies and SOS1 constraints use SCIP’s nativeaddConsIndicatorandaddConsSOS1operations. A nonconstant equality indicator becomes two inequality indicators. A constant body is simplified: a satisfied body needs no row, while an unsatisfied body forces its indicator to zero. OneHot constraints are lowered to regular equalities by the recommended preparation policy.solve()prepares an isolated copy, leaving the caller’s Instance unchanged.solve_without_preparation()and the constructor require the input to satisfyINPUT_CLASSalready.Build, solve, and inspect a 0-1 knapsack problem
Select items to maximize their total value while keeping their total weight at most the capacity. Each Binary variable is one when its item is selected. The names and subscripts preserve the connection to the input data:
>>> from ommx import Instance, Solution >>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> values = [10, 13, 18, 31, 7, 15] >>> weights = [11, 25, 20, 35, 10, 33] >>> capacity = 47 >>> problem = Instance.maximize() >>> items = [problem.new_binary("item", subscripts=[i]) for i in range(len(values))] >>> problem.objective = sum(value * item for value, item in zip(values, items)) >>> weight_limit = problem.add_constraint( ... sum(weight * item for weight, item in zip(weights, items)) <= capacity, ... "Weight limit", ... ) >>> solution = OMMXPySCIPOptAdapter.solve(problem) >>> assert solution.feasible >>> assert solution.optimality == Solution.OPTIMAL >>> selected = sorted( ... i for (i,), value in solution.extract_decision_variables("item").items() ... if value > 0.5 ... ) >>> selected [0, 3] >>> assert abs(solution.objective - sum(values[i] for i in selected)) < 1e-6 >>> assert abs(solution.objective - 41.0) < 1e-6
ommx.Solution.objectiveis the returned candidate’s objective value; the value alone does not establish optimality. Here SCIP proves optimality for the problem being evaluated. The decision-variable table provides IDs, names, subscripts, and values for tabular analysis. The regular-constraint table reports the normalized left-hand side: total weight minus capacity.>>> variables = solution.decision_variables_df() >>> assert set(variables["name"]) == {"item"} >>> assert sorted(variables["value"]) == [0.0, 0.0, 0.0, 0.0, 1.0, 1.0] >>> selection = variables[["subscripts", "value"]].copy() >>> selection["choice"] = selection["value"].map( ... lambda value: "Include" if value > 0.5 else "Exclude" ... ) >>> assert selection["choice"].value_counts().to_dict() == {"Exclude": 4, "Include": 2} >>> constraints = solution.constraints_df(kind=ConstraintKind.Regular) >>> residual = constraints.loc[weight_limit.constraint_id, "value"] >>> assert abs(residual - (sum(weights[i] for i in selected) - capacity)) < 1e-6 >>> assert abs(residual - (-1.0)) < 1e-6
The selected items weigh 46, so the inequality has one unit of slack.
ommx.Solutiondocuments result extraction and evaluation semantics. The input can also come from a modeler,ommx.Instance.load_mps(), orommx.artifact.Artifact; model construction is independent of solve.Native Indicator and SOS1 constraints
This model rewards enabling a feature and selecting options. When
enabledis one, the Indicator requiresoption_a + option_b <= 1. The SOS1 allows at most one ofoption_bandoption_cto be nonzero; it does not require either one to be selected. SCIP receives both structures directly:>>> from ommx import Instance, Sos1Constraint, ConstraintKind >>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> source = Instance.maximize() >>> enabled = source.new_binary("enabled") >>> option_a = source.new_binary("option_a") >>> option_b = source.new_binary("option_b") >>> option_c = source.new_binary("option_c") >>> source.objective = 10 * enabled + 6 * option_a + 5 * option_b + 4 * option_c >>> _ = source.add_indicator_constraint((option_a + option_b <= 1).with_indicator(enabled)) >>> _ = source.add_sos1_constraint( ... Sos1Constraint(variables=[option_b, option_c], name="exclusive_options") ... ) >>> assert OMMXPySCIPOptAdapter.INPUT_CLASS.contains(source) >>> solution = OMMXPySCIPOptAdapter.solve(source) >>> assert solution.feasible >>> assert abs(solution.objective - 20.0) < 1e-6 >>> assert source.active_special_constraint_kinds == { ... ConstraintKind.Indicator, ConstraintKind.Sos1, ... } >>> assert not source.removed_constraints
The selected values are
enabled = option_a = option_c = 1andoption_b = 0. Theommx_highs_adapter.OMMXHighsAdapterReference solves this same model through lowering, demonstrating the distinction between native support and preparation for a target input class.OneHot preparation without changing the source
Exactly one of the three binary variables must be one. The easy solve path lowers the OneHot constraint on its copy before encoding SCIP input:
>>> from ommx import Instance, OneHotConstraint, ConstraintKind >>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> choice_model = Instance.maximize() >>> choices = [choice_model.new_binary("choice", subscripts=[i]) for i in range(3)] >>> choice_model.objective = 5 * choices[0] + 10 * choices[1] + 3 * choices[2] >>> _ = choice_model.add_one_hot_constraint(OneHotConstraint(variables=choices)) >>> assert not OMMXPySCIPOptAdapter.INPUT_CLASS.contains(choice_model) >>> solution = OMMXPySCIPOptAdapter.solve(choice_model) >>> assert solution.feasible >>> assert abs(solution.objective - 10.0) < 1e-6 >>> assert choice_model.active_special_constraint_kinds == {ConstraintKind.OneHot} >>> assert not choice_model.removed_constraints
SOS1 with continuous variables
Native SOS1 support also applies to continuous variables: at most one may be nonzero, and all may be zero.
>>> from ommx import Instance, Sos1Constraint >>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> sparse_model = Instance.maximize() >>> ys = [sparse_model.new_continuous("y", lower=0, upper=10, subscripts=[i]) for i in range(3)] >>> sparse_model.objective = sum(ys) >>> _ = sparse_model.add_sos1_constraint(Sos1Constraint(variables=ys)) >>> solution = OMMXPySCIPOptAdapter.solve(sparse_model) >>> assert solution.feasible >>> assert abs(solution.objective - 10.0) < 1e-6
Select a policy and use the strict solve path
Here the application permits only OneHot lowering and keeps SOS1 native. Constructing the policy does not change the model.
preparechanges the Instance in place and stops when the target class is reached; an enabled operation need not run if the input already belongs to that class.>>> from ommx import ( ... Instance, OneHotConstraint, PreparationPolicy, Sos1Constraint, ... ConstraintKind, SpecialConstraintPreparation, ... ) >>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> model = Instance.maximize() >>> primary = [model.new_binary("primary", subscripts=[i]) for i in range(2)] >>> optional = [model.new_binary("optional", subscripts=[i]) for i in range(2)] >>> model.objective = 10 * primary[0] + 8 * primary[1] + 4 * optional[0] + 3 * optional[1] >>> _ = model.add_one_hot_constraint(OneHotConstraint(variables=primary)) >>> _ = model.add_sos1_constraint(Sos1Constraint(variables=optional)) >>> policy = PreparationPolicy( ... special_constraints=SpecialConstraintPreparation.lower_special_constraints( ... kinds={ConstraintKind.OneHot} ... ) ... ) >>> model.prepare(OMMXPySCIPOptAdapter.INPUT_CLASS, policy) >>> assert OMMXPySCIPOptAdapter.check_applicability(model).is_member >>> assert model.active_special_constraint_kinds == {ConstraintKind.Sos1} >>> solution = OMMXPySCIPOptAdapter.solve_without_preparation(model) >>> assert solution.feasible >>> assert abs(solution.objective - 14.0) < 1e-6 >>> assert len(solution.decision_variables_df()) == 4
The strict call applies no additional preparation. Calling
solveinstead obtains a fresh recommended policy; it does not reuse the localpolicyvariable. A recommendation is an editable starting point, not a guarantee that every model can reachINPUT_CLASS.- classmethod check_applicability(ommx_instance: Instance) InstanceClassMembershipReport#
Check
INPUT_CLASSmembership without mutation.
- decode(data: Model) Solution#
Convert optimized pyscipopt.Model and ommx.Instance to ommx.Solution.
This method is intended to be used if the model has been acquired with solver_input for further adjustment of the solver parameters, and separately optimizing the model.
Note that alterations to the model may make the decoding process incompatible – decoding will only work if the model still describes effectively the same problem as the OMMX instance used to create the adapter.
Backend optimality is mapped through the instance’s output-objective semantics. It remains unspecified when active-formulation optimality does not transport to that objective.
Examples#
>>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> from ommx import Instance, DecisionVariable >>> p = [10, 13, 18, 32, 7, 15] >>> w = [11, 15, 20, 35, 10, 33] >>> x = [DecisionVariable.binary(i) for i in range(6)] >>> instance = Instance.from_components( ... decision_variables=x, ... objective=sum(p[i] * x[i] for i in range(6)), ... constraints={0: sum(w[i] * x[i] for i in range(6)) <= 47}, ... sense=Sense.Maximize, ... ) >>> adapter = OMMXPySCIPOptAdapter(instance) >>> model = adapter.solver_input >>> # ... some modification of model's parameters >>> model.optimize() >>> solution = adapter.decode(model) >>> solution.objective 42.0
- decode_to_state(data: Model) State#
Create an ommx.State from an optimized PySCIPOpt Model.
Examples#
The following example shows how to solve an unconstrained linear optimization problem with `x1` as the objective function. >>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> from ommx import Instance, DecisionVariable >>> x1 = DecisionVariable.integer(1, lower=0, upper=5) >>> ommx_instance = Instance.from_components( ... decision_variables=[x1], ... objective=x1, ... constraints={}, ... sense=Sense.Minimize, ... ) >>> adapter = OMMXPySCIPOptAdapter(ommx_instance) >>> model = adapter.solver_input >>> model.optimize() >>> ommx_state = adapter.decode_to_state(model) >>> ommx_state.entries {1: 0.0}
- classmethod recommended_preparation_policy() PreparationPolicy#
Recommend lowering OneHot constraints before using PySCIPOpt.
PySCIPOpt accepts Indicator and SOS1 constraints directly, so this recommendation preserves those families and lowers only OneHot constraints. The easy API applies a fresh policy to an isolated copy; callers may instead edit and apply one before invoking
solve_without_preparation().
- classmethod require_applicable(ommx_instance: Instance) InstanceClassMembershipReport#
Return the membership report or raise
AdapterNotApplicableError.
- classmethod solve(ommx_instance: Instance, *, initial_state: ToState | NamedState | None = None, diagnostics: DiagnosticsSink | None = None) Solution#
Solve the given ommx.Instance using PySCIPopt, returning an ommx.Solution.
The input instance is not modified. An isolated copy is prepared with the recommended PySCIPOpt policy before preparation-free execution.
- Parameters:
ommx_instance – The ommx.Instance to prepare and solve.
initial_state – Optional initial solution as a State, ID-keyed values, or values grouped by name and tuple subscripts.
Examples#
The
OMMXPySCIPOptAdapterclass documentation contains a complete knapsack example from model construction through named-variable extraction, tabular analysis, objective value, and constraint slack.Start from a candidate
Evaluate the proposed assignment against the same instance you intend to solve. SCIP uses it as a starting point and can return a better solution. In this example the starting objective is 1 and the optimum is 5.
>>> from ommx import Instance >>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> problem = Instance.maximize() >>> x = problem.new_integer("x", lower=0, upper=5) >>> y = problem.new_integer("y", lower=0, upper=5) >>> problem.objective = x - y >>> _ = problem.add_constraint(x + y <= 5) >>> proposed = {x.id: 3, y.id: 2} >>> initial = problem.evaluate(proposed) >>> assert initial.feasible and initial.objective == 1 >>> result = OMMXPySCIPOptAdapter.solve(problem, initial_state=proposed) >>> assert result.feasible and result.objective == 5
To reuse a plan across models whose IDs differ, supply names and tuple subscripts. Use an empty tuple for a scalar and
(i,)for a single index. SCIP accepts the named mapping directly. To check it first, resolve the names against the target instance:>>> named = {"x": {(): 3.0}, "y": {(): 2.0}} >>> state = problem.state_from_named_values(named) >>> assert problem.evaluate(state).feasible >>> assert OMMXPySCIPOptAdapter.solve(problem, initial_state=named).objective == 5
You can also pass the resolved State or a previous solution’s
statewhen the IDs match.ommx.Instance.state_from_named_values()describes unknown or ambiguous names. SCIP initializes omitted solver variables to zero when building the proposed solution. Evaluating a partial assignment with OMMX may require additional values. A starting point neither fixes the final assignment nor guarantees a shorter solve.Infeasible Problem
>>> from ommx import Instance, DecisionVariable, Sense >>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> x = DecisionVariable.integer(0, upper=3, lower=0) >>> instance = Instance.from_components( ... decision_variables=[x], ... objective=x, ... constraints={0: x >= 4}, ... sense=Sense.Maximize, ... ) >>> OMMXPySCIPOptAdapter.solve(instance) Traceback (most recent call last): ... ommx.InfeasibleDetected: Model was infeasible
Unbounded Problem
>>> from ommx import Instance, DecisionVariable, Sense >>> from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter >>> x = DecisionVariable.integer(0, lower=0) >>> instance = Instance.from_components( ... decision_variables=[x], ... objective=x, ... constraints={}, ... sense=Sense.Maximize, ... ) >>> OMMXPySCIPOptAdapter.solve(instance) Traceback (most recent call last): ... ommx.adapter.UnboundedDetected: Model was unbounded
- classmethod solve_without_preparation(ommx_instance: Instance, *, initial_state: ToState | NamedState | None = None, diagnostics: DiagnosticsSink | None = None) Solution#
Solve an exact PySCIPOpt Adapter input without preparing it.
- INPUT_CLASS: ClassVar[InstanceClass]#
Required condition for an exact Adapter input.
- class SCIPDiagnosticsAnalyzer(diagnostics: Iterable[Any])#
Pandas-like post-processor for PySCIPOpt diagnostics.
The analyzer accepts either typed diagnostics collected by
ommx.adapter.DiagnosticCollectoror dictionaries loaded fromommx.experiment.Solve.diagnostics.progress_history_recordsreturnslist[dict[str, object]]and does not require pandas. When diagnostics include a terminal SCIP report, the progress history includes a finalTERMINATIONrow derived from that report.progress_history_dfreturns a pandas DataFrame indexed bysolving_time_secand imports pandas lazily. Time-series properties such asdual_boundreturn pandas Series with the same index.termination_resultreturns the terminal SCIP report as a dictionary.- property dual_bound: Any#
Return dual bounds indexed by solving time.
- property event: Any#
Return progress event names indexed by solving time.
- property gap: Any#
Return relative gaps indexed by solving time.
- property incumbent_objective: Any#
Return incumbent objectives indexed by solving time.
- property lp_iteration_count: Any#
Return LP iteration counts indexed by solving time.
- property node_count: Any#
Return processed node counts indexed by solving time.
- property primal_bound: Any#
Return primal bounds indexed by solving time.
- property progress_history_df: Any#
Return progress history as a pandas DataFrame indexed by time.
- property progress_history_records: list[dict[str, object]]#
Return SCIP progress history, with
TERMINATIONwhen present.
- property progress_snapshots: tuple[SCIPProgressSnapshot, ...]#
Typed progress snapshots in the order they were recorded.
- property solution_count: Any#
Return stored solution counts indexed by solving time.
- property termination_result: dict[str, object] | None#
Return the terminal SCIP report as one dictionary, if present.
- property total_node_count: Any#
Return total processed node counts indexed by solving time.
- class SCIPProgressSnapshot#
SCIP solve progress observed during a solve.
The PySCIPOpt adapter records this snapshot for each tracked SCIP event and once more from the termination report after
model.optimize()finishes. It currently listens forBESTSOLFOUNDandDUALBOUNDIMPROVED. Event snapshots are the model state visible from that callback. SCIP may call aBESTSOLFOUNDcallback before every aggregate model statistic has been updated, so use theTERMINATIONsnapshot orSCIPTerminationReportfor terminal values.- classmethod from_event(model: Model, event: Event) SCIPProgressSnapshot#
- classmethod from_termination_report(report: SCIPTerminationReport) SCIPProgressSnapshot#
- event: str#
Progress marker.
Callback snapshots use SCIP event names such as
"BESTSOLFOUND"and"DUALBOUNDIMPROVED". The terminal snapshot uses the synthetic"TERMINATION"marker.
- class SCIPTerminationReport#
SCIP-side termination summary recorded after
model.optimize().The PySCIPOpt adapter records this report before decoding the optimized SCIP model back into an OMMX solution. It is therefore available even when decoding raises an adapter exception such as infeasible or unbounded detection.
- classmethod from_model(model: Model) SCIPTerminationReport#