ommx_pyscipopt_adapter.adapter
==============================

.. py:module:: ommx_pyscipopt_adapter.adapter


Classes
-------

.. autoapisummary::

   ommx_pyscipopt_adapter.adapter.OMMXPySCIPOptAdapter
   ommx_pyscipopt_adapter.adapter.SCIPDiagnosticsAnalyzer
   ommx_pyscipopt_adapter.adapter.SCIPProgressSnapshot
   ommx_pyscipopt_adapter.adapter.SCIPTerminationReport


Module Contents
---------------

.. py:class:: OMMXPySCIPOptAdapter(ommx_instance: ommx.Instance, *, initial_state: Optional[ommx.ToState | ommx.NamedState] = None)



   Solve OMMX problems with SCIP through PySCIPOpt.

   Install the ``ommx-pyscipopt-adapter`` package alongside a compatible OMMX
   SDK to use these examples. :attr:`INPUT_CLASS` defines the exact supported
   input. Nonconstant linear Indicator bodies and SOS1 constraints use SCIP's
   native ``addConsIndicator`` and ``addConsSOS1`` operations. 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.

   :meth:`solve` prepares an isolated copy, leaving the caller's Instance
   unchanged. :meth:`solve_without_preparation` and the constructor require
   the input to satisfy :attr:`INPUT_CLASS` already.

   **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

   :attr:`ommx.Solution.objective` is 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.
   :class:`ommx.Solution` documents result extraction and evaluation semantics.
   The input can also come from a modeler, :meth:`ommx.Instance.load_mps`, or
   :class:`ommx.artifact.Artifact`; model construction is independent of solve.

   **Native Indicator and SOS1 constraints**

   This model rewards enabling a feature and selecting options. When
   ``enabled`` is one, the Indicator requires ``option_a + option_b <= 1``.
   The SOS1 allows at most one of ``option_b`` and ``option_c`` to 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 = 1`` and
   ``option_b = 0``. The :class:`ommx_highs_adapter.OMMXHighsAdapter`
   Reference 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. ``prepare`` changes 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 ``solve`` instead
   obtains a fresh recommended policy; it does not reuse the local ``policy``
   variable. A recommendation is an editable starting point, not a guarantee
   that every model can reach :attr:`INPUT_CLASS`.


   .. py:method:: check_applicability(ommx_instance: ommx.Instance) -> ommx.InstanceClassMembershipReport
      :classmethod:


      Check ``INPUT_CLASS`` membership without mutation.



   .. py:method:: decode(data: pyscipopt.Model) -> ommx.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
      =========

      .. doctest::

          >>> 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




   .. py:method:: decode_to_state(data: pyscipopt.Model) -> ommx.State

      Create an ommx.State from an optimized PySCIPOpt Model.

      Examples
      =========

      .. doctest::

          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}




   .. py:method:: recommended_preparation_policy() -> ommx.PreparationPolicy
      :classmethod:


      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
      :meth:`solve_without_preparation`.



   .. py:method:: require_applicable(ommx_instance: ommx.Instance) -> ommx.InstanceClassMembershipReport
      :classmethod:


      Return the membership report or raise ``AdapterNotApplicableError``.



   .. py:method:: solve(ommx_instance: ommx.Instance, *, initial_state: Optional[ommx.ToState | ommx.NamedState] = None, diagnostics: ommx.adapter.DiagnosticsSink | None = None) -> ommx.Solution
      :classmethod:


      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.

      :param ommx_instance: The ommx.Instance to prepare and solve.
      :param initial_state: Optional initial solution as a State, ID-keyed
          values, or values grouped by name and tuple subscripts.

      Examples
      =========

      The :class:`OMMXPySCIPOptAdapter` class 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
      :attr:`~ommx.Solution.state` when the IDs match.
      :meth:`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

      .. doctest::

              >>> 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

      .. doctest::

              >>> 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



   .. py:method:: solve_without_preparation(ommx_instance: ommx.Instance, *, initial_state: Optional[ommx.ToState | ommx.NamedState] = None, diagnostics: ommx.adapter.DiagnosticsSink | None = None) -> ommx.Solution
      :classmethod:


      Solve an exact PySCIPOpt Adapter input without preparing it.



   .. py:attribute:: INPUT_CLASS
      :type:  ClassVar[ommx.InstanceClass]

      Required condition for an exact Adapter input.



   .. py:property:: solver_input
      :type: pyscipopt.Model


      The PySCIPOpt model generated from this OMMX instance



.. py:class:: SCIPDiagnosticsAnalyzer(diagnostics: Iterable[Any])

   Pandas-like post-processor for PySCIPOpt diagnostics.

   The analyzer accepts either typed diagnostics collected by
   :class:`ommx.adapter.DiagnosticCollector` or dictionaries loaded from
   :attr:`ommx.experiment.Solve.diagnostics`.

   :attr:`progress_history_records` returns ``list[dict[str, object]]`` and
   does not require pandas. When diagnostics include a terminal SCIP report,
   the progress history includes a final ``TERMINATION`` row derived from that
   report. :attr:`progress_history_df` returns a pandas DataFrame indexed by
   ``solving_time_sec`` and imports pandas lazily. Time-series properties such
   as :attr:`dual_bound` return pandas Series with the same index.
   :attr:`termination_result` returns the terminal SCIP report as a dictionary.


   .. py:property:: dual_bound
      :type: Any


      Return dual bounds indexed by solving time.



   .. py:property:: event
      :type: Any


      Return progress event names indexed by solving time.



   .. py:property:: gap
      :type: Any


      Return relative gaps indexed by solving time.



   .. py:property:: incumbent_objective
      :type: Any


      Return incumbent objectives indexed by solving time.



   .. py:property:: lp_iteration_count
      :type: Any


      Return LP iteration counts indexed by solving time.



   .. py:property:: node_count
      :type: Any


      Return processed node counts indexed by solving time.



   .. py:property:: primal_bound
      :type: Any


      Return primal bounds indexed by solving time.



   .. py:property:: progress_history_df
      :type: Any


      Return progress history as a pandas DataFrame indexed by time.



   .. py:property:: progress_history_records
      :type: list[dict[str, object]]


      Return SCIP progress history, with ``TERMINATION`` when present.



   .. py:property:: progress_snapshots
      :type: tuple[SCIPProgressSnapshot, ...]


      Typed progress snapshots in the order they were recorded.



   .. py:property:: solution_count
      :type: Any


      Return stored solution counts indexed by solving time.



   .. py:property:: termination_result
      :type: dict[str, object] | None


      Return the terminal SCIP report as one dictionary, if present.



   .. py:property:: total_node_count
      :type: Any


      Return total processed node counts indexed by solving time.



.. py: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 for ``BESTSOLFOUND`` and ``DUALBOUNDIMPROVED``. Event
   snapshots are the model state visible from that callback. SCIP may call a
   ``BESTSOLFOUND`` callback before every aggregate model statistic has been
   updated, so use the ``TERMINATION`` snapshot or
   :class:`SCIPTerminationReport` for terminal values.


   .. py:method:: from_event(model: pyscipopt.Model, event: pyscipopt.scip.Event) -> SCIPProgressSnapshot
      :classmethod:



   .. py:method:: from_termination_report(report: SCIPTerminationReport) -> SCIPProgressSnapshot
      :classmethod:



   .. py:attribute:: dual_bound
      :type:  float

      SCIP dual bound reported at the snapshot.



   .. py:attribute:: event
      :type:  str

      Progress marker.

      Callback snapshots use SCIP event names such as ``"BESTSOLFOUND"`` and
      ``"DUALBOUNDIMPROVED"``. The terminal snapshot uses the synthetic
      ``"TERMINATION"`` marker.



   .. py:attribute:: gap
      :type:  float

      SCIP relative gap reported at the snapshot.



   .. py:attribute:: incumbent_objective
      :type:  float | None

      Objective value of SCIP's current best solution.

      This is ``None`` when PySCIPOpt cannot read an incumbent objective at that
      snapshot.



   .. py:attribute:: lp_iteration_count
      :type:  int

      LP iterations at the snapshot.



   .. py:attribute:: node_count
      :type:  int

      Processed branch-and-bound nodes at the snapshot.



   .. py:attribute:: primal_bound
      :type:  float

      SCIP primal bound reported at the snapshot.



   .. py:attribute:: solution_count
      :type:  int

      Number of solutions stored by SCIP at the snapshot.



   .. py:attribute:: solving_time_sec
      :type:  float

      SCIP solving time when the snapshot was recorded.



   .. py:attribute:: total_node_count
      :type:  int

      Total processed nodes including restarts at the snapshot.



.. py: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.


   .. py:method:: from_model(model: pyscipopt.Model) -> SCIPTerminationReport
      :classmethod:



   .. py:attribute:: applied_cut_count
      :type:  int

      Number of cuts applied by SCIP.



   .. py:attribute:: best_solution_count
      :type:  int

      Number of new incumbent solutions SCIP found.



   .. py:attribute:: cut_count
      :type:  int

      Number of cuts available in SCIP's cut pool.



   .. py:attribute:: dual_bound
      :type:  float

      SCIP dual bound at termination.



   .. py:attribute:: gap
      :type:  float

      SCIP relative gap reported by ``getGap()``.



   .. py:attribute:: lp_iteration_count
      :type:  int

      Total LP iterations.



   .. py:attribute:: lp_solve_count
      :type:  int

      Number of solved LPs.



   .. py:attribute:: max_depth
      :type:  int

      Maximum branch-and-bound depth.

      SCIP may report ``-1`` when no branching occurred.



   .. py:attribute:: node_count
      :type:  int

      Number of branch-and-bound nodes processed by SCIP.



   .. py:attribute:: objective_value
      :type:  float | None

      Incumbent objective value, or ``None`` when SCIP has no solution.



   .. py:attribute:: presolving_time_sec
      :type:  float

      SCIP presolving time in seconds.



   .. py:attribute:: primal_bound
      :type:  float

      SCIP primal bound at termination.



   .. py:attribute:: primal_dual_integral
      :type:  float

      SCIP primal-dual integral at termination.



   .. py:attribute:: pyscipopt_version
      :type:  str | None

      PySCIPOpt package version, if available.



   .. py:attribute:: reading_time_sec
      :type:  float

      SCIP reading time in seconds.



   .. py:attribute:: scip_version
      :type:  str

      SCIP version used through PySCIPOpt.



   .. py:attribute:: solution_count
      :type:  int

      Number of solutions stored by SCIP at termination.



   .. py:attribute:: solution_found_count
      :type:  int

      Number of solutions SCIP found during the solve.



   .. py:attribute:: solving_time_sec
      :type:  float

      SCIP solving time in seconds.



   .. py:attribute:: status
      :type:  str

      SCIP termination status, such as ``"optimal"``, ``"infeasible"``, or
      ``"unbounded"``.



   .. py:attribute:: total_node_count
      :type:  int

      Total processed nodes including restarts.



