Source code for pycsp3_scheduling.constraints.forbidden

"""
Forbidden time constraints for interval variables.

This module provides constraints that prevent intervals from starting, ending,
or spanning during specific time periods:

1. **forbid_start(interval, forbidden_periods)**: Cannot start during forbidden periods
2. **forbid_end(interval, forbidden_periods)**: Cannot end during forbidden periods
3. **forbid_extent(interval, forbidden_periods)**: Cannot overlap forbidden periods

All constraints return pycsp3 Node objects that can be used with satisfy().

Absent interval semantics:
- When interval is present: constraint is enforced
- When interval is absent: constraint is trivially satisfied
"""

from __future__ import annotations

from collections.abc import Iterable
from typing import Sequence

from pycsp3_scheduling.constraints._pycsp3 import (
    _get_node_builders,
    _validate_interval,
    length_value,
    presence_var,
    start_var,
)
from pycsp3_scheduling.variables.interval import IntervalVar


def _validate_periods(
    periods: Sequence[tuple[int, int]] | Iterable[tuple[int, int]], func_name: str
) -> list[tuple[int, int]]:
    """Validate and normalize forbidden periods."""
    result = []
    for i, period in enumerate(periods):
        if not isinstance(period, (list, tuple)) or len(period) != 2:
            raise TypeError(
                f"{func_name}: forbidden_periods[{i}] must be a (start, end) tuple"
            )
        start, end = period
        if not isinstance(start, int) or not isinstance(end, int):
            raise TypeError(
                f"{func_name}: forbidden_periods[{i}] must contain integers"
            )
        if start >= end:
            raise ValueError(
                f"{func_name}: forbidden_periods[{i}] must have start < end, "
                f"got ({start}, {end})"
            )
        result.append((start, end))
    return result


def _build_end_expr(interval: IntervalVar, Node, TypeNode):
    """Build end expression: start + length."""
    start = start_var(interval)
    length = length_value(interval)
    if isinstance(length, int) and length == 0:
        return start
    return Node.build(TypeNode.ADD, start, length)


# =============================================================================
# Forbidden Time Constraints
# =============================================================================


[docs] def forbid_start( interval: IntervalVar, forbidden_periods: Sequence[tuple[int, int]], ) -> list: """ Constrain the interval to not start during any of the forbidden time periods. For each forbidden period (s, e), the interval's start time must not be in the range [s, e). That is: NOT (s <= start < e). Args: interval: The interval variable to constrain. forbidden_periods: List of (start, end) tuples defining forbidden periods. Each period is a half-open interval [start, end). Returns: List of pycsp3 constraint nodes. Raises: TypeError: If interval is not an IntervalVar or periods are malformed. ValueError: If any period has start >= end. Example: >>> task = IntervalVar(size=10, name="task") >>> # Cannot start during lunch break (12-13) or after hours (17-24) >>> satisfy(forbid_start(task, [(12, 13), (17, 24)])) """ _validate_interval(interval, "forbid_start") periods = _validate_periods(forbidden_periods, "forbid_start") if not periods: return [] Node, TypeNode = _get_node_builders() constraints = [] start = start_var(interval) is_optional = interval.optional for period_start, period_end in periods: # NOT (period_start <= start < period_end) # = (start < period_start) OR (start >= period_end) before_period = Node.build(TypeNode.LT, start, period_start) after_period = Node.build(TypeNode.GE, start, period_end) if is_optional: # (presence == 0) OR (start < period_start) OR (start >= period_end) pres = presence_var(interval) absent = Node.build(TypeNode.EQ, pres, 0) constraint = Node.build(TypeNode.OR, absent, before_period, after_period) else: constraint = Node.build(TypeNode.OR, before_period, after_period) constraints.append(constraint) return constraints
[docs] def forbid_end( interval: IntervalVar, forbidden_periods: Sequence[tuple[int, int]], ) -> list: """ Constrain the interval to not end during any of the forbidden time periods. For each forbidden period (s, e), the interval's end time must not be in the range (s, e]. That is: NOT (s < end <= e). Args: interval: The interval variable to constrain. forbidden_periods: List of (start, end) tuples defining forbidden periods. Each period is a half-open interval (start, end]. Returns: List of pycsp3 constraint nodes. Raises: TypeError: If interval is not an IntervalVar or periods are malformed. ValueError: If any period has start >= end. Example: >>> task = IntervalVar(size=10, name="task") >>> # Cannot end during maintenance window >>> satisfy(forbid_end(task, [(6, 8)])) """ _validate_interval(interval, "forbid_end") periods = _validate_periods(forbidden_periods, "forbid_end") if not periods: return [] Node, TypeNode = _get_node_builders() constraints = [] end = _build_end_expr(interval, Node, TypeNode) is_optional = interval.optional for period_start, period_end in periods: # NOT (period_start < end <= period_end) # = (end <= period_start) OR (end > period_end) before_or_at_start = Node.build(TypeNode.LE, end, period_start) after_period = Node.build(TypeNode.GT, end, period_end) if is_optional: pres = presence_var(interval) absent = Node.build(TypeNode.EQ, pres, 0) constraint = Node.build(TypeNode.OR, absent, before_or_at_start, after_period) else: constraint = Node.build(TypeNode.OR, before_or_at_start, after_period) constraints.append(constraint) return constraints
[docs] def forbid_extent( interval: IntervalVar, forbidden_periods: Sequence[tuple[int, int]], ) -> list: """ Constrain the interval to not overlap any of the forbidden time periods. For each forbidden period (s, e), the interval must be completely before or completely after. That is: (end <= s) OR (start >= e). Args: interval: The interval variable to constrain. forbidden_periods: List of (start, end) tuples defining forbidden periods. The interval cannot span across any of these periods. Returns: List of pycsp3 constraint nodes. Raises: TypeError: If interval is not an IntervalVar or periods are malformed. ValueError: If any period has start >= end. Example: >>> task = IntervalVar(size=10, name="task") >>> # Task cannot span across lunch break - must be entirely before or after >>> satisfy(forbid_extent(task, [(12, 13)])) """ _validate_interval(interval, "forbid_extent") periods = _validate_periods(forbidden_periods, "forbid_extent") if not periods: return [] Node, TypeNode = _get_node_builders() constraints = [] start = start_var(interval) end = _build_end_expr(interval, Node, TypeNode) is_optional = interval.optional for period_start, period_end in periods: # No overlap: (end <= period_start) OR (start >= period_end) ends_before = Node.build(TypeNode.LE, end, period_start) starts_after = Node.build(TypeNode.GE, start, period_end) if is_optional: pres = presence_var(interval) absent = Node.build(TypeNode.EQ, pres, 0) constraint = Node.build(TypeNode.OR, absent, ends_before, starts_after) else: constraint = Node.build(TypeNode.OR, ends_before, starts_after) constraints.append(constraint) return constraints