diff --git a/pyomo/contrib/cp/plugins.py b/pyomo/contrib/cp/plugins.py index ccebb187e06..107eb0a3988 100644 --- a/pyomo/contrib/cp/plugins.py +++ b/pyomo/contrib/cp/plugins.py @@ -11,4 +11,5 @@ def load(): from . import interval_var from .repn import docplex_writer + from .repn import cpsat_writer from .transform import logical_to_disjunctive_program diff --git a/pyomo/contrib/cp/repn/cpsat_writer.py b/pyomo/contrib/cp/repn/cpsat_writer.py new file mode 100644 index 00000000000..3beb486f09e --- /dev/null +++ b/pyomo/contrib/cp/repn/cpsat_writer.py @@ -0,0 +1,1653 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and Engineering +# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this +# software. This software is distributed under the 3-clause BSD License. +# ____________________________________________________________________________________ +"""Writer and solver plugin for Google OR-Tools' CP-SAT solver +(ortools.sat.python.cp_model). + +This mirrors the architecture of repn/docplex_writer.py (a +StreamBasedExpressionVisitor-based writer/solver pair), sharing the +solver-agnostic parts with it via repn/util.py, but the two differ in one +structural way that's worth calling out up front: CP Optimizer's docplex API +is *expression*-oriented (every relation, connective, etc. is a reusable +value you can nest anywhere), while CP-SAT's cp_model API is *statement*- +oriented (model.add(...)/model.add_bool_and(...)/etc. post a constraint; +they don't hand back something you can use as a Boolean value elsewhere). +That's the reason for the `_AUXILIARY` tag below: a Boolean-valued Pyomo +node's CP-SAT translation has to be decided lazily, based on whether it ends +up being asserted directly (cheap: no extra variable) or used as a value +nested inside something else (needs an auxiliary literal manufactured for +it via reification). This is the same "make an auxiliary variable that +stands for a logical statement's truth value" idea used in +transform/logical_to_disjunctive_walker.py's `z` variables -- the difference +is that walker always mints one, even at the root, because a disjunctive +program has no way to just assert a statement; CP-SAT can, so we only pay +for the auxiliary variable when one is actually needed. +""" + +from pyomo.common.dependencies import attempt_import + +import logging + +from pyomo.common import DeveloperError +from pyomo.common.config import ConfigDict, ConfigValue + +from pyomo.contrib.cp import IntervalVar +from pyomo.contrib.cp.interval_var import ( + IntervalVarStartTime, + IntervalVarEndTime, + IntervalVarPresence, + IntervalVarLength, + ScalarIntervalVar, + IntervalVarData, + IndexedIntervalVar, +) +from pyomo.contrib.cp.sequence_var import ( + SequenceVar, + ScalarSequenceVar, + SequenceVarData, +) +from pyomo.contrib.cp.scheduling_expr.scheduling_logic import ( + AlternativeExpression, + SpanExpression, + SynchronizeExpression, +) +from pyomo.contrib.cp.scheduling_expr.precedence_expressions import ( + BeforeExpression, + AtExpression, +) +from pyomo.contrib.cp.scheduling_expr.sequence_expressions import ( + NoOverlapExpression, + FirstInSequenceExpression, + LastInSequenceExpression, + BeforeInSequenceExpression, + PredecessorToExpression, +) +from pyomo.contrib.cp.scheduling_expr.step_function_expressions import ( + AlwaysIn, + StepAt, + StepAtStart, + StepAtEnd, + Pulse, + CumulativeFunction, + NegatedStepFunction, +) +from pyomo.contrib.cp.repn.util import ( + _GENERAL, + CPExpressionVisitorBase, + before_named_expression as _before_named_expression, + categorize_cp_model, + getitem_arg_domain, + handle_named_expression_node as _handle_named_expression_node, +) + +from pyomo.core.base import ( + minimize, + maximize, + SortComponents, + Objective, + Constraint, + Var, + BooleanVar, + LogicalConstraint, + value, +) +from pyomo.core.base.boolean_var import ( + ScalarBooleanVar, + BooleanVarData, + IndexedBooleanVar, +) +from pyomo.core.base.expression import ScalarExpression, ExpressionData +from pyomo.core.base.param import IndexedParam, ScalarParam, ParamData +from pyomo.core.base.var import ScalarVar, VarData, IndexedVar +import pyomo.core.expr as EXPR +from pyomo.core.base.set import SetProduct +from pyomo.repn.util import ExitNodeDispatcher +from pyomo.opt import WriterFactory, SolverFactory, TerminationCondition, SolverResults + +cp_model, cp_model_available = attempt_import('ortools.sat.python.cp_model') + +logger = logging.getLogger('pyomo.contrib.cp') + + +# A Boolean-valued Pyomo node whose CP-SAT translation is a *statement* +# (model.add_bool_and(...), etc.), not a reusable value -- see the module +# docstring. Carries a lazy `(assert_fn, reify_fn)` pair: `assert_fn(visitor)` +# posts the statement directly (used when this node is the root of a +# LogicalConstraint, or an unconditional conjunct of one -- no auxiliary +# variable needed); `reify_fn(visitor, lit)` manufactures the auxiliary +# Boolean `lit` and posts the "lit <=> statement" constraints linking it +# (used when this node is nested as an operand inside something else). +class _AUXILIARY: + pass + + +# A GetItemExpression on IntervalVars whose sub-attribute (.start_time vs +# .end_time vs ...) hasn't been picked yet by a GetAttrExpression, so the +# add_element(...) call can't be built yet. +class _DEFERRED_ELEMENT_CONSTRAINT: + pass + + +def _presence_literal(cpsat_interval_var): + # Every interval var this writer builds is created via + # new_optional_interval_var with an explicit presence literal (even + # "mandatory" ones, whose literal is just fixed to 1) -- see + # _create_cpsat_interval_var -- so this list always has exactly one + # element. + return cpsat_interval_var.presence_literals()[0] + + +def _materialize_literal(visitor, payload): + # Mint a fresh auxiliary Boolean and have the node's reify_fn tie it to + # the node's actual truth value. Minting the variable itself never + # depends on anything not yet known, so this is always safe to do + # eagerly, even for the deferred sequencing constraints below whose + # *content* isn't decided until every LogicalConstraint has been walked. + _, reify_fn = payload + lit = visitor.model.new_bool_var('') + reify_fn(visitor, lit) + return lit + + +def _get_int_expr(visitor, arg): + kind, payload = arg + if kind is _GENERAL: + return payload + if kind is _AUXILIARY: + # Bools are ints in CP-SAT, so a literal is already usable wherever + # an integer-valued expression is needed. + return _materialize_literal(visitor, payload) + if kind is _DEFERRED_ELEMENT_CONSTRAINT: + raise DeveloperError( + "A GetItemExpression over IntervalVars was used in a numeric " + "context without first selecting an attribute (e.g. " + "'.start_time') via a GetAttrExpression." + ) + raise DeveloperError( + "Attempting to get a CP-SAT integer-valued expression from " + "object in class %s" % str(kind) + ) + + +def _get_literal(visitor, arg): + kind, payload = arg + if kind is _GENERAL: + return payload + if kind is _AUXILIARY: + return _materialize_literal(visitor, payload) + raise DeveloperError( + "Attempting to get a CP-SAT Boolean-valued expression from " + "object in class %s" % str(kind) + ) + + +def _assert_true(visitor, arg): + kind, payload = arg + if kind is _AUXILIARY: + assert_fn, reify_fn = payload + assert_fn(visitor) + elif kind is _GENERAL: + visitor.model.add(payload == 1) + else: + raise DeveloperError( + "Attempting to assert a CP-SAT expression from " + "object in class %s as a LogicalConstraint" % str(kind) + ) + + +def _bounds(elem): + # elem is either a plain Python constant, or a CP-SAT IntVar/literal. + if isinstance(elem, (int, float)): + return elem, elem + # NOTE: elem.proto.domain is a protobuf repeated-field wrapper, not a + # plain Python list -- its negative indexing (e.g. dom[-1]) does not + # behave like a list's and silently returns the wrong element, so + # convert it to a real list first. + dom = list(elem.proto.domain) + return dom[0], dom[-1] + + +def _nm(name): + # OR-Tools' new_*_var() factories want a str, not None, even for an + # anonymous/unlabeled variable. + return name if name is not None else '' + + +def _sub_name(name, suffix): + # Build a per-sub-variable name (e.g. an IntervalVar's "_start") without + # producing None when the base name itself is None (symbolic_solver_labels + # is off, or the base component's name simply wasn't requested). + return name + suffix if name else '' + + +## +# Leaf/component handlers +## + + +def _make_int_var(visitor, pyomo_var, name, what): + # Shared by regular Vars and by an IntervalVar's start_time/end_time/ + # length sub-variables (all plain ScalarVars domained on Integers). + if pyomo_var.fixed: + return visitor.model.new_constant(int(value(pyomo_var))) + lb, ub = pyomo_var.bounds + if lb is None or ub is None: + raise ValueError( + "The CP-SAT writer requires finite bounds on every integer " + "variable (unlike CP Optimizer, CP-SAT has no notion of an " + "unbounded horizon/domain). Cannot write %s '%s' with bounds " + "%s." % (what, pyomo_var.name, (lb, ub)) + ) + return visitor.model.new_int_var(int(lb), int(ub), _nm(name)) + + +def _create_cpsat_var(visitor, pyomo_var, name=None): + if pyomo_var.is_binary(): + return visitor.model.new_bool_var(_nm(name)) + elif pyomo_var.is_integer(): + return _make_int_var(visitor, pyomo_var, name, 'Var') + elif pyomo_var.domain.isdiscrete(): + if pyomo_var.domain.isfinite(): + return visitor.model.new_int_var_from_domain( + cp_model.Domain.from_values(sorted(pyomo_var.domain)), _nm(name) + ) + raise ValueError( + "The CP-SAT writer does not support infinite discrete " + "domains. Cannot write Var '%s' with domain '%s'" + % (pyomo_var.name, pyomo_var.domain) + ) + else: + raise ValueError( + "The CP-SAT writer can only support integer- or Boolean-valued " + "variables. Cannot write Var '%s' with domain '%s'" + % (pyomo_var.name, pyomo_var.domain) + ) + + +def _before_var(visitor, child): + _id = id(child) + if _id not in visitor.var_map: + if child.fixed: + return False, (_GENERAL, child.value) + nm = child.name if visitor.symbolic_solver_labels else None + cpsat_var = _create_cpsat_var(visitor, child, name=nm) + visitor.var_map[_id] = cpsat_var + visitor.pyomo_to_native[child] = cpsat_var + return False, (_GENERAL, visitor.var_map[_id]) + + +def _before_indexed_var(visitor, child): + cpsat_vars = {} + for i, v in child.items(): + if v.fixed: + cpsat_vars[i] = v.value + continue + nm = v.name if visitor.symbolic_solver_labels else None + cpsat_var = _create_cpsat_var(visitor, v, name=nm) + visitor.var_map[id(v)] = cpsat_var + visitor.pyomo_to_native[v] = cpsat_var + cpsat_vars[i] = cpsat_var + return False, (_GENERAL, cpsat_vars) + + +def _before_boolean_var(visitor, child): + _id = id(child) + if _id not in visitor.var_map: + if child.fixed: + # A fixed literal still needs to behave like one (e.g. support + # .Not()) wherever it's used, so it can't just be a bare Python + # int the way a fixed *numeric* Var's value can be. + return False, (_GENERAL, visitor.model.new_constant(int(value(child)))) + nm = child.name if visitor.symbolic_solver_labels else None + # Unlike docplex, CP-SAT bool vars are already usable directly as + # Boolean literals -- no "== 1" wrapper needed to disambiguate them + # from a generic integer variable. + cpsat_var = visitor.model.new_bool_var(_nm(nm)) + visitor.var_map[_id] = cpsat_var + visitor.pyomo_to_native[child] = cpsat_var + return False, (_GENERAL, visitor.var_map[_id]) + + +def _before_indexed_boolean_var(visitor, child): + cpsat_vars = {} + for i, v in child.items(): + if v.fixed: + cpsat_vars[i] = visitor.model.new_constant(int(value(v))) + continue + nm = v.name if visitor.symbolic_solver_labels else None + cpsat_var = visitor.model.new_bool_var(_nm(nm)) + visitor.var_map[id(v)] = cpsat_var + visitor.pyomo_to_native[v] = cpsat_var + cpsat_vars[i] = cpsat_var + return False, (_GENERAL, cpsat_vars) + + +def _before_param(visitor, child): + return False, (_GENERAL, value(child)) + + +def _before_indexed_param(visitor, child): + return False, (_GENERAL, {idx: value(p) for idx, p in child.items()}) + + +def _create_cpsat_interval_var(visitor, interval_var): + nm = interval_var.name if visitor.symbolic_solver_labels else None + # NOTE: OR-Tools variable names exist "for debug/logging only" -- that's + # the literal comment on IntegerVariableProto's `name` field in + # cp_model.proto -- and need not be unique; CP-SAT identifies variables + # internally by index, not name. So appending "_start"/"_end"/"_size"/ + # "_present" below can't introduce a name collision that would change + # the meaning of the model actually built (it would only ever affect + # what shows up in solver debug logs, and only when + # symbolic_solver_labels=True is requested in the first place). + s = _make_int_var( + visitor, interval_var.start_time, nm and nm + '_start', 'start time' + ) + z = _make_int_var(visitor, interval_var.length, nm and nm + '_size', 'length') + e = _make_int_var(visitor, interval_var.end_time, nm and nm + '_end', 'end time') + + # Always create an explicit presence literal, even for a mandatory + # interval (where it's simply fixed to 1) -- this keeps + # _presence_literal() uniform, and the fixed-value constraint costs + # nothing once CP-SAT's presolve propagates it. + p = visitor.model.new_bool_var(_sub_name(nm, '_present')) + if interval_var.is_present.fixed: + visitor.model.add(p == int(value(interval_var.is_present))) + + # new_optional_interval_var enforces start + size == end *only when the + # presence literal is 1* -- exactly matching Pyomo's own semantics (an + # absent IntervalVar's start/end/length aren't linked to each other + # either), so no separate linking constraint is needed here. + return visitor.model.new_optional_interval_var(s, z, e, p, _nm(nm)) + + +def _get_cpsat_interval_var(visitor, interval_var): + _id = id(interval_var) + if _id not in visitor.var_map: + visitor.var_map[_id] = _create_cpsat_interval_var(visitor, interval_var) + return visitor.var_map[_id] + + +def _before_interval_var(visitor, child): + cpsat_iv = _get_cpsat_interval_var(visitor, child) + visitor.pyomo_to_native[child] = cpsat_iv + return False, (_GENERAL, cpsat_iv) + + +def _before_indexed_interval_var(visitor, child): + cpsat_vars = {} + for i, v in child.items(): + cpsat_iv = _get_cpsat_interval_var(visitor, v) + visitor.pyomo_to_native[v] = cpsat_iv + cpsat_vars[i] = cpsat_iv + return False, (_GENERAL, cpsat_vars) + + +def _before_interval_var_start_time(visitor, child): + interval_var = child.get_associated_interval_var() + cpsat_iv = _get_cpsat_interval_var(visitor, interval_var) + return False, (_GENERAL, cpsat_iv.start_expr()) + + +def _before_interval_var_end_time(visitor, child): + interval_var = child.get_associated_interval_var() + cpsat_iv = _get_cpsat_interval_var(visitor, interval_var) + return False, (_GENERAL, cpsat_iv.end_expr()) + + +def _before_interval_var_length(visitor, child): + interval_var = child.get_associated_interval_var() + cpsat_iv = _get_cpsat_interval_var(visitor, interval_var) + return False, (_GENERAL, cpsat_iv.size_expr()) + + +def _before_interval_var_presence(visitor, child): + interval_var = child.get_associated_interval_var() + cpsat_iv = _get_cpsat_interval_var(visitor, interval_var) + return False, (_GENERAL, _presence_literal(cpsat_iv)) + + +def _before_sequence_var(visitor, child): + _id = id(child) + if _id not in visitor.var_map: + members = [_get_cpsat_interval_var(visitor, v) for v in child.interval_vars] + visitor.var_map[_id] = members + visitor.pyomo_to_native[child] = members + return False, (_GENERAL, visitor.var_map[_id]) + + +## +# GetItemExpression / GetAttrExpression (variable indirection) +## + + +def _handle_getitem(visitor, node, *data): + # Determining each index argument's finite domain (and, if relevant, + # the (min, max, step) "scale" of that domain) is solver-agnostic and + # lives in repn/util.py, shared with docplex_writer.py. + arg_domain = [] + expr = 0 + mult = 1 + # Note: skipping the first argument: that's the IndexedComponent itself. + for i, arg in enumerate(data[1:]): + arg_set, scale = getitem_arg_domain(node, i, arg[1]) + arg_domain.append(arg_set) + if scale is not None: + _min, _max, _step = scale + if _step is None: + raise ValueError( + "Variable indirection '%s' is over a discrete domain " + "without a constant step size. This is not supported." % node + ) + # Unlike docplex's expression objects, CP-SAT's IntVar/LinearExpr + # don't support "//" directly, so a non-constant numerator needs + # an explicit auxiliary target + add_division_equality. + numerator = _get_int_expr(visitor, arg) - _min + if isinstance(numerator, (int, float)): + term = numerator // _step + else: + ub = (_max - _min) // _step + term = visitor.model.new_int_var(0, ub, '') + visitor.model.add_division_equality(term, numerator, _step) + expr += mult * term + mult *= len(arg_set) + + # Get the list of all elements selectable by the argument expression(s). + elements = [] + for idx in SetProduct(*arg_domain): + try: + idx = idx if len(idx) > 1 else idx[0] + elements.append(data[0][1][idx]) + except KeyError: + raise ValueError( + "Variable indirection '%s' permits an index '%s' " + "that is not a valid key. In CP-SAT, this is a " + "structural infeasibility." % (node, idx) + ) + + if elements and hasattr(elements[0], 'start_expr'): + # IntervalVar candidates: we don't yet know which attribute + # (start_time, end_time, ...) the caller wants, so we can't build + # the add_element() call yet -- deferred until a GetAttrExpression + # picks one (see _handle_getattr). Decided proactively here (by + # inspecting the CP-SAT object we already built), rather than via + # docplex's try/except AssertionError dance, since CP-SAT gives no + # equivalent "you built the wrong kind of element constraint" + # signal to catch. + return (_DEFERRED_ELEMENT_CONSTRAINT, (elements, expr)) + + lb = min(_bounds(e)[0] for e in elements) + ub = max(_bounds(e)[1] for e in elements) + target = visitor.model.new_int_var(lb, ub, '') + visitor.model.add_element(expr, elements, target) + return (_GENERAL, target) + + +_deferred_element_getattr_dispatcher = { + 'start_time': lambda iv: iv.start_expr(), + 'end_time': lambda iv: iv.end_expr(), + 'length': lambda iv: iv.size_expr(), + 'is_present': _presence_literal, +} + + +def _handle_getattr(visitor, node, obj, attr): + if obj[0] is _DEFERRED_ELEMENT_CONSTRAINT: + elements, expr = obj[1] + try: + resolved = [ + _deferred_element_getattr_dispatcher[attr[1]](e) for e in elements + ] + except KeyError: + logger.error("Unrecognized attribute in GetAttrExpression: %s." % attr[1]) + raise + lb = min(_bounds(e)[0] for e in resolved) + ub = max(_bounds(e)[1] for e in resolved) + target = visitor.model.new_int_var(lb, ub, '') + visitor.model.add_element(expr, resolved, target) + return (_GENERAL, target) + raise DeveloperError( + "Unrecognized argument type '%s' to getattr dispatcher." % obj[0] + ) + + +def _handle_call(visitor, node, *args): + # docplex supports calling methods like '.before()'/'.implies()' through + # variable indirection (e.g. 'm.i[m.x].before(...)') by routing them + # through GetAttrExpression + CallExpression. This writer doesn't yet -- + # the ordinary (non-indirected) form 'm.i.start_time.before(...)' is + # unaffected, since that's a plain Python method call that never + # produces a CallExpression node at all. + raise NotImplementedError( + "The CP-SAT writer does not yet support calling methods such as " + "'.before()', '.after()', '.at()', '.implies()', etc. through " + "variable indirection (found in expression '%s'). Rewrite the " + "constraint without indirection." % node + ) + + +## +# Algebraic expressions +## + + +def _handle_monomial_expr(visitor, node, arg1, arg2): + if arg2[1].__class__ in EXPR.native_types: + return _GENERAL, arg1[1] * arg2[1] + elif arg1[1].__class__ in EXPR.native_types and arg1[1] == 1: + return arg2 + return (_GENERAL, _get_int_expr(visitor, arg1) * _get_int_expr(visitor, arg2)) + + +def _handle_sum_node(visitor, node, *args): + return (_GENERAL, sum(_get_int_expr(visitor, arg) for arg in args)) + + +def _handle_negation_node(visitor, node, arg1): + return (_GENERAL, -1 * _get_int_expr(visitor, arg1)) + + +def _new_aux_int_var(visitor, lb, ub): + return visitor.model.new_int_var(lb, ub, '') + + +def _handle_product_node(visitor, node, arg1, arg2): + a = _get_int_expr(visitor, arg1) + b = _get_int_expr(visitor, arg2) + a_lb, a_ub = _bounds(a) + b_lb, b_ub = _bounds(b) + products = [a_lb * b_lb, a_lb * b_ub, a_ub * b_lb, a_ub * b_ub] + target = _new_aux_int_var(visitor, min(products), max(products)) + visitor.model.add_multiplication_equality(target, [a, b]) + return (_GENERAL, target) + + +def _handle_division_node(visitor, node, arg1, arg2): + # NOTE: CP-SAT's add_division_equality is *truncating* integer division + # (rounds toward 0), unlike docplex's cp.float_div. A Pyomo model + # written with CP Optimizer's division semantics in mind may not port + # over with identical results. + a = _get_int_expr(visitor, arg1) + b = _get_int_expr(visitor, arg2) + a_lb, a_ub = _bounds(a) + b_lb, b_ub = _bounds(b) + if b_lb <= 0 <= b_ub: + raise ValueError( + "Cannot write DivisionExpression '%s' to CP-SAT: the " + "denominator's domain includes 0." % node + ) + quotients = [a_lb // b_lb, a_lb // b_ub, a_ub // b_lb, a_ub // b_ub] + target = _new_aux_int_var(visitor, min(quotients), max(quotients)) + visitor.model.add_division_equality(target, a, b) + return (_GENERAL, target) + + +def _handle_pow_node(visitor, node, arg1, arg2): + base = _get_int_expr(visitor, arg1) + if arg2[1].__class__ not in EXPR.native_types or arg2[1] < 0: + raise NotImplementedError( + "The CP-SAT writer only supports PowExpression with a " + "non-negative constant integer exponent. Cannot write '%s'." % node + ) + exponent = int(arg2[1]) + if exponent == 0: + return (_GENERAL, 1) + result = base + for _ in range(exponent - 1): + a_lb, a_ub = _bounds(result) + b_lb, b_ub = _bounds(base) + products = [a_lb * b_lb, a_lb * b_ub, a_ub * b_lb, a_ub * b_ub] + target = _new_aux_int_var(visitor, min(products), max(products)) + visitor.model.add_multiplication_equality(target, [result, base]) + result = target + return (_GENERAL, result) + + +def _handle_abs_node(visitor, node, arg1): + a = _get_int_expr(visitor, arg1) + a_lb, a_ub = _bounds(a) + ub = max(abs(a_lb), abs(a_ub)) + target = _new_aux_int_var(visitor, 0, ub) + visitor.model.add_abs_equality(target, a) + return (_GENERAL, target) + + +def _handle_min_node(visitor, node, *args): + exprs = [_get_int_expr(visitor, arg) for arg in args] + lb = min(_bounds(e)[0] for e in exprs) + ub = min(_bounds(e)[1] for e in exprs) + target = _new_aux_int_var(visitor, lb, ub) + visitor.model.add_min_equality(target, exprs) + return (_GENERAL, target) + + +def _handle_max_node(visitor, node, *args): + exprs = [_get_int_expr(visitor, arg) for arg in args] + lb = max(_bounds(e)[0] for e in exprs) + ub = max(_bounds(e)[1] for e in exprs) + target = _new_aux_int_var(visitor, lb, ub) + visitor.model.add_max_equality(target, exprs) + return (_GENERAL, target) + + +## +# Relational expressions (all _AUXILIARY: CP-SAT constraints are statements) +## + + +def _handle_equality_node(visitor, node, arg1, arg2): + a = _get_int_expr(visitor, arg1) + b = _get_int_expr(visitor, arg2) + + def assert_fn(visitor): + visitor.model.add(a == b) + + def reify_fn(visitor, lit): + visitor.model.add(a == b).only_enforce_if(lit) + visitor.model.add(a != b).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_not_equal_node(visitor, node, arg1, arg2): + a = _get_int_expr(visitor, arg1) + b = _get_int_expr(visitor, arg2) + + def assert_fn(visitor): + visitor.model.add(a != b) + + def reify_fn(visitor, lit): + visitor.model.add(a != b).only_enforce_if(lit) + visitor.model.add(a == b).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_inequality_node(visitor, node, arg1, arg2): + # arg1 <= arg2 + a = _get_int_expr(visitor, arg1) + b = _get_int_expr(visitor, arg2) + + def assert_fn(visitor): + visitor.model.add(a <= b) + + def reify_fn(visitor, lit): + visitor.model.add(a <= b).only_enforce_if(lit) + visitor.model.add(a >= b + 1).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_ranged_inequality_node(visitor, node, arg1, arg2, arg3): + # arg1 <= arg2 <= arg3 + lo = _get_int_expr(visitor, arg1) + mid = _get_int_expr(visitor, arg2) + hi = _get_int_expr(visitor, arg3) + + def assert_fn(visitor): + visitor.model.add(lo <= mid) + visitor.model.add(mid <= hi) + + def reify_fn(visitor, lit): + visitor.model.add(lo <= mid).only_enforce_if(lit) + visitor.model.add(mid <= hi).only_enforce_if(lit) + # not(lo <= mid <= hi) <=> (mid < lo) or (mid > hi) + below = visitor.model.new_bool_var('') + visitor.model.add(mid < lo).only_enforce_if(below) + above = visitor.model.new_bool_var('') + visitor.model.add(mid > hi).only_enforce_if(above) + visitor.model.add_bool_or([below, above]).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +## +# Logical expressions (all _AUXILIARY) +## + + +def _handle_not_node(visitor, node, arg): + # A materialized literal's .Not() is itself a valid literal at zero + # extra cost -- no CP-SAT call, and no new auxiliary variable, needed. + return (_GENERAL, _get_literal(visitor, arg).Not()) + + +def _handle_and_node(visitor, node, *args): + lits = [_get_literal(visitor, a) for a in args] + + def assert_fn(visitor): + visitor.model.add_bool_and(lits) + + def reify_fn(visitor, lit): + visitor.model.add_bool_and(lits).only_enforce_if(lit) + visitor.model.add_bool_or([l.Not() for l in lits]).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_or_node(visitor, node, *args): + lits = [_get_literal(visitor, a) for a in args] + + def assert_fn(visitor): + visitor.model.add_bool_or(lits) + + def reify_fn(visitor, lit): + visitor.model.add_bool_or(lits).only_enforce_if(lit) + visitor.model.add_bool_and([l.Not() for l in lits]).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_xor_node(visitor, node, arg1, arg2): + a = _get_literal(visitor, arg1) + b = _get_literal(visitor, arg2) + + def assert_fn(visitor): + visitor.model.add_bool_xor([a, b]) + + def reify_fn(visitor, lit): + visitor.model.add_bool_xor([a, b]).only_enforce_if(lit) + visitor.model.add(a == b).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_implication_node(visitor, node, arg1, arg2): + # a => b == (not a) or b -- delegate rather than duplicate add_bool_or. + not_a = (_GENERAL, _get_literal(visitor, arg1).Not()) + return _handle_or_node(visitor, node, not_a, arg2) + + +def _handle_equivalence_node(visitor, node, arg1, arg2): + # Bools are ints in CP-SAT, so equivalence of two literals is literal + # integer equality. + a = _get_literal(visitor, arg1) + b = _get_literal(visitor, arg2) + return _handle_equality_node(visitor, node, (_GENERAL, a), (_GENERAL, b)) + + +def _handle_exactly_node(visitor, node, *args): + n = _get_int_expr(visitor, args[0]) + total = sum(_get_literal(visitor, a) for a in args[1:]) + + def assert_fn(visitor): + visitor.model.add(total == n) + + def reify_fn(visitor, lit): + visitor.model.add(total == n).only_enforce_if(lit) + visitor.model.add(total != n).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_at_most_node(visitor, node, *args): + n = _get_int_expr(visitor, args[0]) + total = sum(_get_literal(visitor, a) for a in args[1:]) + + def assert_fn(visitor): + visitor.model.add(total <= n) + + def reify_fn(visitor, lit): + visitor.model.add(total <= n).only_enforce_if(lit) + visitor.model.add(total >= n + 1).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_at_least_node(visitor, node, *args): + n = _get_int_expr(visitor, args[0]) + total = sum(_get_literal(visitor, a) for a in args[1:]) + + def assert_fn(visitor): + visitor.model.add(total >= n) + + def reify_fn(visitor, lit): + visitor.model.add(total >= n).only_enforce_if(lit) + visitor.model.add(total <= n - 1).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_all_diff_node(visitor, node, *args): + exprs = [_get_int_expr(visitor, arg) for arg in args] + + def assert_fn(visitor): + visitor.model.add_all_different(exprs) + + def reify_fn(visitor, lit): + visitor.model.add_all_different(exprs).only_enforce_if(lit) + # CP-SAT has no reified "not all different" primitive; build the + # negation as "some pair is equal" directly. + pairs = [] + for i in range(len(exprs)): + for j in range(i + 1, len(exprs)): + eq = visitor.model.new_bool_var('') + visitor.model.add(exprs[i] == exprs[j]).only_enforce_if(eq) + pairs.append(eq) + visitor.model.add_bool_or(pairs).only_enforce_if(lit.Not()) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_count_if_node(visitor, node, *args): + # CountIfExpression is numeric-valued (a count), not Boolean -- no + # reification machinery needed at all. + return (_GENERAL, sum(_get_literal(visitor, arg) for arg in args)) + + +## +# Named expressions +## + +# before_named_expression / handle_named_expression_node are shared with +# docplex_writer.py via repn/util.py (imported above, aliased to the names +# used in the dispatch tables below). + + +## +# Scheduling: precedence +## + + +def _handle_before_expression_node(visitor, node, time1, time2, delay): + # CP-SAT has no analog of docplex's start_before_start/etc. specialized + # functions, so precedence is always just a plain affine comparison -- + # no "maybe use the specialized form" fallback dance needed. + lhs = (_GENERAL, _get_int_expr(visitor, time1) + _get_int_expr(visitor, delay)) + return _handle_inequality_node(visitor, node, lhs, time2) + + +def _handle_at_expression_node(visitor, node, time1, time2, delay): + lhs = (_GENERAL, _get_int_expr(visitor, time1) + _get_int_expr(visitor, delay)) + return _handle_equality_node(visitor, node, lhs, time2) + + +## +# Scheduling: span / alternative / synchronize +## +# +# None of these three has a native CP-SAT primitive (unlike docplex's +# cp.span/cp.alternative/cp.synchronize). Each is decomposed below into +# reified presence + start/end/size (in)equalities. Only the "assert +# directly" case is implemented -- Pyomo only ever asserts these as a +# top-level scheduling requirement in practice, and reifying them soundly as +# a *nested* Boolean value would be substantially more work for a case that +# isn't expected to arise; that path raises NotImplementedError rather than +# silently doing something wrong. + + +def _assert_span(visitor, container, members): + model = visitor.model + c_present = _presence_literal(container) + m_present = [_presence_literal(m) for m in members] + + # container present iff at least one member present + model.add_bool_or(m_present).only_enforce_if(c_present) + model.add_bool_and([p.Not() for p in m_present]).only_enforce_if(c_present.Not()) + + c_start = container.start_expr() + c_end = container.end_expr() + at_min_start = [] + at_max_end = [] + for m, p in zip(members, m_present): + # the container's span bounds every present member ... + model.add(c_start <= m.start_expr()).only_enforce_if([c_present, p]) + model.add(c_end >= m.end_expr()).only_enforce_if([c_present, p]) + # ... and each of these literals is a one-directional witness that + # some present member actually attains the min start / max end (the + # <= / >= constraints above already guarantee c_start/c_end can't be + # tighter than the true min/max, so it's sound to let the solver + # pick whichever member(s) satisfy the equality; we don't need the + # converse direction since these literals aren't consumed anywhere + # else). + is_min = model.new_bool_var('') + model.add(c_start == m.start_expr()).only_enforce_if(is_min) + at_min_start.append(is_min) + is_max = model.new_bool_var('') + model.add(c_end == m.end_expr()).only_enforce_if(is_max) + at_max_end.append(is_max) + model.add_bool_or(at_min_start).only_enforce_if(c_present) + model.add_bool_or(at_max_end).only_enforce_if(c_present) + + +def _assert_alternative(visitor, container, members): + model = visitor.model + c_present = _presence_literal(container) + m_present = [_presence_literal(m) for m in members] + + model.add(sum(m_present) == 1).only_enforce_if(c_present) + model.add(sum(m_present) == 0).only_enforce_if(c_present.Not()) + + c_start = container.start_expr() + c_end = container.end_expr() + c_size = container.size_expr() + for m, p in zip(members, m_present): + model.add(c_start == m.start_expr()).only_enforce_if(p) + model.add(c_end == m.end_expr()).only_enforce_if(p) + model.add(c_size == m.size_expr()).only_enforce_if(p) + + +def _assert_synchronize(visitor, container, members): + # Pyomo's docstring for SynchronizeExpression only says "if the + # container is present, the members start/end with it," but the + # underlying CP Optimizer primitive this wraps (cp.synchronize) actually + # forces full presence *equality* (a member is present iff the + # container is), not just one-directional timing when a member happens + # to be present -- matching that is what's implemented here. + model = visitor.model + c_present = _presence_literal(container) + c_start = container.start_expr() + c_end = container.end_expr() + for m in members: + model.add(_presence_literal(m) == c_present) + model.add(m.start_expr() == c_start).only_enforce_if(c_present) + model.add(m.end_expr() == c_end).only_enforce_if(c_present) + + +def _make_structural_scheduling_auxiliary(kind, assert_body): + def assert_fn(visitor): + assert_body(visitor) + + def reify_fn(visitor, lit): + raise NotImplementedError( + "The CP-SAT writer does not support using a %sExpression as a " + "nested Boolean term (only asserting it directly)." % kind + ) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_span_expression_node(visitor, node, *args): + container = args[0][1] + members = [a[1] for a in args[1:]] + return _make_structural_scheduling_auxiliary( + 'Span', lambda visitor: _assert_span(visitor, container, members) + ) + + +def _handle_alternative_expression_node(visitor, node, *args): + container = args[0][1] + members = [a[1] for a in args[1:]] + return _make_structural_scheduling_auxiliary( + 'Alternative', lambda visitor: _assert_alternative(visitor, container, members) + ) + + +def _handle_synchronize_expression_node(visitor, node, *args): + container = args[0][1] + members = [a[1] for a in args[1:]] + return _make_structural_scheduling_auxiliary( + 'Synchronize', lambda visitor: _assert_synchronize(visitor, container, members) + ) + + +## +# Scheduling: sequencing (no_overlap / first / last / before_in / predecessor_to) +## +# +# CP-SAT has no sequence/permutation-of-intervals primitive at all (Pyomo's +# SequenceVar is already just a plain Python list on the Pyomo side -- see +# sequence_var.py -- with no CP-SAT object counterpart here either). Whether +# first_in_sequence/last_in_sequence/before_in_sequence/predecessor_to can be +# encoded cheaply (as reified start-time comparisons) or need a heavier, +# fully general encoding (explicit rank/position variables + AllDifferent) +# depends on whether a NoOverlapExpression is *unconditionally* asserted +# elsewhere over the same SequenceVar -- and that can only be known once +# every LogicalConstraint in the model has been processed, regardless of the +# order constraints were declared in or whether they share an expression +# tree. So these four constraint types are never resolved at exitNode time: +# each just records a task and defers translation to the very end of +# CPSatWriter.write(), after every LogicalConstraint has been walked and +# asserted (see visitor.deferred_sequence_tasks / _resolve_sequence_task). + + +class _SequenceTask: + __slots__ = ('kind', 'args', 'seq') + + def __init__(self, kind, args, seq): + self.kind = kind + self.args = args + self.seq = seq + + +def _defer_sequence_task(kind, args, seq_var): + task = _SequenceTask(kind, args, seq_var) + + def assert_fn(visitor): + visitor.deferred_sequence_tasks.append(task) + + def reify_fn(visitor, lit): + raise NotImplementedError( + "The CP-SAT writer does not support using a %s sequencing " + "expression as a nested Boolean term (only asserting it " + "directly)." % kind + ) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_no_overlap_expression_node(visitor, node, seq_var): + members = seq_var[1] + seq = node.arg(0) + + def assert_fn(visitor): + visitor.model.add_no_overlap(members) + # Recorded here (in assert_fn), not at exitNode/walk time, so that a + # NoOverlapExpression sitting inside e.g. Or(no_overlap(seq), foo) + # (i.e. not actually guaranteed) correctly does *not* get credited -- + # that path goes through reify_fn below instead. + visitor.sequences_with_no_overlap.add(id(seq)) + + def reify_fn(visitor, lit): + raise NotImplementedError( + "The CP-SAT writer does not support using a NoOverlapExpression " + "as a nested Boolean term (only asserting it directly)." + ) + + return (_AUXILIARY, (assert_fn, reify_fn)) + + +def _handle_first_in_sequence_expression_node(visitor, node, interval_var, seq_var): + return _defer_sequence_task('first_in_sequence', (node.arg(0),), node.arg(1)) + + +def _handle_last_in_sequence_expression_node(visitor, node, interval_var, seq_var): + return _defer_sequence_task('last_in_sequence', (node.arg(0),), node.arg(1)) + + +def _handle_before_in_sequence_expression_node( + visitor, node, before_var, after_var, seq_var +): + return _defer_sequence_task( + 'before_in_sequence', (node.arg(0), node.arg(1)), node.arg(2) + ) + + +def _handle_predecessor_to_expression_node( + visitor, node, before_var, after_var, seq_var +): + return _defer_sequence_task( + 'predecessor_to', (node.arg(0), node.arg(1)), node.arg(2) + ) + + +def _get_sequence_positions(visitor, seq): + # Explicit rank/position variables, giving each member of the sequence + # a genuine (solver-chosen) position independent of its timing -- the + # fully general fallback, built lazily and memoized per SequenceVar, + # only for sequences that don't have an accompanying NoOverlap to + # piggyback a cheaper encoding on (see the module comment above). + _id = id(seq) + if _id in visitor.sequence_positions: + return visitor.sequence_positions[_id] + + model = visitor.model + members = seq.interval_vars + n = len(members) + positions = {} + pos_vars = [] + for i, iv in enumerate(members): + p = _presence_literal(_get_cpsat_interval_var(visitor, iv)) + # A present member's position is one of 0..n-1; an absent member is + # pinned to its own unique sentinel n+i (not a single shared + # sentinel -- two simultaneously-absent members sharing one value + # would otherwise violate add_all_different for no good reason). + domain = cp_model.Domain.from_intervals([[0, n - 1], [n + i, n + i]]) + pos = model.new_int_var_from_domain(domain, '') + model.add(pos < n).only_enforce_if(p) + model.add(pos == n + i).only_enforce_if(p.Not()) + positions[id(iv)] = pos + pos_vars.append(pos) + model.add_all_different(pos_vars) + + visitor.sequence_positions[_id] = positions + return positions + + +def _resolve_sequence_task(visitor, task): + model = visitor.model + seq = task.seq + use_positions = id(seq) not in visitor.sequences_with_no_overlap + + def iv(pyomo_iv): + return _get_cpsat_interval_var(visitor, pyomo_iv) + + def present(pyomo_iv): + return _presence_literal(iv(pyomo_iv)) + + if task.kind == 'first_in_sequence': + (target,) = task.args + others = [m for m in seq.interval_vars if m is not target] + if use_positions: + positions = _get_sequence_positions(visitor, seq) + for m in others: + model.add(positions[id(target)] < positions[id(m)]).only_enforce_if( + [present(target), present(m)] + ) + else: + for m in others: + model.add( + iv(target).start_expr() <= iv(m).start_expr() + ).only_enforce_if([present(target), present(m)]) + + elif task.kind == 'last_in_sequence': + (target,) = task.args + others = [m for m in seq.interval_vars if m is not target] + if use_positions: + positions = _get_sequence_positions(visitor, seq) + for m in others: + model.add(positions[id(target)] > positions[id(m)]).only_enforce_if( + [present(target), present(m)] + ) + else: + for m in others: + model.add( + iv(target).start_expr() >= iv(m).start_expr() + ).only_enforce_if([present(target), present(m)]) + + elif task.kind == 'before_in_sequence': + before_iv, after_iv = task.args + if use_positions: + positions = _get_sequence_positions(visitor, seq) + model.add( + positions[id(before_iv)] < positions[id(after_iv)] + ).only_enforce_if([present(before_iv), present(after_iv)]) + else: + model.add( + iv(before_iv).end_expr() <= iv(after_iv).start_expr() + ).only_enforce_if([present(before_iv), present(after_iv)]) + + elif task.kind == 'predecessor_to': + before_iv, after_iv = task.args + p_before = present(before_iv) + p_after = present(after_iv) + if use_positions: + # Direct adjacency in rank is simpler here than the start-time + # version below: no "nothing in between" loop is needed at all. + positions = _get_sequence_positions(visitor, seq) + model.add( + positions[id(after_iv)] == positions[id(before_iv)] + 1 + ).only_enforce_if([p_before, p_after]) + else: + model.add( + iv(before_iv).end_expr() <= iv(after_iv).start_expr() + ).only_enforce_if([p_before, p_after]) + # ... and no other present member of the sequence may sit + # strictly between them. + for m in seq.interval_vars: + if m is before_iv or m is after_iv: + continue + p_m = present(m) + # One-directional reifications suffice here: these two + # literals are consumed only inside the add_bool_or below, + # so forcing "literal true => inequality holds" is enough to + # make the disjunction actually imply what we want; we don't + # need the converse. + m_is_before = model.new_bool_var('') + model.add( + iv(m).end_expr() <= iv(before_iv).start_expr() + ).only_enforce_if(m_is_before) + m_is_after = model.new_bool_var('') + model.add( + iv(m).start_expr() >= iv(after_iv).end_expr() + ).only_enforce_if(m_is_after) + model.add_bool_or([m_is_before, m_is_after]).only_enforce_if( + [p_before, p_after, p_m] + ) + else: + raise DeveloperError("Unrecognized deferred sequence task kind %r" % task.kind) + + +## +# Scheduling: step functions / cumulative resources +## +# +# Confirmed scope for v1: a fast path for the common "sum of Pulses over the +# whole horizon, capacity bound" case (-> add_cumulative), a special case for +# a sum of Steps only (-> add_reservoir_constraint_with_active), and a clear +# NotImplementedError naming the failing condition for everything else +# (mixed Pulse+Step, non-constant heights/bounds, a partial sub-window). The +# general breakpoint/event-based encoding for the fully general case is +# deferred to a follow-up. +# +# AlwaysIn is intercepted in beforeChild (alongside Pulse/Step*/ +# CumulativeFunction/NegatedStepFunction, all of which never reach exitNode +# on their own here -- see LogicalToCpSat.step_function_handles) so this +# handler sees the *raw*, unwalked node: deciding which fast path (if any) +# applies requires inspecting the original Pulse/Step term structure, which +# would already be lost by the time a normal walk finished reducing +# everything down to a single summed numeric expression. + + +def _try_constant(x): + if x.__class__ in EXPR.native_types: + return x + return None + + +def _decompose_cumulative_function(cumul_func): + # Returns a list of (term, sign) pairs: `term` is always an elementary + # Pulse/StepAt/StepAtStart/StepAtEnd (a NegatedStepFunction is unwrapped, + # its negation folded into `sign`, which is +1 or -1). + raw = ( + cumul_func.args if cumul_func.__class__ is CumulativeFunction else [cumul_func] + ) + terms = [] + for t in raw: + if t.__class__ is NegatedStepFunction: + terms.append((t.args[0], -1)) + else: + terms.append((t, 1)) + return terms + + +def _term_interval_var_data(term): + # Pulse keys off ._interval_var; StepAtStart/StepAtEnd key off ._time, + # which Step.__new__ already resolved to the associated IntervalVarData. + # StepAt's ._time is a bare constant, not tied to any interval. + if term.__class__ is Pulse: + return term._interval_var + if term.__class__ in (StepAtStart, StepAtEnd): + return term._time + return None + + +def _covers_whole_horizon(terms, start_val, end_val): + if start_val is None or end_val is None: + return False + for t, _sign in terms: + iv_data = _term_interval_var_data(t) + if iv_data is None: + # StepAt: a bare constant trigger time, not tied to an interval. + if not (start_val <= t._time): + return False + continue + if not ( + start_val <= value(iv_data.start_time.lb) + and end_val >= value(iv_data.end_time.ub) + ): + return False + return True + + +def _handle_always_in_node(visitor, node): + cumul_func, lb, ub, start, end = node.args + terms = _decompose_cumulative_function(cumul_func) + + lb_val = _try_constant(lb) + ub_val = _try_constant(ub) + start_val = _try_constant(start) + end_val = _try_constant(end) + heights = [] + for t, sign in terms: + h = _try_constant(t._height) + heights.append(None if h is None else sign * h) + + if ( + lb_val == 0 + and all(t.__class__ is Pulse for t, _sign in terms) + and all(h is not None and h >= 0 for h in heights) + and ub_val is not None + and _covers_whole_horizon(terms, start_val, end_val) + ): + intervals = [ + _get_cpsat_interval_var(visitor, t._interval_var) for t, _sign in terms + ] + visitor.model.add_cumulative(intervals, heights, ub_val) + return False, (_GENERAL, 1) + + if ( + terms + and all(t.__class__ in (StepAt, StepAtStart, StepAtEnd) for t, _sign in terms) + and all(h is not None for h in heights) + and lb_val is not None + and ub_val is not None + and _covers_whole_horizon(terms, start_val, end_val) + ): + times = [] + actives = [] + for t, _sign in terms: + if t.__class__ is StepAt: + times.append(t._time) + actives.append(True) + else: + cpsat_iv = _get_cpsat_interval_var(visitor, t._time) + times.append( + cpsat_iv.start_expr() + if t.__class__ is StepAtStart + else cpsat_iv.end_expr() + ) + actives.append(_presence_literal(cpsat_iv)) + visitor.model.add_reservoir_constraint_with_active( + times, heights, actives, lb_val, ub_val + ) + return False, (_GENERAL, 1) + + raise NotImplementedError( + "The CP-SAT writer only supports AlwaysIn constraints in two " + "special cases: (1) a sum of Pulse terms only, with a zero lower " + "bound, constant non-negative heights, and a window covering the " + "full range of the referenced interval vars (translated to " + "add_cumulative), or (2) a sum of Step terms only, with constant " + "heights and a window similarly covering the full horizon " + "(translated to add_reservoir_constraint_with_active). This " + "model's AlwaysIn('%s') satisfies neither -- it may mix Pulse and " + "Step terms, use a non-constant bound/height/window, or use a " + "window that doesn't cover the full horizon of the terms involved. " + "General AlwaysIn support is not yet implemented." % node + ) + + +_step_function_handles = { + AlwaysIn: lambda visitor, node: _handle_always_in_node(visitor, node) +} + + +## +# Dispatch tables +## + +_operator_handles = { + EXPR.GetItemExpression: _handle_getitem, + EXPR.GetAttrExpression: _handle_getattr, + EXPR.CallExpression: _handle_call, + EXPR.NegationExpression: _handle_negation_node, + EXPR.ProductExpression: _handle_product_node, + EXPR.DivisionExpression: _handle_division_node, + EXPR.PowExpression: _handle_pow_node, + EXPR.AbsExpression: _handle_abs_node, + EXPR.MonomialTermExpression: _handle_monomial_expr, + EXPR.SumExpression: _handle_sum_node, + EXPR.MinExpression: _handle_min_node, + EXPR.MaxExpression: _handle_max_node, + EXPR.NotExpression: _handle_not_node, + EXPR.EquivalenceExpression: _handle_equivalence_node, + EXPR.ImplicationExpression: _handle_implication_node, + EXPR.AndExpression: _handle_and_node, + EXPR.OrExpression: _handle_or_node, + EXPR.XorExpression: _handle_xor_node, + EXPR.ExactlyExpression: _handle_exactly_node, + EXPR.AtMostExpression: _handle_at_most_node, + EXPR.AtLeastExpression: _handle_at_least_node, + EXPR.AllDifferentExpression: _handle_all_diff_node, + EXPR.CountIfExpression: _handle_count_if_node, + EXPR.EqualityExpression: _handle_equality_node, + EXPR.NotEqualExpression: _handle_not_equal_node, + EXPR.InequalityExpression: _handle_inequality_node, + EXPR.RangedExpression: _handle_ranged_inequality_node, + BeforeExpression: _handle_before_expression_node, + AtExpression: _handle_at_expression_node, + ExpressionData: _handle_named_expression_node, + ScalarExpression: _handle_named_expression_node, + NoOverlapExpression: _handle_no_overlap_expression_node, + FirstInSequenceExpression: _handle_first_in_sequence_expression_node, + LastInSequenceExpression: _handle_last_in_sequence_expression_node, + BeforeInSequenceExpression: _handle_before_in_sequence_expression_node, + PredecessorToExpression: _handle_predecessor_to_expression_node, + SpanExpression: _handle_span_expression_node, + AlternativeExpression: _handle_alternative_expression_node, + SynchronizeExpression: _handle_synchronize_expression_node, +} + + +class LogicalToCpSat(CPExpressionVisitorBase): + exit_node_dispatcher = ExitNodeDispatcher(_operator_handles) + + var_handles = { + IntervalVarStartTime: _before_interval_var_start_time, + IntervalVarEndTime: _before_interval_var_end_time, + IntervalVarLength: _before_interval_var_length, + IntervalVarPresence: _before_interval_var_presence, + ScalarIntervalVar: _before_interval_var, + IntervalVarData: _before_interval_var, + IndexedIntervalVar: _before_indexed_interval_var, + ScalarSequenceVar: _before_sequence_var, + SequenceVarData: _before_sequence_var, + ScalarVar: _before_var, + VarData: _before_var, + IndexedVar: _before_indexed_var, + ScalarBooleanVar: _before_boolean_var, + BooleanVarData: _before_boolean_var, + IndexedBooleanVar: _before_indexed_boolean_var, + ExpressionData: _before_named_expression, + ScalarExpression: _before_named_expression, + IndexedParam: _before_indexed_param, + ScalarParam: _before_param, + ParamData: _before_param, + } + step_function_handles = _step_function_handles + + def __init__(self, cpsat_model, symbolic_solver_labels=False): + super().__init__(symbolic_solver_labels=symbolic_solver_labels) + self.model = cpsat_model + # Populated as a side effect of asserting NoOverlapExpressions (see + # _handle_no_overlap_expression_node); consulted while resolving + # deferred_sequence_tasks, both below. + self.sequences_with_no_overlap = set() + self.deferred_sequence_tasks = [] + # Lazily built, memoized by id(SequenceVarData), only for sequences + # that actually need the position-variable fallback. + self.sequence_positions = {} + + +@WriterFactory.register( + 'cpsat_model', 'Generate the corresponding OR-Tools CP-SAT cp_model.CpModel object' +) +class CPSatWriter: + CONFIG = ConfigDict('cpsat_model_writer') + CONFIG.declare( + 'symbolic_solver_labels', + ConfigValue( + default=False, + domain=bool, + description='Write Pyomo Var and Constraint names to the CP-SAT model', + ), + ) + + def __init__(self): + self.config = self.CONFIG() + + def write(self, model, **options): + config = options.pop('config', self.config)(options) + + components = categorize_cp_model(model, sort=SortComponents.deterministic) + + cpsat_model = cp_model.CpModel() + visitor = LogicalToCpSat( + cpsat_model, symbolic_solver_labels=config.symbolic_solver_labels + ) + + active_objs = components[Objective] + if len(active_objs) > 1: + raise ValueError( + "More than one active objective defined for " + "input model '%s': Cannot write to CP-SAT." % model.name + ) + elif len(active_objs) == 1: + obj = active_objs[0] + obj_expr = visitor.walk_expression((obj.expr, obj, 0)) + obj_int_expr = _get_int_expr(visitor, obj_expr) + if obj.sense is minimize: + cpsat_model.minimize(obj_int_expr) + else: + cpsat_model.maximize(obj_int_expr) + # No objective is fine too, this is CP after all... + + # Write algebraic constraints + for cons in components[Constraint]: + expr = visitor.walk_expression((cons.body, cons, 0)) + expr_val = _get_int_expr(visitor, expr) + if cons.lower is not None: + cpsat_model.add(cons.lb <= expr_val) + if cons.upper is not None: + cpsat_model.add(expr_val <= cons.ub) + + # Write interval vars (these are secretly constraints if they have + # to be scheduled) -- walked once, purely for the side effect of + # creating them, even if otherwise unreferenced. + for var in components[IntervalVar]: + visitor.walk_expression((var, var, 0)) + + # Same idea for sequence vars, so their member lists are available + # before any dependent sequencing constraint is processed. + for var in components[SequenceVar]: + visitor.walk_expression((var, var, 0)) + + # Write logical constraints. This can't be a single "walk, then + # immediately assert" loop the way docplex's writer does it: the + # sequencing constraints deferred above need every LogicalConstraint + # walked *and* asserted first, so that visitor.sequences_with_no_overlap + # is complete regardless of declaration order (see the "Scheduling: + # sequencing" section of cpsat_writer.py for the full explanation). + walked = [ + (cons, visitor.walk_expression((cons.expr, cons, 0))) + for cons in components[LogicalConstraint] + ] + for cons, expr in walked: + _assert_true(visitor, expr) + + for task in visitor.deferred_sequence_tasks: + _resolve_sequence_task(visitor, task) + + return cpsat_model, visitor.pyomo_to_native + + +@SolverFactory.register( + 'cp_sat', doc='Direct interface to Google OR-Tools CP-SAT solver' +) +class CPSatSolver: + CONFIG = ConfigDict('cp_sat_solver') + CONFIG.declare( + 'symbolic_solver_labels', + ConfigValue( + default=False, + domain=bool, + description='Write Pyomo Var and Constraint names to the CP-SAT model', + ), + ) + CONFIG.declare( + 'tee', + ConfigValue( + default=False, domain=bool, description="Stream solver output to terminal." + ), + ) + CONFIG.declare( + 'options', ConfigValue(default={}, description="Dictionary of solver options.") + ) + + def __init__(self, **kwds): + self.config = self.CONFIG() + self.config.set_value(kwds) + # A flat 1:1 status map suffices here -- unlike docplex's separate + # solve-status/stop-cause distinction, CP-SAT's solve() returns a + # single status enum. + if cp_model_available: + self._status_map = { + cp_model.OPTIMAL: TerminationCondition.optimal, + cp_model.FEASIBLE: TerminationCondition.feasible, + cp_model.INFEASIBLE: TerminationCondition.infeasible, + cp_model.MODEL_INVALID: TerminationCondition.error, + cp_model.UNKNOWN: TerminationCondition.unknown, + } + + @property + def options(self): + return self.config.options + + # Support use as a context manager under current solver API + def __enter__(self): + return self + + def __exit__(self, t, v, traceback): + pass + + def available(self, exception_flag=True): + return bool(cp_model_available) + + def license_is_valid(self): + # CP-SAT is open-source with no license restriction. + return True + + def solve(self, model, **kwds): + """Solve the model. + + Args: + model (Block): a Pyomo model or block to be solved + + """ + config = self.config() + config.set_value(kwds) + + writer = CPSatWriter() + cpsat_model, var_map = writer.write( + model, symbolic_solver_labels=config.symbolic_solver_labels + ) + + solver = cp_model.CpSolver() + for key, val in self.options.items(): + setattr(solver.parameters, key, val) + if config.tee: + solver.parameters.log_search_progress = True + + status = solver.solve(cpsat_model) + + results = SolverResults() + results.solver.name = "CP-SAT" + results.problem.name = model.name + results.solver.solve_time = solver.wall_time + results.solver.termination_condition = self._status_map.get( + status, TerminationCondition.unknown + ) + + if cpsat_model.has_objective(): + objs = list(model.component_data_objects(Objective, active=True)) + sense = objs[0].sense if objs else None + val = solver.objective_value + bound = solver.best_objective_bound + results.problem.number_of_objectives = 1 + results.problem.sense = sense + if sense is maximize: + results.problem.lower_bound = val + results.problem.upper_bound = bound + else: + results.problem.lower_bound = bound + results.problem.upper_bound = val + else: + results.problem.number_of_objectives = 0 + results.problem.sense = None + results.problem.lower_bound = None + results.problem.upper_bound = None + + # Copy the variable values onto the Pyomo model, using the map we + # stored on the writer. + if status in (cp_model.OPTIMAL, cp_model.FEASIBLE): + for py_var, cpsat_var in var_map.items(): + if py_var.ctype is SequenceVar: + # They don't actually have values -- the IntervalVars + # will get set. + continue + if py_var.ctype is IntervalVar: + p = solver.value(_presence_literal(cpsat_var)) + if not p: + py_var.is_present.set_value(False) + else: + start = solver.value(cpsat_var.start_expr()) + end = solver.value(cpsat_var.end_expr()) + py_var.is_present.set_value(True) + py_var.start_time.set_value(start, skip_validation=True) + py_var.end_time.set_value(end, skip_validation=True) + py_var.length.set_value(end - start, skip_validation=True) + elif py_var.ctype in {Var, BooleanVar}: + py_var.set_value(solver.value(cpsat_var), skip_validation=True) + else: + raise DeveloperError( + "Unrecognized Pyomo type in pyomo-to-CP-SAT " + "variable map: %s" % type(py_var) + ) + + return results diff --git a/pyomo/contrib/cp/repn/docplex_writer.py b/pyomo/contrib/cp/repn/docplex_writer.py index 4065a3381aa..7705f9e53f9 100644 --- a/pyomo/contrib/cp/repn/docplex_writer.py +++ b/pyomo/contrib/cp/repn/docplex_writer.py @@ -9,13 +9,11 @@ from pyomo.common.dependencies import attempt_import -import itertools import logging -from operator import attrgetter from pyomo.common import DeveloperError from pyomo.common.config import ConfigDict, ConfigValue -from pyomo.common.collections import ComponentMap +from pyomo.common.deprecation import deprecated from pyomo.common.fileutils import Executable from pyomo.contrib.cp import IntervalVar @@ -59,18 +57,23 @@ NegatedStepFunction, ) +from pyomo.contrib.cp.repn.util import ( + _GENERAL, + CPExpressionVisitorBase, + before_named_expression as _before_named_expression, + categorize_cp_model, + getitem_arg_domain, + handle_named_expression_node as _handle_named_expression_node, +) from pyomo.core.base import ( minimize, maximize, SortComponents, - Block, Objective, Constraint, Var, - Param, BooleanVar, LogicalConstraint, - Suffix, value, ) from pyomo.core.base.boolean_var import ( @@ -82,18 +85,10 @@ from pyomo.core.base.param import IndexedParam, ScalarParam, ParamData from pyomo.core.base.var import ScalarVar, VarData, IndexedVar import pyomo.core.expr as EXPR -from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, identify_variables -from pyomo.core.base import Set, RangeSet from pyomo.core.base.set import SetProduct -from pyomo.repn.util import ExitNodeDispatcher +from pyomo.repn.util import ExitNodeDispatcher, categorize_valid_components from pyomo.opt import WriterFactory, SolverFactory, TerminationCondition, SolverResults -### FIXME: Remove the following as soon as non-active components no -### longer report active==True -from pyomo.network import Port - -### - def _finalize_docplex(module, available): if not available: @@ -125,9 +120,8 @@ def _finalize_docplex(module, available): logger = logging.getLogger('pyomo.contrib.cp') -# These are things that don't need special handling: -class _GENERAL: - pass +# _GENERAL (things that don't need special handling) is defined in +# repn/util.py and shared with the other CP writer(s). # These are operations that need to be deferred sometimes, usually because of @@ -188,24 +182,6 @@ class _EQUIVALENT_TO: pass -def _check_var_domain(visitor, node, var): - if not var.domain.isdiscrete(): - # Note: in the context of the current writer, this should be unreachable - # because we can't handle non-discrete variables at all, so there will - # already be errors handling the children of this expression. - raise ValueError( - "Variable indirection '%s' contains argument '%s', " - "which is not a discrete variable" % (node, var) - ) - bnds = var.bounds - if None in bnds: - raise ValueError( - "Variable indirection '%s' contains argument '%s', " - "which is not restricted to a finite discrete domain" % (node, var) - ) - return var.domain & RangeSet(*bnds) - - def _handle_getitem(visitor, node, *data): # First we need to determine the range for each of the the # arguments. They can be: @@ -213,64 +189,23 @@ def _handle_getitem(visitor, node, *data): # - simple values # - docplex integer variables # - docplex integer expressions + # + # Determining each argument's domain (and, where relevant, the (min, max, + # step) "scale" of that domain) is solver-agnostic, so it's implemented + # once, in repn/util.py, and shared with the other CP writer(s). arg_domain = [] arg_scale = [] expr = 0 mult = 1 # Note: skipping the first argument: that should be the IndexedComponent for i, arg in enumerate(data[1:]): - if arg[1].__class__ in EXPR.native_types: - arg_set = Set(initialize=[arg[1]]) - arg_set.construct() - arg_domain.append(arg_set) - arg_scale.append(None) - elif node.arg(i + 1).is_expression_type(): - # This argument is an expression. It could be any - # combination of any number of integer variables, as long as - # the resulting expression is still an IntExpression. We - # can't really rely on FBBT here, because we need to know - # that the expression returns values in a regular domain - # (i.e., the set of possible values has to have a start, - # end, and finite, regular step). - # - # We will brute force it: go through every combination of - # every variable and record the resulting expression value. - arg_expr = node.arg(i + 1) - var_list = list(identify_variables(arg_expr, include_fixed=False)) - var_domain = [list(_check_var_domain(visitor, node, v)) for v in var_list] - arg_vals = set() - for var_vals in itertools.product(*var_domain): - for v, val in zip(var_list, var_vals): - v.set_value(val) - arg_vals.add(arg_expr()) - # Now that we have all the values that define the domain of - # the result of the expression, stick them into a set and - # rely on the Set infrastructure to calculate (and verify) - # the interval. - arg_set = Set(initialize=sorted(arg_vals)) - arg_set.construct() - interval = arg_set.get_interval() - if not interval[2]: - raise ValueError( - "Variable indirection '%s' contains argument expression " - "'%s' that does not evaluate to a simple discrete set" - % (node, arg_expr) - ) - arg_domain.append(arg_set) - arg_scale.append(interval) - else: - # This had better be a simple variable over a regular - # discrete domain. When we add support for categorical - # variables, we will need to ensure that the categoricals - # have already been converted to simple integer domains by - # this point. - var = node.arg(i + 1) - arg_domain.append(_check_var_domain(visitor, node, var)) - arg_scale.append(arg_domain[-1].get_interval()) + arg_set, scale = getitem_arg_domain(node, i, arg[1]) + arg_domain.append(arg_set) + arg_scale.append(scale) # Build the expression that maps arguments to GetItem() to a # position in the elements list - if arg_scale[-1] is not None: - _min, _max, _step = arg_scale[-1] + if scale is not None: + _min, _max, _step = scale # ESJ: Have to use integer division here because otherwise, later, # when we construct the element constraint, docplex won't believe # the index is an integer expression. @@ -285,7 +220,7 @@ def _handle_getitem(visitor, node, *data): # lower and upper bounds were part of the step. That # *should* be the case for Set, but I am suffering from a # crisis of confidence at the moment. - mult *= len(arg_domain[-1]) + mult *= len(arg_set) # Get the list of all elements selectable by the argument # expression(s); fill in new variables for any indices allowable by # the argument expression(s) but not present in the IndexedComponent @@ -367,7 +302,7 @@ def _before_boolean_var(visitor, child): # return a Boolean expression (in docplex land) so this can be used as # an argument to logical expressions later visitor.var_map[_id] = cpx_var == 1 - visitor.pyomo_to_docplex[child] = cpx_var + visitor.pyomo_to_native[child] = cpx_var return False, (_GENERAL, visitor.var_map[_id]) @@ -380,7 +315,7 @@ def _before_indexed_boolean_var(visitor, child): cpx_var = cp.binary_var(name=v.name if visitor.symbolic_solver_labels else None) visitor.cpx.add(cpx_var) visitor.var_map[id(v)] = cpx_var == 1 - visitor.pyomo_to_docplex[v] = cpx_var + visitor.pyomo_to_native[v] = cpx_var cpx_vars[i] = cpx_var == 1 return False, (_GENERAL, cpx_vars) @@ -431,7 +366,7 @@ def _before_var(visitor, child): ) visitor.cpx.add(cpx_var) visitor.var_map[_id] = cpx_var - visitor.pyomo_to_docplex[child] = cpx_var + visitor.pyomo_to_native[child] = cpx_var return False, (_GENERAL, visitor.var_map[_id]) @@ -443,30 +378,18 @@ def _before_indexed_var(visitor, child): ) visitor.cpx.add(cpx_var) visitor.var_map[id(v)] = cpx_var - visitor.pyomo_to_docplex[v] = cpx_var + visitor.pyomo_to_native[v] = cpx_var cpx_vars[i] = cpx_var return False, (_GENERAL, cpx_vars) -def _handle_named_expression_node(visitor, node, expr): - visitor._named_expressions[id(node)] = expr[1] - return expr - - -def _before_named_expression(visitor, child): - _id = id(child) - if _id not in visitor._named_expressions: - return True, None - return False, (_GENERAL, visitor._named_expressions[_id]) - - def _create_docplex_interval_var(visitor, interval_var): # Create a new docplex interval var and then figure out all the info that # gets stored on it nm = interval_var.name if visitor.symbolic_solver_labels else None cpx_interval_var = cp.interval_var(name=nm) visitor.var_map[id(interval_var)] = cpx_interval_var - visitor.pyomo_to_docplex[interval_var] = cpx_interval_var + visitor.pyomo_to_native[interval_var] = cpx_interval_var # Figure out if it exists if interval_var.is_present.fixed and not interval_var.is_present.value: @@ -546,7 +469,7 @@ def _before_sequence_var(visitor, child): if _id not in visitor.var_map: cpx_seq_var = _get_docplex_sequence_var(visitor, child) visitor.var_map[_id] = cpx_seq_var - visitor.pyomo_to_docplex[child] = cpx_seq_var + visitor.pyomo_to_native[child] = cpx_seq_var return False, (_GENERAL, visitor.var_map[_id]) @@ -556,7 +479,7 @@ def _before_interval_var(visitor, child): if _id not in visitor.var_map: cpx_interval_var = _get_docplex_interval_var(visitor, child) visitor.var_map[_id] = cpx_interval_var - visitor.pyomo_to_docplex[child] = cpx_interval_var + visitor.pyomo_to_native[child] = cpx_interval_var return False, (_GENERAL, visitor.var_map[_id]) @@ -566,7 +489,7 @@ def _before_indexed_interval_var(visitor, child): for i, v in child.items(): cpx_interval_var = _get_docplex_interval_var(visitor, v) visitor.var_map[id(v)] = cpx_interval_var - visitor.pyomo_to_docplex[v] = cpx_interval_var + visitor.pyomo_to_native[v] = cpx_interval_var cpx_vars[i] = cpx_interval_var return False, (_GENERAL, cpx_vars) @@ -1040,12 +963,12 @@ def _handle_synchronize_expression_node(visitor, node, *args): } -class LogicalToDoCplex(StreamBasedExpressionVisitor): +class LogicalToDoCplex(CPExpressionVisitorBase): exit_node_dispatcher = ExitNodeDispatcher(_operator_handles) # NOTE: Because of indirection, we can encounter indexed Params and Vars in # expressions - _var_handles = { + var_handles = { IntervalVarStartTime: _before_interval_var_start_time, IntervalVarEndTime: _before_interval_var_end_time, IntervalVarLength: _before_interval_var_length, @@ -1067,62 +990,39 @@ class LogicalToDoCplex(StreamBasedExpressionVisitor): ScalarParam: _before_param, ParamData: _before_param, } + step_function_handles = _step_function_handles def __init__(self, cpx_model, symbolic_solver_labels=False): + super().__init__(symbolic_solver_labels=symbolic_solver_labels) self.cpx = cpx_model - self.symbolic_solver_labels = symbolic_solver_labels - self._process_node = self._process_node_bx - self.var_map = {} - self._named_expressions = {} - self.pyomo_to_docplex = ComponentMap() - - def initializeWalker(self, expr): - expr, src, src_idx = expr - walk, result = self.beforeChild(None, expr, 0) - if not walk: - return False, result - return True, expr - - def beforeChild(self, node, child, child_idx): - # Return native types - if child.__class__ in EXPR.native_types: - return False, (_GENERAL, child) - - if child.__class__ in step_func_expression_types: - return _step_function_handles[child.__class__](self, child) - - # Convert Vars Logical vars to docplex equivalents - if not child.is_expression_type() or child.is_named_expression_type(): - return self._var_handles[child.__class__](self, child) - - return True, None - - def exitNode(self, node, data): - return self.exit_node_dispatcher[node.__class__](self, node, *data) - - finalizeResult = None - - -# [ESJ 11/7/22]: TODO: We should revisit this method in the future, as it is not -# very efficient. -def collect_valid_components(model, active=True, sort=None, valid=set(), targets=set()): - assert active in (True, None) - unrecognized = {} - components = {k: [] for k in targets} - for obj in model.component_data_objects(active=True, descend_into=True, sort=sort): - # HACK around #3045 - if not hasattr(obj, 'ctype'): - continue - ctype = obj.ctype - if ctype in components: - components[ctype].append(obj) - elif ctype not in valid: - if ctype not in unrecognized: - unrecognized[ctype] = [obj] - else: - unrecognized[ctype].append(obj) +@deprecated( + "collect_valid_components() is deprecated. Use " + "pyomo.repn.util.categorize_valid_components() instead. Note that its " + "component_map maps a component type to the *blocks* that contain " + "components of that type, not to the component data objects " + "themselves (as this function's 'components' return value did), so " + "callers need an additional loop over " + "block.component_data_objects(...) to recover a component-data list.", + version='6.10.2', +) +def collect_valid_components(model, active=True, sort=None, valid=None, targets=None): + if valid is None: + valid = set() + if targets is None: + targets = set() + component_map, unrecognized = categorize_valid_components( + model, active=active, sort=sort, valid=valid, targets=targets + ) + components = {ctype: [] for ctype in targets} + for ctype, blocks in component_map.items(): + for block in blocks: + components[ctype].extend( + block.component_data_objects( + ctype, active=True, descend_into=False, sort=sort + ) + ) return components, unrecognized @@ -1146,44 +1046,7 @@ def __init__(self): def write(self, model, **options): config = options.pop('config', self.config)(options) - components, unknown = collect_valid_components( - model, - active=True, - sort=SortComponents.deterministic, - valid={ - Block, - Objective, - Constraint, - Var, - Param, - BooleanVar, - LogicalConstraint, - Suffix, - # FIXME: Non-active components should not report as Active - Set, - RangeSet, - Port, - }, - targets={ - Objective, - Constraint, - LogicalConstraint, - IntervalVar, - SequenceVar, - }, - ) - if unknown: - raise ValueError( - "The model ('%s') contains the following active components " - "that the docplex writer does not know how to process:\n\t%s" - % ( - model.name, - "\n\t".join( - "%s:\n\t\t%s" % (k, "\n\t\t".join(map(attrgetter('name'), v))) - for k, v in unknown.items() - ), - ) - ) + components = categorize_cp_model(model, sort=SortComponents.deterministic) cpx_model = cp.CpoModel() visitor = LogicalToDoCplex( @@ -1242,7 +1105,7 @@ def write(self, model, **options): cpx_model.add(expr[1]) # That's all, folks. - return cpx_model, visitor.pyomo_to_docplex + return cpx_model, visitor.pyomo_to_native @SolverFactory.register('cp_optimizer', doc='Direct interface to CPLEX CP Optimizer') diff --git a/pyomo/contrib/cp/repn/util.py b/pyomo/contrib/cp/repn/util.py new file mode 100644 index 00000000000..aff3523f161 --- /dev/null +++ b/pyomo/contrib/cp/repn/util.py @@ -0,0 +1,241 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and Engineering +# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this +# software. This software is distributed under the 3-clause BSD License. +# ____________________________________________________________________________________ +"""Infrastructure shared between the pyomo.contrib.cp writers (currently +docplex_writer.py and cpsat_writer.py). Everything here is solver-agnostic: +it operates on Pyomo components and expressions only, and knows nothing +about docplex or OR-Tools. +""" + +import itertools +from operator import attrgetter + +from pyomo.common.collections import ComponentMap +from pyomo.contrib.cp.interval_var import IntervalVar +from pyomo.contrib.cp.sequence_var import SequenceVar +from pyomo.core.base import ( + Block, + BooleanVar, + Constraint, + LogicalConstraint, + Objective, + Param, + RangeSet, + Set, + Suffix, + Var, +) +import pyomo.core.expr as EXPR +from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, identify_variables +from pyomo.repn.util import categorize_valid_components + +# FIXME: Remove the following as soon as non-active components no longer +# report active==True (see #3045) +from pyomo.network import Port + + +# A generic tag for an already-resolved, directly-usable value (a native +# Python constant, or a solver-native variable/expression object). This is +# the one tag that genuinely means the same thing regardless of which +# backend is writing the model, so it lives here instead of being defined +# separately (and identically) in each writer. +class _GENERAL: + pass + + +# The set of component types that pyomo.contrib.cp's modeling layer can +# appear as, and the subset of those that a writer actually needs to walk. +# Both writers currently target exactly this component surface (they differ +# only in how they translate what they find, not in what they're willing to +# find), so the categorization step is shared. +_CP_TARGET_CTYPES = {Objective, Constraint, LogicalConstraint, IntervalVar, SequenceVar} +# categorize_valid_components() treats every target ctype as implicitly +# valid, and errors out if a ctype appears in both sets -- so the ctypes we +# only want to *tolerate* (not descend into and collect) go here, and must +# exclude anything already in _CP_TARGET_CTYPES. +_CP_VALID_CTYPES = {Block, Var, Param, BooleanVar, Suffix, Set, RangeSet, Port} + + +def categorize_cp_model(model, sort=None): + """Collect the components of a pyomo.contrib.cp model that a CP writer + needs to translate. + + Returns a dict mapping each of Objective, Constraint, LogicalConstraint, + IntervalVar, and SequenceVar to the flat list of active component data + objects of that type found on `model`. Raises ValueError if the model + contains an active component of any other, unrecognized, type. + """ + component_map, unrecognized = categorize_valid_components( + model, active=True, sort=sort, valid=_CP_VALID_CTYPES, targets=_CP_TARGET_CTYPES + ) + if unrecognized: + raise ValueError( + "The model ('%s') contains the following active components " + "that this writer does not know how to process:\n\t%s" + % ( + model.name, + "\n\t".join( + "%s:\n\t\t%s" % (k, "\n\t\t".join(map(attrgetter('name'), v))) + for k, v in unrecognized.items() + ), + ) + ) + # categorize_valid_components' component_map maps ctype to the *blocks* + # that contain a component of that type, not to the component data + # objects themselves, so we still need to descend into each block to get + # the actual list of things to translate. + components = {ctype: [] for ctype in _CP_TARGET_CTYPES} + for ctype, blocks in component_map.items(): + for block in blocks: + components[ctype].extend( + block.component_data_objects( + ctype, active=True, descend_into=False, sort=sort + ) + ) + return components + + +def _check_var_domain(node, var): + if not var.domain.isdiscrete(): + # Note: in the context of the current writer, this should be unreachable + # because we can't handle non-discrete variables at all, so there will + # already be errors handling the children of this expression. + raise ValueError( + "Variable indirection '%s' contains argument '%s', " + "which is not a discrete variable" % (node, var) + ) + bnds = var.bounds + if None in bnds: + raise ValueError( + "Variable indirection '%s' contains argument '%s', " + "which is not restricted to a finite discrete domain" % (node, var) + ) + return var.domain & RangeSet(*bnds) + + +def getitem_arg_domain(node, i, arg_value): + """Determine the finite discrete domain of the i-th indirection index + argument of a GetItemExpression `node` (the IndexedComponent being + accessed is `node.arg(0)`; `i` counts the remaining index arguments + starting from 0, i.e. this is about `node.arg(i + 1)`). + + `arg_value` is that argument's already-resolved value if it is a plain + constant (its class is in pyomo.core.expr.native_types); otherwise it is + ignored, and the argument's domain is instead determined either by + brute-force enumeration (if the argument is itself an expression: we + can't rely on FBBT to tell us the domain is a regular, finite-step range, + so we evaluate the expression over every combination of its variables' + values) or by the bounds of a discrete Var. + + Returns a (domain, scale) pair: `domain` is a Pyomo Set enumerating the + argument's possible values, and `scale` is either None (a plain constant + argument contributes nothing to the position/index arithmetic a caller + may want to build) or the (min, max, step) tuple describing the domain's + regular structure, as returned by Set.get_interval(). + """ + if arg_value.__class__ in EXPR.native_types: + arg_set = Set(initialize=[arg_value]) + arg_set.construct() + return arg_set, None + + node_arg = node.arg(i + 1) + if node_arg.is_expression_type(): + var_list = list(identify_variables(node_arg, include_fixed=False)) + var_domain = [list(_check_var_domain(node, v)) for v in var_list] + arg_vals = set() + for var_vals in itertools.product(*var_domain): + for v, val in zip(var_list, var_vals): + v.set_value(val) + arg_vals.add(node_arg()) + arg_set = Set(initialize=sorted(arg_vals)) + arg_set.construct() + interval = arg_set.get_interval() + if not interval[2]: + raise ValueError( + "Variable indirection '%s' contains argument expression " + "'%s' that does not evaluate to a simple discrete set" + % (node, node_arg) + ) + return arg_set, interval + + # This had better be a simple variable over a regular discrete domain. + # When we add support for categorical variables, we will need to ensure + # that the categoricals have already been converted to simple integer + # domains by this point. + arg_domain = _check_var_domain(node, node_arg) + return arg_domain, arg_domain.get_interval() + + +def before_named_expression(visitor, child): + _id = id(child) + if _id not in visitor._named_expressions: + return True, None + return False, (_GENERAL, visitor._named_expressions[_id]) + + +def handle_named_expression_node(visitor, node, expr): + visitor._named_expressions[id(node)] = expr[1] + return expr + + +class CPExpressionVisitorBase(StreamBasedExpressionVisitor): + """Shared engine for the pyomo.contrib.cp writers' expression walkers. + + A concrete writer's visitor (e.g. LogicalToDoCplex, LogicalToCpSat) + subclasses this and supplies two solver-specific dispatch tables as + class (or instance) attributes: + + - `var_handles`: maps a leaf/non-expression Pyomo component class + (Var, Param, IntervalVar, ...) to a function that creates (or looks + up a memoized) native solver object for it. + - `exit_node_dispatcher`: an ExitNodeDispatcher mapping a Pyomo + expression node class to a function that builds the corresponding + native solver constraint/expression from its already-processed + children. + + and, optionally, `step_function_handles`: a dict of node classes whose + translation needs to bypass the normal recursive walk (because they + require custom, non-uniform handling of their own children) - consulted + directly from `beforeChild` rather than through `exit_node_dispatcher`. + """ + + step_function_handles = {} + + def __init__(self, symbolic_solver_labels=False): + self.symbolic_solver_labels = symbolic_solver_labels + self._process_node = self._process_node_bx + + self.var_map = {} + self._named_expressions = {} + self.pyomo_to_native = ComponentMap() + + def initializeWalker(self, expr): + expr, src, src_idx = expr + walk, result = self.beforeChild(None, expr, 0) + if not walk: + return False, result + return True, expr + + def beforeChild(self, node, child, child_idx): + # Return native types + if child.__class__ in EXPR.native_types: + return False, (_GENERAL, child) + + if child.__class__ in self.step_function_handles: + return self.step_function_handles[child.__class__](self, child) + + # Convert Vars/BooleanVars/etc. to their solver-native equivalents + if not child.is_expression_type() or child.is_named_expression_type(): + return self.var_handles[child.__class__](self, child) + + return True, None + + def exitNode(self, node, data): + return self.exit_node_dispatcher[node.__class__](self, node, *data) + + finalizeResult = None diff --git a/pyomo/contrib/cp/tests/common_tests.py b/pyomo/contrib/cp/tests/common_tests.py new file mode 100644 index 00000000000..8f0d537d9fe --- /dev/null +++ b/pyomo/contrib/cp/tests/common_tests.py @@ -0,0 +1,76 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and Engineering +# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this +# software. This software is distributed under the 3-clause BSD License. +# ____________________________________________________________________________________ +"""Solver-agnostic checks for the models in models.py, parametrized by the +name a CP writer/solver is registered under (e.g. 'cp_optimizer', 'cp_sat'). +Each check function solves the model through the named backend and asserts +the same expected results regardless of which backend was used, so a single +check exercises every backend's writer against a shared, once-written model. +""" + +from pyomo.contrib.cp.tests import models +from pyomo.environ import SolverFactory, TerminationCondition, value + +# For a pure satisfaction (no-objective) model, "solved successfully" isn't +# reported the same way by every backend: CP Optimizer reports `feasible` +# (there being no objective to have proven optimal), while CP-SAT reports +# `optimal` (a feasible solution to a problem with no objective is, +# trivially, optimal). Both mean the same thing here, so checks accept +# either rather than assuming one solver's convention is universal. +_SOLVED = {TerminationCondition.optimal, TerminationCondition.feasible} + + +def check_solve_mice_and_cookies_model(self, solver_name): + m = models.mice_and_cookies_model() + results = SolverFactory(solver_name).solve(m, symbolic_solver_labels=True, tee=True) + + self.assertIn(results.solver.termination_condition, _SOLVED) + + # check solution + self.assertTrue(value(m.eat_cookie[0].is_present)) + self.assertTrue(value(m.eat_cookie[1].is_present)) + # That means there were crumbs: + self.assertEqual(value(m.num_crumbs), 5) + # So there was sweeping: + self.assertTrue(value(m.sweep_crumbs.is_present)) + + # start with the first cookie: + self.assertEqual(value(m.eat_cookie[0].start_time), 0) + self.assertEqual(value(m.eat_cookie[0].end_time), 8) + self.assertEqual(value(m.eat_cookie[0].length), 8) + # Proceed to second cookie: + self.assertEqual(value(m.eat_cookie[1].start_time), 8) + self.assertEqual(value(m.eat_cookie[1].end_time), 16) + self.assertEqual(value(m.eat_cookie[1].length), 8) + # Sweep + self.assertEqual(value(m.sweep_crumbs.start_time), 16) + self.assertEqual(value(m.sweep_crumbs.end_time), 17) + self.assertEqual(value(m.sweep_crumbs.length), 1) + # End with read story, as it keeps exactly one mouse occupied + # indefinitely (in this particular retelling) + self.assertEqual(value(m.read_story.start_time), 17) + + # Since doing the dishes actually *bores* a mouse, we leave the dishes + # in the sink + self.assertFalse(value(m.do_dishes.is_present)) + + self.assertEqual(results.problem.number_of_objectives, 0) + + return results + + +def check_solve_three_step_sequence_model(self, solver_name): + m = models.three_step_sequence_model() + + results = SolverFactory(solver_name).solve(m) + self.assertIn(results.solver.termination_condition, _SOLVED) + self.assertEqual(value(m.i[1].start_time), 0) + self.assertEqual(value(m.i[2].start_time), 2) + self.assertEqual(value(m.i[3].start_time), 6) + + return results diff --git a/pyomo/contrib/cp/tests/models.py b/pyomo/contrib/cp/tests/models.py new file mode 100644 index 00000000000..52d9651405f --- /dev/null +++ b/pyomo/contrib/cp/tests/models.py @@ -0,0 +1,115 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and Engineering +# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this +# software. This software is distributed under the 3-clause BSD License. +# ____________________________________________________________________________________ +"""Model builders shared by the pyomo.contrib.cp writer test suites (one per +solver backend). Keeping these here means a scheduling model that one CP +writer is tested against can be reused, unmodified, to test another, rather +than being retyped in each writer's own test file. + +None of these functions solve or check anything -- see common_tests.py for +the solver-agnostic checks that go with each model. +""" + +from pyomo.contrib.cp import IntervalVar, SequenceVar, Pulse, Step, AlwaysIn +from pyomo.contrib.cp.scheduling_expr.sequence_expressions import ( + first_in_sequence, + predecessor_to, + no_overlap, +) +from pyomo.core.expr.logical_expr import implies +from pyomo.environ import ConcreteModel, Set, Var, Integers, LogicalConstraint + + +def mice_and_cookies_model(): + """A satisfaction (no-objective) scheduling problem: eating cookies makes + crumbs, which (if there are enough of them) require sweeping, and a mouse + must be kept busy at all times by exactly one of these chores (or by + reading a story, indefinitely, once the chores run out) -- otherwise it + will get up to trouble doing the dishes instead. + + Exercises: optional and mandatory IntervalVars, precedence + (start_time.after), implications, and a cumulative resource ("exactly one + mouse occupied") built from Pulse and Step step functions and asserted + with AlwaysIn. + """ + m = ConcreteModel() + m.eat_cookie = IntervalVar([0, 1], length=8, end=(0, 24), optional=False) + m.eat_cookie[0].start_time.bounds = (0, 4) + m.eat_cookie[1].start_time.bounds = (5, 20) + + m.read_story = IntervalVar(start=(15, 24), end=(0, 24), length=(2, 3)) + m.sweep_crumbs = IntervalVar(optional=True, length=1, end=(0, 24)) + m.do_dishes = IntervalVar(optional=True, length=5, end=(0, 24)) + + m.num_crumbs = Var(domain=Integers, bounds=(0, 100)) + + # Precedence + m.cookies = LogicalConstraint( + expr=m.eat_cookie[1].start_time.after(m.eat_cookie[0].end_time) + ) + m.cookies_imply_crumbs = LogicalConstraint( + expr=m.eat_cookie[0].is_present.implies(m.num_crumbs == 5) + ) + m.good_mouse = LogicalConstraint( + expr=implies(m.num_crumbs >= 3, m.sweep_crumbs.is_present) + ) + m.sweep_after = LogicalConstraint( + expr=m.sweep_crumbs.start_time.after(m.eat_cookie[1].end_time) + ) + + m.mice_occupied = ( + sum(Pulse((m.eat_cookie[i], 1)) for i in range(2)) + + Step(m.read_story.start_time, 1) + + Pulse((m.sweep_crumbs, 1)) + - Pulse((m.do_dishes, 1)) + ) + + # Must keep exactly one mouse occupied for a 25-hour day + m.treat_your_mouse_well = LogicalConstraint( + expr=AlwaysIn(cumul_func=m.mice_occupied, bounds=(1, 1), times=(0, 24)) + ) + + return m + + +def three_step_sequence_model(): + """A sequencing problem over three tasks whose lengths increase with + their index: task 1 must be first, and 1->2->3 must be immediate + predecessors of each other, with no overlap allowed. + + Exercises: an indexed IntervalVar, a SequenceVar, first_in_sequence, + predecessor_to, and no_overlap. + """ + m = ConcreteModel() + m.Steps = Set(initialize=[1, 2, 3]) + + def length_rule(m, j): + return 2 * j + + m.i = IntervalVar(m.Steps, start=(0, 12), end=(0, 12), length=length_rule) + m.seq = SequenceVar(expr=[m.i[j] for j in m.Steps]) + m.first = LogicalConstraint(expr=first_in_sequence(m.i[1], m.seq)) + m.seq_order1 = LogicalConstraint(expr=predecessor_to(m.i[1], m.i[2], m.seq)) + m.seq_order2 = LogicalConstraint(expr=predecessor_to(m.i[2], m.i[3], m.seq)) + m.no_overlap = LogicalConstraint(expr=no_overlap(m.seq)) + + return m + + +def three_step_sequence_model_no_overlap_constraint(): + """The same sequencing problem as three_step_sequence_model(), but + without the no_overlap constraint. Since first_in_sequence and + predecessor_to no longer have an accompanying NoOverlap to piggyback + their ordering semantics on, a writer that only knows how to encode + sequencing via temporal (start-time) comparisons cannot handle this + model correctly -- it needs an explicit notion of sequence position + instead. Used to exercise that fallback path specifically. + """ + m = three_step_sequence_model() + del m.no_overlap + return m diff --git a/pyomo/contrib/cp/tests/test_cpsat_writer.py b/pyomo/contrib/cp/tests/test_cpsat_writer.py new file mode 100644 index 00000000000..b4a84f981bf --- /dev/null +++ b/pyomo/contrib/cp/tests/test_cpsat_writer.py @@ -0,0 +1,352 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and Engineering +# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this +# software. This software is distributed under the 3-clause BSD License. +# ____________________________________________________________________________________ + +import pyomo.common.unittest as unittest + +from pyomo.contrib.cp import IntervalVar, SequenceVar, Pulse, Step, AlwaysIn +from pyomo.contrib.cp.scheduling_expr.scheduling_logic import ( + spans, + alternative, + synchronize, +) +from pyomo.contrib.cp.scheduling_expr.sequence_expressions import ( + predecessor_to, + before_in_sequence, + no_overlap, +) +from pyomo.contrib.cp.repn.cpsat_writer import cp_model_available +from pyomo.contrib.cp.tests import common_tests as ct +from pyomo.core.expr.numeric_expr import MinExpression, MaxExpression +from pyomo.core.expr.logical_expr import ( + implies, + land, + exactly, + atleast, + atmost, + all_different, + count_if, +) +from pyomo.environ import ( + ConcreteModel, + Set, + Var, + Integers, + BooleanVar, + LogicalConstraint, + Constraint, + Objective, + maximize, + value, + TerminationCondition, +) +from pyomo.opt import SolverFactory + + +@unittest.skipIf(not cp_model_available, "ortools is not available") +class TestSolveModel(unittest.TestCase): + def test_solve_infeasible_problem(self): + m = ConcreteModel() + m.x = Var(within=[1, 2, 3, 5]) + m.c = Constraint(expr=m.x == 0) + + result = SolverFactory('cp_sat').solve(m) + self.assertEqual( + result.solver.termination_condition, TerminationCondition.infeasible + ) + + def test_solve_max_problem(self): + m = ConcreteModel() + m.cookies = Var(domain=Integers, bounds=(7, 10)) + m.chocolate_chip_equity = Constraint(expr=m.cookies <= 9) + m.obj = Objective(expr=m.cookies, sense=maximize) + + results = SolverFactory('cp_sat').solve(m) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertEqual(value(m.cookies), 9) + self.assertEqual(results.problem.lower_bound, 9) + self.assertEqual(results.problem.upper_bound, 9) + + def test_algebraic_operators(self): + # product, abs, min, max, ranged, truncating division -- all built + # via an auxiliary target var + the matching add_*_equality call, + # since CP-SAT's expression objects don't overload these directly + # the way docplex's do. + m = ConcreteModel() + m.x = Var(bounds=(2, 5), domain=Integers) + m.y = Var(bounds=(3, 4), domain=Integers) + m.p = Var(bounds=(0, 20), domain=Integers) + m.x.fix(3) + m.y.fix(4) + m.prod = Constraint(expr=m.p == m.x * m.y) + + m.a = Var([1, 2, 3], bounds=(0, 10), domain=Integers) + m.a[1].fix(3) + m.a[2].fix(7) + m.a[3].fix(1) + m.mn = Var(bounds=(0, 10), domain=Integers) + m.mx = Var(bounds=(0, 10), domain=Integers) + m.c1 = Constraint(expr=m.mn == MinExpression([m.a[1], m.a[2], m.a[3]])) + m.c2 = Constraint(expr=m.mx == MaxExpression([m.a[1], m.a[2], m.a[3]])) + + m.q = Var(bounds=(0, 20), domain=Integers) + m.qcon = Constraint(expr=m.q == m.p / m.y) # 12 // 4 == 3 + + m.r = Var(bounds=(0, 20), domain=Integers) + m.rcon = Constraint(expr=(5, m.r, 15)) + + results = SolverFactory('cp_sat').solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertEqual(value(m.p), 12) + self.assertEqual(value(m.mn), 1) + self.assertEqual(value(m.mx), 7) + self.assertEqual(value(m.q), 3) + self.assertTrue(5 <= value(m.r) <= 15) + + def test_all_different(self): + m = ConcreteModel() + m.x = Var([1, 2, 3], bounds=(0, 2), domain=Integers) + m.con = LogicalConstraint(expr=all_different(m.x[i] for i in [1, 2, 3])) + + results = SolverFactory('cp_sat').solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertEqual(sorted(value(m.x[i]) for i in [1, 2, 3]), [0, 1, 2]) + + def test_exactly_atleast_atmost(self): + m = ConcreteModel() + m.b = BooleanVar([1, 2, 3, 4]) + m.con = LogicalConstraint(expr=exactly(2, m.b[1], m.b[2], m.b[3], m.b[4])) + SolverFactory('cp_sat').solve(m) + self.assertEqual(sum(1 for i in [1, 2, 3, 4] if value(m.b[i])), 2) + + m2 = ConcreteModel() + m2.b = BooleanVar([1, 2, 3, 4]) + m2.con = LogicalConstraint(expr=atleast(3, m2.b[1], m2.b[2], m2.b[3], m2.b[4])) + SolverFactory('cp_sat').solve(m2) + self.assertGreaterEqual(sum(1 for i in [1, 2, 3, 4] if value(m2.b[i])), 3) + + m3 = ConcreteModel() + m3.b = BooleanVar([1, 2, 3, 4]) + for i in [1, 2, 3]: + m3.b[i].fix(True) + m3.con = LogicalConstraint(expr=atmost(1, m3.b[1], m3.b[2], m3.b[3], m3.b[4])) + results = SolverFactory('cp_sat').solve(m3) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + + def test_count_if(self): + m = ConcreteModel() + m.b = BooleanVar([1, 2, 3]) + m.b[1].fix(True) + m.b[2].fix(True) + m.b[3].fix(False) + m.cnt = Var(domain=Integers, bounds=(0, 3)) + m.con = LogicalConstraint(expr=m.cnt == count_if(m.b[i] for i in [1, 2, 3])) + + SolverFactory('cp_sat').solve(m) + self.assertEqual(value(m.cnt), 2) + + def test_nested_logical_constraint(self): + # Exercises the _AUXILIARY reify/materialize path (a fresh literal + # gets minted and tied to the nested subexpression's truth value), + # not just the cheaper root-level assert path. + m = ConcreteModel() + m.a = BooleanVar() + m.b = BooleanVar() + m.c = BooleanVar() + m.a.fix(True) + m.con = LogicalConstraint(expr=implies(m.a, land(m.b, m.c))) + + SolverFactory('cp_sat').solve(m) + self.assertTrue(value(m.b)) + self.assertTrue(value(m.c)) + + def test_get_item_expression_indirection(self): + m = ConcreteModel() + m.i = IntervalVar( + [1, 2, 3], optional=True, start=(0, 10), end=(0, 10), length=(0, 10) + ) + m.x = Var(within={1, 2, 3}) + m.cons = LogicalConstraint(expr=m.i[m.x].is_present) + + results = SolverFactory('cp_sat').solve(m) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertTrue(value(m.i[int(value(m.x))].is_present)) + + def test_span_expression(self): + m = ConcreteModel() + m.a = IntervalVar(start=(0, 10), end=(0, 10), length=(1, 10)) + m.b = IntervalVar(start=(0, 10), end=(0, 10), length=2) + m.c = IntervalVar(start=(0, 10), end=(0, 10), length=3) + m.b.start_time.fix(2) + m.c.start_time.fix(5) + m.con = LogicalConstraint(expr=spans(m.a, m.b, m.c)) + + SolverFactory('cp_sat').solve(m) + self.assertEqual(value(m.a.start_time), 2) + self.assertEqual(value(m.a.end_time), 8) + + def test_alternative_expression(self): + m = ConcreteModel() + m.container = IntervalVar(start=(0, 10), end=(0, 10), length=(1, 10)) + m.opt1 = IntervalVar(optional=True, start=(0, 10), end=(0, 10), length=3) + m.opt2 = IntervalVar(optional=True, start=(0, 10), end=(0, 10), length=5) + m.opt1.start_time.fix(2) + m.opt2.start_time.fix(4) + m.con = LogicalConstraint(expr=alternative(m.container, m.opt1, m.opt2)) + + SolverFactory('cp_sat').solve(m) + self.assertTrue(value(m.container.is_present)) + self.assertEqual(value(m.opt1.is_present) + value(m.opt2.is_present), 1) + if value(m.opt1.is_present): + self.assertEqual(value(m.container.start_time), 2) + self.assertEqual(value(m.container.end_time), 5) + else: + self.assertEqual(value(m.container.start_time), 4) + self.assertEqual(value(m.container.end_time), 9) + + def test_synchronize_expression(self): + m = ConcreteModel() + m.container = IntervalVar(start=(0, 10), end=(0, 10), length=5) + m.container.start_time.fix(3) + m.follower = IntervalVar( + optional=True, start=(0, 10), end=(0, 10), length=(1, 10) + ) + m.con = LogicalConstraint(expr=synchronize(m.container, m.follower)) + + SolverFactory('cp_sat').solve(m) + self.assertTrue(value(m.follower.is_present)) + self.assertEqual(value(m.follower.start_time), 3) + self.assertEqual(value(m.follower.end_time), 8) + + def test_scheduling_with_sequence_vars(self): + # Exercises Branch 1 (start-time comparisons): first_in_sequence and + # two predecessor_to constraints, with an accompanying no_overlap -- + # the same model docplex's writer is tested against. + ct.check_solve_three_step_sequence_model(self, 'cp_sat') + + def test_sequencing_without_no_overlap(self): + # No docplex-suite analog: exercises Branch 2 (explicit rank/ + # position variables), which is only needed when no NoOverlap over + # the same SequenceVar guarantees a temporal ordering to piggyback + # on. All three intervals are free to overlap in time here; only + # their *sequence position* is constrained. + from pyomo.contrib.cp.tests.models import ( + three_step_sequence_model_no_overlap_constraint, + ) + + m = three_step_sequence_model_no_overlap_constraint() + results = SolverFactory('cp_sat').solve(m) + self.assertIn( + results.solver.termination_condition, + (TerminationCondition.optimal, TerminationCondition.feasible), + ) + + def test_predecessor_to_forbids_interloper(self): + # The trickiest part of Branch 1's encoding: predecessor_to means + # *direct* adjacency, so nothing else present may be scheduled + # strictly between the two intervals -- unlike before_in_sequence, + # which only requires "somewhere earlier," not "immediately before." + def build(use_predecessor): + m = ConcreteModel() + m.a = IntervalVar(start=(0, 0), end=(2, 2), length=2) + m.c = IntervalVar(start=(5, 5), end=(7, 7), length=2) + # b's bounds force it into the gap between a and c. + m.b = IntervalVar(start=(3, 3), end=(4, 4), length=1) + m.seq = SequenceVar(expr=[m.a, m.b, m.c]) + if use_predecessor: + m.pred = LogicalConstraint(expr=predecessor_to(m.a, m.c, m.seq)) + else: + m.pred = LogicalConstraint(expr=before_in_sequence(m.a, m.c, m.seq)) + m.no_ovl = LogicalConstraint(expr=no_overlap(m.seq)) + return m + + m1 = build(use_predecessor=True) + results1 = SolverFactory('cp_sat').solve(m1) + self.assertEqual( + results1.solver.termination_condition, TerminationCondition.infeasible + ) + + m2 = build(use_predecessor=False) + results2 = SolverFactory('cp_sat').solve(m2) + self.assertIn( + results2.solver.termination_condition, + (TerminationCondition.optimal, TerminationCondition.feasible), + ) + + def test_pulse_cumulative_fast_path(self): + m = ConcreteModel() + m.tasks = Set(initialize=[0, 1, 2]) + m.t = IntervalVar(m.tasks, start=(0, 10), end=(0, 10), length=3) + m.usage = sum(Pulse((m.t[i], 1)) for i in m.tasks) + m.cap = LogicalConstraint( + expr=AlwaysIn(cumul_func=m.usage, bounds=(0, 2), times=(0, 10)) + ) + + results = SolverFactory('cp_sat').solve(m) + self.assertIn( + results.solver.termination_condition, + (TerminationCondition.optimal, TerminationCondition.feasible), + ) + starts = [value(m.t[i].start_time) for i in m.tasks] + for t in range(10): + n_active = sum(1 for i in m.tasks if starts[i] <= t < starts[i] + 3) + self.assertLessEqual(n_active, 2) + + def test_step_only_reservoir_special_case(self): + m = ConcreteModel() + m.produce = IntervalVar(start=(0, 10), end=(0, 10), length=1) + m.consume = IntervalVar(start=(0, 10), end=(0, 10), length=1) + m.level = Step(m.produce.start_time, 5) - Step(m.consume.start_time, 5) + m.cap = LogicalConstraint( + expr=AlwaysIn(cumul_func=m.level, bounds=(0, 5), times=(0, 10)) + ) + m.order = LogicalConstraint( + expr=m.produce.start_time.before(m.consume.start_time) + ) + + results = SolverFactory('cp_sat').solve(m) + self.assertIn( + results.solver.termination_condition, + (TerminationCondition.optimal, TerminationCondition.feasible), + ) + self.assertLessEqual(value(m.produce.start_time), value(m.consume.start_time)) + + def test_always_in_unsupported_general_case_raises_not_implemented(self): + # A CumulativeFunction mixing Pulse and Step terms is outside the two + # special cases this writer supports (see the "Scheduling: step + # functions" section of cpsat_writer.py) -- confirmed to raise a + # clear, actionable error rather than silently mistranslating it. + m = ConcreteModel() + m.a = IntervalVar(start=(0, 10), end=(0, 10), length=2) + m.mixed = Pulse((m.a, 1)) + Step(m.a.start_time, 1) + m.con = LogicalConstraint( + expr=AlwaysIn(cumul_func=m.mixed, bounds=(0, 5), times=(0, 10)) + ) + + with self.assertRaises(NotImplementedError): + SolverFactory('cp_sat').solve(m) + + def test_unbounded_interval_var_raises_clear_error(self): + # Unlike CP Optimizer, CP-SAT has no notion of an unbounded horizon: + # every IntervalVar's start/end/length needs finite bounds. + m = ConcreteModel() + m.i = IntervalVar(length=1, end=(0, 24)) # no start= given -> unbounded + + with self.assertRaises(ValueError): + SolverFactory('cp_sat').solve(m) diff --git a/pyomo/contrib/cp/tests/test_docplex_walker.py b/pyomo/contrib/cp/tests/test_docplex_walker.py index 93513a5d08a..1729bc0b3cf 100644 --- a/pyomo/contrib/cp/tests/test_docplex_walker.py +++ b/pyomo/contrib/cp/tests/test_docplex_walker.py @@ -98,9 +98,9 @@ def test_write_addition(self): expr[1].equals(cpx_x + cp.start_of(cpx_i) + cp.length_of(cpx_i2)) ) - self.assertIs(visitor.pyomo_to_docplex[m.x], cpx_x) - self.assertIs(visitor.pyomo_to_docplex[m.i], cpx_i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], cpx_i2) + self.assertIs(visitor.pyomo_to_native[m.x], cpx_x) + self.assertIs(visitor.pyomo_to_native[m.i], cpx_i) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], cpx_i2) def test_write_subtraction(self): m = self.get_model() @@ -117,8 +117,8 @@ def test_write_subtraction(self): self.assertTrue(expr[1].equals(x + (-1 * a1))) - self.assertIs(visitor.pyomo_to_docplex[m.x], x) - self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + self.assertIs(visitor.pyomo_to_native[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.a[1]], a1) def test_write_product(self): m = self.get_model() @@ -135,8 +135,8 @@ def test_write_product(self): self.assertTrue(expr[1].equals(x * (a1 + 1))) - self.assertIs(visitor.pyomo_to_docplex[m.x], x) - self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + self.assertIs(visitor.pyomo_to_native[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.a[1]], a1) def test_write_floating_point_division(self): m = self.get_model() @@ -153,8 +153,8 @@ def test_write_floating_point_division(self): self.assertTrue(expr[1].equals(x / (a1 + 1))) - self.assertIs(visitor.pyomo_to_docplex[m.x], x) - self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + self.assertIs(visitor.pyomo_to_native[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.a[1]], a1) def test_write_power_expression(self): m = self.get_model() @@ -167,7 +167,7 @@ def test_write_power_expression(self): # .equals checks the equality of two expressions in docplex. self.assertTrue(expr[1].equals(cpx_x**2)) - self.assertIs(visitor.pyomo_to_docplex[m.x], cpx_x) + self.assertIs(visitor.pyomo_to_native[m.x], cpx_x) def test_write_absolute_value_expression(self): m = self.get_model() @@ -182,7 +182,7 @@ def test_write_absolute_value_expression(self): self.assertTrue(expr[1].equals(cp.abs(a1) + 1)) - self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) + self.assertIs(visitor.pyomo_to_native[m.a[1]], a1) def test_write_min_expression(self): m = self.get_model() @@ -195,7 +195,7 @@ def test_write_min_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] - self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) + self.assertIs(visitor.pyomo_to_native[m.a[i]], a[i]) self.assertTrue(expr[1].equals(cp.min(a[i] for i in m.I))) @@ -210,7 +210,7 @@ def test_write_max_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] - self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) + self.assertIs(visitor.pyomo_to_native[m.a[i]], a[i]) self.assertTrue(expr[1].equals(cp.max(a[i] for i in m.I))) @@ -279,8 +279,8 @@ def test_write_logical_and(self): # map by checking that we can build an expression that is the same as b # (because b is actually "b == 1" since docplex doesn't believe in # Booleans) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) - self.assertTrue(b2b.equals(visitor.pyomo_to_docplex[m.b2['b']] == 1)) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) + self.assertTrue(b2b.equals(visitor.pyomo_to_native[m.b2['b']] == 1)) def test_write_logical_or(self): m = self.get_model() @@ -295,8 +295,8 @@ def test_write_logical_or(self): self.assertTrue(expr[1].equals(cp.logical_or(b, cp.presence_of(i)))) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.i], i) def test_write_xor(self): m = self.get_model() @@ -315,8 +315,8 @@ def test_write_xor(self): expr[1].equals(cp.count([b, cp.less_or_equal(5, cp.start_of(i22))], 1) == 1) ) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) def test_write_logical_not(self): m = self.get_model() @@ -329,7 +329,7 @@ def test_write_logical_not(self): self.assertTrue(expr[1].equals(cp.logical_not(b2a))) - self.assertTrue(b2a.equals(visitor.pyomo_to_docplex[m.b2['a']] == 1)) + self.assertTrue(b2a.equals(visitor.pyomo_to_native[m.b2['a']] == 1)) def test_equivalence(self): m = self.get_model() @@ -344,8 +344,8 @@ def test_equivalence(self): self.assertTrue(expr[1].equals(cp.equal(cp.logical_not(b2a), b))) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) - self.assertTrue(b2a.equals(visitor.pyomo_to_docplex[m.b2['a']] == 1)) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) + self.assertTrue(b2a.equals(visitor.pyomo_to_native[m.b2['a']] == 1)) def test_equality(self): m = self.get_model() @@ -362,8 +362,8 @@ def test_equality(self): self.assertTrue(expr[1].equals(cp.if_then(b, cp.equal(a3, 4)))) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) - self.assertIs(visitor.pyomo_to_docplex[m.a[3]], a3) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.a[3]], a3) def test_inequality(self): m = self.get_model() @@ -382,9 +382,9 @@ def test_inequality(self): self.assertTrue(expr[1].equals(cp.if_then(b, cp.less_or_equal(a4, a3)))) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) - self.assertIs(visitor.pyomo_to_docplex[m.a[3]], a3) - self.assertIs(visitor.pyomo_to_docplex[m.a[4]], a4) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.a[3]], a3) + self.assertIs(visitor.pyomo_to_native[m.a[4]], a4) def test_ranged_inequality(self): m = self.get_model() @@ -416,9 +416,9 @@ def test_not_equal(self): self.assertTrue(expr[1].equals(cp.if_then(b, a3 != a4))) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) - self.assertIs(visitor.pyomo_to_docplex[m.a[3]], a3) - self.assertIs(visitor.pyomo_to_docplex[m.a[4]], a4) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.a[3]], a3) + self.assertIs(visitor.pyomo_to_native[m.a[4]], a4) def test_exactly_expression(self): m = self.get_model() @@ -432,7 +432,7 @@ def test_exactly_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] - self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) + self.assertIs(visitor.pyomo_to_native[m.a[i]], a[i]) self.assertTrue( expr[1].equals(cp.equal(cp.count([a[i] == 4 for i in m.I], 1), 3)) @@ -450,7 +450,7 @@ def test_atleast_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] - self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) + self.assertIs(visitor.pyomo_to_native[m.a[i]], a[i]) self.assertTrue( expr[1].equals( @@ -470,7 +470,7 @@ def test_atmost_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] - self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) + self.assertIs(visitor.pyomo_to_native[m.a[i]], a[i]) self.assertTrue( expr[1].equals(cp.less_or_equal(cp.count([a[i] == 4 for i in m.I], 1), 3)) @@ -489,7 +489,7 @@ def test_all_diff_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] - self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) + self.assertIs(visitor.pyomo_to_native[m.a[i]], a[i]) self.assertTrue(expr[1].equals(cp.all_diff(a[i] for i in m.I))) @@ -506,7 +506,7 @@ def test_count_if_expression(self): for i in m.I: self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] - self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) + self.assertIs(visitor.pyomo_to_native[m.a[i]], a[i]) self.assertTrue(expr[1].equals(cp.count((a[i] == i for i in m.I), 1) == 5)) @@ -525,8 +525,8 @@ def test_interval_var_is_present(self): self.assertTrue(expr[1].equals(cp.if_then(cp.presence_of(i), a1 == 5))) - self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.a[1]], a1) + self.assertIs(visitor.pyomo_to_native[m.i], i) def test_interval_var_is_present_indirection(self): m = self.get_model() @@ -561,10 +561,10 @@ def test_interval_var_is_present_indirection(self): ) ) - self.assertIs(visitor.pyomo_to_docplex[m.a[1]], a1) - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.a[1]], a1) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) def test_is_present_indirection_and_length(self): m = self.get_model() @@ -600,9 +600,9 @@ def test_is_present_indirection_and_length(self): ) ) - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) def test_handle_getattr_lor(self): m = self.get_model() @@ -635,10 +635,10 @@ def test_handle_getattr_lor(self): ) ) - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) def test_handle_getattr_xor(self): m = self.get_model() @@ -678,10 +678,10 @@ def test_handle_getattr_xor(self): ) ) - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) def test_handle_getattr_equivalent_to(self): m = self.get_model() @@ -714,10 +714,10 @@ def test_handle_getattr_equivalent_to(self): ) ) - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) def test_logical_or_on_indirection(self): m = ConcreteModel() @@ -748,10 +748,10 @@ def test_logical_or_on_indirection(self): ) ) - self.assertIs(visitor.pyomo_to_docplex[m.x], x) - self.assertTrue(b3.equals(visitor.pyomo_to_docplex[m.b[3]] == 1)) - self.assertTrue(b4.equals(visitor.pyomo_to_docplex[m.b[4]] == 1)) - self.assertTrue(b5.equals(visitor.pyomo_to_docplex[m.b[5]] == 1)) + self.assertIs(visitor.pyomo_to_native[m.x], x) + self.assertTrue(b3.equals(visitor.pyomo_to_native[m.b[3]] == 1)) + self.assertTrue(b4.equals(visitor.pyomo_to_native[m.b[4]] == 1)) + self.assertTrue(b5.equals(visitor.pyomo_to_native[m.b[5]] == 1)) def test_logical_xor_on_indirection(self): m = ConcreteModel() @@ -787,9 +787,9 @@ def test_logical_xor_on_indirection(self): ) ) - self.assertIs(visitor.pyomo_to_docplex[m.x], x) - self.assertTrue(b3.equals(visitor.pyomo_to_docplex[m.b[3]] == 1)) - self.assertTrue(b5.equals(visitor.pyomo_to_docplex[m.b[5]] == 1)) + self.assertIs(visitor.pyomo_to_native[m.x], x) + self.assertTrue(b3.equals(visitor.pyomo_to_native[m.b[3]] == 1)) + self.assertTrue(b5.equals(visitor.pyomo_to_native[m.b[5]] == 1)) def test_using_precedence_expr_as_boolean_expr(self): m = self.get_model() @@ -810,9 +810,9 @@ def test_using_precedence_expr_as_boolean_expr(self): expr[1].equals(cp.if_then(b, cp.start_of(i22) + 0 <= cp.start_of(i21))) ) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) def test_using_precedence_expr_as_boolean_expr_positive_delay(self): m = self.get_model() @@ -833,9 +833,9 @@ def test_using_precedence_expr_as_boolean_expr_positive_delay(self): expr[1].equals(cp.if_then(b, cp.start_of(i22) + 4 <= cp.start_of(i21))) ) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) def test_using_precedence_expr_as_boolean_expr_negative_delay(self): m = self.get_model() @@ -856,9 +856,9 @@ def test_using_precedence_expr_as_boolean_expr_negative_delay(self): expr[1].equals(cp.if_then(b, cp.start_of(i22) + (-3) == cp.start_of(i21))) ) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertTrue(b.equals(visitor.pyomo_to_docplex[m.b] == 1)) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertTrue(b.equals(visitor.pyomo_to_native[m.b] == 1)) @unittest.skipIf(not docplex_available, "docplex is not available") @@ -873,7 +873,7 @@ def test_interval_var_fixed_presences_correct(self): i = visitor.var_map[id(m.i)] # Check that docplex knows it's optional self.assertTrue(i.is_optional()) - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i], i) # Now fix it to absent m.i.is_present.fix(False) @@ -884,10 +884,10 @@ def test_interval_var_fixed_presences_correct(self): self.assertIn(id(m.i2[1]), visitor.var_map) i21 = visitor.var_map[id(m.i2[1])] - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) self.assertIn(id(m.i), visitor.var_map) i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i], i) # Check that we passed on the presence info to docplex self.assertTrue(i.is_absent()) @@ -906,7 +906,7 @@ def test_interval_var_fixed_length(self): self.assertIn(id(m.i), visitor.var_map) i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i], i) self.assertTrue(i.is_optional()) self.assertEqual(i.get_length(), (4, 4)) @@ -924,7 +924,7 @@ def test_interval_var_fixed_start_and_end(self): self.assertIn(id(m.i), visitor.var_map) i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i], i) self.assertFalse(i.is_optional()) self.assertEqual(i.get_start(), (3, 3)) @@ -942,14 +942,14 @@ def get_model(self): def check_scalar_sequence_var(self, m, visitor): self.assertIn(id(m.seq), visitor.var_map) seq = visitor.var_map[id(m.seq)] - self.assertIs(visitor.pyomo_to_docplex[m.seq], seq) + self.assertIs(visitor.pyomo_to_native[m.seq], seq) i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) ivs = seq.get_interval_variables() self.assertEqual(len(ivs), 3) @@ -1016,8 +1016,8 @@ def test_start_before_start(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.start_before_start(i, i21, 0))) @@ -1032,8 +1032,8 @@ def test_start_before_end(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.start_before_end(i, i21, 3))) @@ -1048,8 +1048,8 @@ def test_end_before_start(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.end_before_start(i, i21, -2))) @@ -1064,8 +1064,8 @@ def test_end_before_end(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.end_before_end(i, i21, 6))) @@ -1080,8 +1080,8 @@ def test_start_at_start(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.start_at_start(i, i21, 0))) @@ -1096,8 +1096,8 @@ def test_start_at_end(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.start_at_end(i, i21, 3))) @@ -1112,8 +1112,8 @@ def test_end_at_start(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.end_at_start(i, i21, -2))) @@ -1128,8 +1128,8 @@ def test_end_at_end(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) self.assertTrue(expr[1].equals(cp.end_at_end(i, i21, 6))) @@ -1154,10 +1154,10 @@ def test_indirection_before_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i], i) self.assertTrue( expr[1].equals( @@ -1184,10 +1184,10 @@ def test_indirection_after_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i], i) self.assertTrue( expr[1].equals( @@ -1215,10 +1215,10 @@ def test_indirection_at_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i], i) self.assertTrue( expr[1].equals( @@ -1246,10 +1246,10 @@ def test_before_indirection_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i], i) self.assertTrue( expr[1].equals( @@ -1275,10 +1275,10 @@ def test_after_indirection_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i], i) self.assertTrue( expr[1].equals( @@ -1304,10 +1304,10 @@ def test_at_indirection_constraint(self): i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i], i) self.assertTrue( expr[1].equals( @@ -1342,13 +1342,13 @@ def test_double_indirection_before_constraint(self): i33 = visitor.var_map[id(m.i3[1, 3])] i34 = visitor.var_map[id(m.i3[1, 4])] i35 = visitor.var_map[id(m.i3[1, 5])] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.x], x) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 3]], i33) - self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 4]], i34) - self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 5]], i35) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i3[1, 3]], i33) + self.assertIs(visitor.pyomo_to_native[m.i3[1, 4]], i34) + self.assertIs(visitor.pyomo_to_native[m.i3[1, 5]], i35) self.assertTrue( expr[1].equals( @@ -1386,13 +1386,13 @@ def test_double_indirection_after_constraint(self): i33 = visitor.var_map[id(m.i3[1, 3])] i34 = visitor.var_map[id(m.i3[1, 4])] i35 = visitor.var_map[id(m.i3[1, 5])] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.x], x) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 3]], i33) - self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 4]], i34) - self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 5]], i35) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i3[1, 3]], i33) + self.assertIs(visitor.pyomo_to_native[m.i3[1, 4]], i34) + self.assertIs(visitor.pyomo_to_native[m.i3[1, 5]], i35) self.assertTrue( expr[1].equals( @@ -1428,13 +1428,13 @@ def test_double_indirection_at_constraint(self): i33 = visitor.var_map[id(m.i3[1, 3])] i34 = visitor.var_map[id(m.i3[1, 4])] i35 = visitor.var_map[id(m.i3[1, 5])] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) - self.assertIs(visitor.pyomo_to_docplex[m.x], x) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) - self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 3]], i33) - self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 4]], i34) - self.assertIs(visitor.pyomo_to_docplex[m.i3[1, 5]], i35) + self.assertIs(visitor.pyomo_to_native[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i3[1, 3]], i33) + self.assertIs(visitor.pyomo_to_native[m.i3[1, 4]], i34) + self.assertIs(visitor.pyomo_to_native[m.i3[1, 5]], i35) self.assertTrue( expr[1].equals( @@ -1482,8 +1482,8 @@ def param_rule(m, i): self.assertIn(id(m.a), visitor.var_map) x = visitor.var_map[id(m.x)] a = visitor.var_map[id(m.a)] - self.assertIs(visitor.pyomo_to_docplex[m.x], x) - self.assertIs(visitor.pyomo_to_docplex[m.a], a) + self.assertIs(visitor.pyomo_to_native[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.a], a) self.assertTrue(expr[1].equals(cp.element([2, 4, 6], 0 + 1 * (x - 1) // 2) / a)) @@ -1515,7 +1515,7 @@ def test_spans(self): self.assertIn(id(m.whole_enchilada), visitor.var_map) whole_enchilada = visitor.var_map[id(m.whole_enchilada)] - self.assertIs(visitor.pyomo_to_docplex[m.whole_enchilada], whole_enchilada) + self.assertIs(visitor.pyomo_to_native[m.whole_enchilada], whole_enchilada) iv = {} for i in [1, 2, 3]: @@ -1535,7 +1535,7 @@ def test_alternative(self): self.assertIn(id(m.whole_enchilada), visitor.var_map) whole_enchilada = visitor.var_map[id(m.whole_enchilada)] - self.assertIs(visitor.pyomo_to_docplex[m.whole_enchilada], whole_enchilada) + self.assertIs(visitor.pyomo_to_native[m.whole_enchilada], whole_enchilada) iv = {} for i in [1, 2, 3]: @@ -1555,7 +1555,7 @@ def test_synchronize(self): self.assertIn(id(m.whole_enchilada), visitor.var_map) whole_enchilada = visitor.var_map[id(m.whole_enchilada)] - self.assertIs(visitor.pyomo_to_docplex[m.whole_enchilada], whole_enchilada) + self.assertIs(visitor.pyomo_to_native[m.whole_enchilada], whole_enchilada) iv = {} for i in [1, 2, 3]: @@ -1588,9 +1588,9 @@ def test_always_in(self): i = visitor.var_map[id(m.i)] i21 = visitor.var_map[id(m.i2[1])] i22 = visitor.var_map[id(m.i2[2])] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) - self.assertIs(visitor.pyomo_to_docplex[m.i2[1]], i21) - self.assertIs(visitor.pyomo_to_docplex[m.i2[2]], i22) + self.assertIs(visitor.pyomo_to_native[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i2[1]], i21) + self.assertIs(visitor.pyomo_to_native[m.i2[2]], i22) self.assertTrue( expr[1].equals( @@ -1618,7 +1618,7 @@ def test_always_in_single_pulse(self): self.assertIn(id(m.i), visitor.var_map) i = visitor.var_map[id(m.i)] - self.assertIs(visitor.pyomo_to_docplex[m.i], i) + self.assertIs(visitor.pyomo_to_native[m.i], i) self.assertTrue( expr[1].equals(cp.always_in(cp.pulse(i, 3), interval=(0, 10), min=0, max=3)) @@ -1637,7 +1637,7 @@ def test_named_expression(self): self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] - self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.x], x) self.assertTrue(expr[1].equals(x**2 + 7)) @@ -1651,7 +1651,7 @@ def test_repeated_named_expression(self): self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] - self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.x], x) self.assertTrue(expr[1].equals(x**2 + 7 + (-1) * (8 * (x**2 + 7)))) @@ -1682,7 +1682,7 @@ def test_fixed_integer_var(self): self.assertIn(id(m.a[2]), visitor.var_map) a2 = visitor.var_map[id(m.a[2])] - self.assertIs(visitor.pyomo_to_docplex[m.a[2]], a2) + self.assertIs(visitor.pyomo_to_native[m.a[2]], a2) self.assertTrue(expr[1].equals(3 + a2)) @@ -1697,7 +1697,7 @@ def test_fixed_boolean_var(self): self.assertIn(id(m.b2['b']), visitor.var_map) b2b = visitor.var_map[id(m.b2['b'])] - self.assertTrue(b2b.equals(visitor.pyomo_to_docplex[m.b2['b']] == 1)) + self.assertTrue(b2b.equals(visitor.pyomo_to_native[m.b2['b']] == 1)) self.assertTrue(expr[1].equals(cp.logical_or(False, cp.logical_and(True, b2b)))) @@ -1711,7 +1711,7 @@ def test_indirection_single_index(self): self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] - self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.x], x) a = [] # only need indices 6, 7, and 8 from a, since that's what x is capable # of selecting. @@ -1719,7 +1719,7 @@ def test_indirection_single_index(self): v = m.a[idx] self.assertIn(id(v), visitor.var_map) cpx_v = visitor.var_map[id(v)] - self.assertIs(visitor.pyomo_to_docplex[v], cpx_v) + self.assertIs(visitor.pyomo_to_native[v], cpx_v) a.append(cpx_v) # since x is between 6 and 8, we subtract 6 from it for it to be the # right index @@ -1738,10 +1738,10 @@ def test_indirection_multi_index_second_constant(self): for i in [6, 7, 8]: self.assertIn(id(m.z[i, 3]), visitor.var_map) z[i, 3] = visitor.var_map[id(m.z[i, 3])] - self.assertIs(visitor.pyomo_to_docplex[m.z[i, 3]], z[i, 3]) + self.assertIs(visitor.pyomo_to_native[m.z[i, 3]], z[i, 3]) self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] - self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.x], x) self.assertTrue( expr[1].equals( @@ -1762,11 +1762,11 @@ def test_indirection_multi_index_first_constant(self): for i in [6, 7, 8]: self.assertIn(id(m.z[3, i]), visitor.var_map) z[3, i] = visitor.var_map[id(m.z[3, i])] - self.assertIs(visitor.pyomo_to_docplex[m.z[3, i]], z[3, i]) + self.assertIs(visitor.pyomo_to_native[m.z[3, i]], z[3, i]) self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] - self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.x], x) self.assertTrue( expr[1].equals( @@ -1788,11 +1788,11 @@ def test_indirection_multi_index_neither_constant_same_var(self): for j in [6, 7, 8]: self.assertIn(id(m.z[i, j]), visitor.var_map) z[i, j] = visitor.var_map[id(m.z[i, j])] - self.assertIs(visitor.pyomo_to_docplex[m.z[i, j]], z[i, j]) + self.assertIs(visitor.pyomo_to_native[m.z[i, j]], z[i, j]) self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] - self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.x], x) self.assertTrue( expr[1].equals( @@ -1818,15 +1818,15 @@ def test_indirection_multi_index_neither_constant_diff_vars(self): for j in [1, 3, 5]: self.assertIn(id(m.z[i, j]), visitor.var_map) z[i, j] = visitor.var_map[id(m.z[i, j])] - self.assertIs(visitor.pyomo_to_docplex[m.z[i, j]], z[i, j]) + self.assertIs(visitor.pyomo_to_native[m.z[i, j]], z[i, j]) self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] - self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.x], x) self.assertIn(id(m.y), visitor.var_map) y = visitor.var_map[id(m.y)] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.y], y) self.assertTrue( expr[1].equals( @@ -1851,14 +1851,14 @@ def test_indirection_expression_index(self): for i in range(1, 8): self.assertIn(id(m.a[i]), visitor.var_map) a[i] = visitor.var_map[id(m.a[i])] - self.assertIs(visitor.pyomo_to_docplex[m.a[i]], a[i]) + self.assertIs(visitor.pyomo_to_native[m.a[i]], a[i]) self.assertIn(id(m.x), visitor.var_map) x = visitor.var_map[id(m.x)] - self.assertIs(visitor.pyomo_to_docplex[m.x], x) + self.assertIs(visitor.pyomo_to_native[m.x], x) self.assertIn(id(m.y), visitor.var_map) y = visitor.var_map[id(m.y)] - self.assertIs(visitor.pyomo_to_docplex[m.y], y) + self.assertIs(visitor.pyomo_to_native[m.y], y) self.assertTrue( expr[1].equals( diff --git a/pyomo/contrib/cp/tests/test_docplex_writer.py b/pyomo/contrib/cp/tests/test_docplex_writer.py index 08bd487bf17..319bb22d9a3 100644 --- a/pyomo/contrib/cp/tests/test_docplex_writer.py +++ b/pyomo/contrib/cp/tests/test_docplex_writer.py @@ -10,17 +10,9 @@ import pyomo.common.unittest as unittest from pyomo.common.fileutils import Executable -from pyomo.contrib.cp import ( - IntervalVar, - SequenceVar, - Pulse, - Step, - AlwaysIn, - first_in_sequence, - predecessor_to, - no_overlap, -) +from pyomo.contrib.cp import IntervalVar from pyomo.contrib.cp.repn.docplex_writer import LogicalToDoCplex +from pyomo.contrib.cp.tests import common_tests as ct from pyomo.environ import ( all_different, count_if, @@ -30,7 +22,6 @@ Integers, Param, LogicalConstraint, - implies, value, TerminationCondition, Constraint, @@ -130,80 +121,12 @@ def test_write_model_with_bool_expr_as_constraint(self): @unittest.skipIf(not cpoptimizer_available, "CP optimizer is not available") class TestSolveModel(unittest.TestCase): def test_solve_scheduling_problem(self): - m = ConcreteModel() - m.eat_cookie = IntervalVar([0, 1], length=8, end=(0, 24), optional=False) - m.eat_cookie[0].start_time.bounds = (0, 4) - m.eat_cookie[1].start_time.bounds = (5, 20) - - m.read_story = IntervalVar(start=(15, 24), end=(0, 24), length=(2, 3)) - m.sweep_crumbs = IntervalVar(optional=True, length=1, end=(0, 24)) - m.do_dishes = IntervalVar(optional=True, length=5, end=(0, 24)) - - m.num_crumbs = Var(domain=Integers, bounds=(0, 100)) - - ## Precedence - m.cookies = LogicalConstraint( - expr=m.eat_cookie[1].start_time.after(m.eat_cookie[0].end_time) - ) - m.cookies_imply_crumbs = LogicalConstraint( - expr=m.eat_cookie[0].is_present.implies(m.num_crumbs == 5) - ) - m.good_mouse = LogicalConstraint( - expr=implies(m.num_crumbs >= 3, m.sweep_crumbs.is_present) - ) - m.sweep_after = LogicalConstraint( - expr=m.sweep_crumbs.start_time.after(m.eat_cookie[1].end_time) - ) - - m.mice_occupied = ( - sum(Pulse((m.eat_cookie[i], 1)) for i in range(2)) - + Step(m.read_story.start_time, 1) - + Pulse((m.sweep_crumbs, 1)) - - Pulse((m.do_dishes, 1)) - ) - - # Must keep exactly one mouse occupied for a 25-hour day - m.treat_your_mouse_well = LogicalConstraint( - expr=AlwaysIn(cumul_func=m.mice_occupied, bounds=(1, 1), times=(0, 24)) - ) + results = ct.check_solve_mice_and_cookies_model(self, 'cp_optimizer') - results = SolverFactory('cp_optimizer').solve( - m, symbolic_solver_labels=True, tee=True - ) - - self.assertEqual( - results.solver.termination_condition, TerminationCondition.feasible - ) - - # check solution - self.assertTrue(value(m.eat_cookie[0].is_present)) - self.assertTrue(value(m.eat_cookie[1].is_present)) - # That means there were crumbs: - self.assertEqual(value(m.num_crumbs), 5) - # So there was sweeping: - self.assertTrue(value(m.sweep_crumbs.is_present)) - - # start with the first cookie: - self.assertEqual(value(m.eat_cookie[0].start_time), 0) - self.assertEqual(value(m.eat_cookie[0].end_time), 8) - self.assertEqual(value(m.eat_cookie[0].length), 8) - # Proceed to second cookie: - self.assertEqual(value(m.eat_cookie[1].start_time), 8) - self.assertEqual(value(m.eat_cookie[1].end_time), 16) - self.assertEqual(value(m.eat_cookie[1].length), 8) - # Sweep - self.assertEqual(value(m.sweep_crumbs.start_time), 16) - self.assertEqual(value(m.sweep_crumbs.end_time), 17) - self.assertEqual(value(m.sweep_crumbs.length), 1) - # End with read story, as it keeps exactly one mouse occupied - # indefinitely (in this particular retelling) - self.assertEqual(value(m.read_story.start_time), 17) - - # Since doing the dishes actually *bores* a mouse, we leave the dishes - # in the sink - self.assertFalse(value(m.do_dishes.is_present)) - - self.assertEqual(results.problem.number_of_objectives, 0) + # docplex-specific problem-size stats (these aren't something we'd + # expect to match across backends, since different writers encode + # the same model with different numbers of native variables/ + # constraints) self.assertEqual(results.problem.number_of_constraints, 5) self.assertEqual(results.problem.number_of_integer_vars, 1) self.assertEqual(results.problem.number_of_interval_vars, 5) @@ -400,23 +323,4 @@ def test_matching_problem(self): self.assertEqual(value(m.obj), perfect) def test_scheduling_with_sequence_vars(self): - m = ConcreteModel() - m.Steps = Set(initialize=[1, 2, 3]) - - def length_rule(m, j): - return 2 * j - - m.i = IntervalVar(m.Steps, start=(0, 12), end=(0, 12), length=length_rule) - m.seq = SequenceVar(expr=[m.i[j] for j in m.Steps]) - m.first = LogicalConstraint(expr=first_in_sequence(m.i[1], m.seq)) - m.seq_order1 = LogicalConstraint(expr=predecessor_to(m.i[1], m.i[2], m.seq)) - m.seq_order2 = LogicalConstraint(expr=predecessor_to(m.i[2], m.i[3], m.seq)) - m.no_ovlerpa = LogicalConstraint(expr=no_overlap(m.seq)) - - results = SolverFactory('cp_optimizer').solve(m) - self.assertEqual( - results.solver.termination_condition, TerminationCondition.feasible - ) - self.assertEqual(value(m.i[1].start_time), 0) - self.assertEqual(value(m.i[2].start_time), 2) - self.assertEqual(value(m.i[3].start_time), 6) + ct.check_solve_three_step_sequence_model(self, 'cp_optimizer')