Skip to content

raplan.milp

Linear Programming approach to the scheduling problem.

LpBuilder dataclass

LpBuilder(
    time_segments: int = 1,
    duration_scale: float = 1.0,
    window_fallback_fn: Callable[
        [int | float], Window
    ] = new_immovable,
    force_sync: bool = False,
    movement_objective: int | float = 100,
    dynamic_movement_objective: Callable[
        [UUID, float], float | None
    ] = lambda _id, _delta: None,
    concurrent_objective: int | float = -250,
    pair_objective: Callable[
        [frozenset[UUID]], int | float | None
    ] = lambda _: None,
    pair_exclusions: Callable[
        [frozenset[UUID]], bool
    ] = lambda _: False,
    only_positive: bool = False,
    silent: bool = True,
)

Linear programming problem builder.

Attributes:

Name Type Description
lp Highs

Linear programming model.

time_segments int

How many segments to subdivide each 1.0 of time in.

duration_scale float

Scale to convert task durations to the absolute time scale used in scheduling.

window_fallback_fn Callable[[int | float], Window]

Fallback function for maintenance that does not have a defined window.

force_sync bool

Forcefully synchronize starting points, disallowing overlapping tasks with disjunct starting segments.

movement_objective int | float

Movement penalty (>0) for moving a maintenance item 1 time unit. The penalty per segment is calculated internally.

dynamic_movement_objective Callable[[UUID, float], float | None]

A callable that may return a specific time movement objective contribution for a given maintenance ID and shift in absolute time w.r.t. the project horizon. It converts the segment delta to the maximum amount of time it represents.

concurrent_objective int | float

Concurrent execution reward (<0) to the objective function for when a task is executed concurrently for 1 time unit. The reward per segment is calculated internally.

pair_objective Callable[[frozenset[UUID]], int | float | None]

Method to retrieve the objective function contribution for pairing two given maintenance items per unit of time. Discourage with a positive value (i.e. penalty) or encourage with a negative value (i.e. savings).

pair_exclusions Callable[[frozenset[UUID]], bool]

Method to retrieve whether a specific maintenance UUID pairing should be disabled to start together at all times.

only_positive bool

Whether to only include tasks and windows that result in positive times.

silent bool

Whether to silence the solver's output while running.

get_duration_segments

get_duration_segments(delta_x: float) -> int

Calculate the number of segments for a given duration.

Source code in src/raplan/milp.py
def get_duration_segments(self, delta_x: float) -> int:
    """Calculate the number of segments for a given duration."""
    return duration_segments(delta_x=delta_x, time_segments=self.time_segments)

get_movement_objective

get_movement_objective(
    id: UUID, delta_segments: int
) -> int | float

Get the movement objective value for a maintenance ID that moves this amount with respect to its Window's initial value.

Source code in src/raplan/milp.py
def get_movement_objective(self, id: UUID, delta_segments: int) -> int | float:
    """Get the movement objective value for a maintenance ID that moves this amount
    with respect to its Window's initial value."""
    dynamic: int | float | None = self.dynamic_movement_objective(
        id, self.per_segment * delta_segments
    )
    if dynamic is None:
        return self.movement_objective * self.per_segment * abs(delta_segments)
    return dynamic

get_time_segment

get_time_segment(x: float) -> int

Calculate the time segment for a given time.

Source code in src/raplan/milp.py
def get_time_segment(self, x: float) -> int:
    """Calculate the time segment for a given time."""
    return time_segment(x, time_segments=self.time_segments)

get_window_range

get_window_range(window: Window) -> range

Calculate the time segment range for a window.

Source code in src/raplan/milp.py
def get_window_range(self, window: Window) -> range:
    """Calculate the time segment range for a window."""
    return window_segments(window, time_segments=self.time_segments)

LpCombinations dataclass

LpCombinations(
    builder: LpBuilder,
    pairs: list[tuple[int, UUID, UUID, highs_var]],
    pair_constraints: list[
        tuple[int, UUID, UUID, highs_cons]
    ],
    disjoint_constraints: list[
        tuple[int, UUID, UUID, highs_cons]
    ],
    exclusion_constraints: list[
        tuple[int, UUID, UUID, highs_cons]
    ],
)

Bases: WithBuilder

Synchronization that either dictates a matching start or a disjoint sequence.

Attributes:

Name Type Description
pairs list[tuple[int, UUID, UUID, highs_var]]

Pairwise start combination variables.

disjoint_constraints list[tuple[int, UUID, UUID, highs_cons]]

Constraints to disable disjoint starts for overlapping maintenance.

exclusion_constraints list[tuple[int, UUID, UUID, highs_cons]]

Constraints to prevent two maintenance items to overlap completely.

builder instance-attribute

builder: LpBuilder

Linear programming builder this project is tied to.

info property

info: HighsInfo

Current solver info.

lp property

lp: Highs

Inner linear programming solver.

model_status property

model_status: HighsModelStatus

Current solver status.

objective property

objective: float

The current solution objective value.

solution property

solution: HighsSolution

Current solver solution.

build staticmethod

build(
    builder: LpBuilder,
    items: dict[UUID, LpMoveableItem],
    candidates: dict[int, list[tuple[UUID, highs_var]]],
) -> LpCombinations

Build a set of synchronization constraints that prohibit overlap without a common starting segment.

Parameters:

Name Type Description Default
builder LpBuilder

MILP model builder.

required
items dict[UUID, LpMoveableItem]

Mapping of unique maintenance identifiers to their MILP synchronization descriptions.

required
candidates dict[int, list[tuple[UUID, highs_var]]]

Mapping of segment indices to lists of pairs of maintenance identifiers and the scheduling booleans that may lead to occupation of the given segment.

required
Source code in src/raplan/milp.py
@staticmethod
def build(
    builder: LpBuilder,
    items: dict[UUID, LpMoveableItem],
    candidates: dict[int, list[tuple[UUID, highs_var]]],
) -> "LpCombinations":
    """Build a set of synchronization constraints that prohibit overlap without a common
    starting segment.

    Arguments:
        builder: MILP model builder.
        items: Mapping of unique maintenance identifiers to their MILP synchronization
            descriptions.
        candidates: Mapping of segment indices to lists of pairs of maintenance identifiers and
            the scheduling booleans that may lead to occupation of the given segment.
    """

    disjoint_constraints: list[tuple[int, UUID, UUID, highs_cons]] = []
    pairs: list[tuple[int, UUID, UUID, highs_var]] = []
    pair_constraints: list[tuple[int, UUID, UUID, highs_cons]] = []
    exclusion_constraints: list[tuple[int, UUID, UUID, highs_cons]] = []

    observed: set[frozenset[UUID]] = set()
    for entries in candidates.values():
        for combi in combinations((e[0] for e in entries), r=2):
            pair: frozenset[UUID] = frozenset(combi)
            if len(pair) < 2 or pair in observed:
                continue
            observed.add(pair)

            a, b = (items[member] for member in pair)

            if builder.pair_exclusions(pair):
                exclusion_constraints.extend(a.exclude(other=b))
                exclusion_constraints.extend(b.exclude(other=a))
                continue  # because forcing sync or objective combinations become redundant.

            if builder.force_sync:
                disjoint_constraints.extend(a.disable_tail_start(other=b))
                disjoint_constraints.extend(b.disable_tail_start(other=a))

            obj: float | None = builder.pair_objective(pair)
            if obj:
                for segment in shared_range(a.window, b.window):
                    var = builder.lp.addBinary(obj=obj * builder.per_segment)
                    var_a = a.segment_vars[segment - a.window.start]
                    var_b = b.segment_vars[segment - b.window.start]
                    pairs.append(
                        (
                            segment,
                            a.uuid,
                            b.uuid,
                            var,
                        )
                    )
                    pair_constraints.append(
                        (
                            segment,
                            a.uuid,
                            b.uuid,
                            builder.lp.addConstr(2 * var <= var_a + var_b),
                        )
                    )

    return LpCombinations(
        builder=builder,
        pairs=pairs,
        pair_constraints=pair_constraints,
        disjoint_constraints=disjoint_constraints,
        exclusion_constraints=exclusion_constraints,
    )

get_value

get_value(
    var: highs_var | highs_linear_expression,
) -> int | bool | float

Get a highs variable or expression value.

Source code in src/raplan/milp.py
def get_value(self, var: highs_var | highs_linear_expression) -> int | bool | float:
    """Get a highs variable or expression value."""
    value = self.lp.variableValue(var)
    return value

run

run() -> HighsModelStatus

Run the solver.

Source code in src/raplan/milp.py
def run(self) -> HighsModelStatus:
    """Run the solver."""
    self.lp.solve()
    return self.model_status

solve

solve() -> HighsModelStatus

Run the solver if we're not optimal.

Source code in src/raplan/milp.py
def solve(self) -> HighsModelStatus:
    """Run the solver if we're not optimal."""

    if self.model_status != HighsModelStatus.kOptimal:
        return self.run()

    return self.model_status

LpMoveableItem dataclass

LpMoveableItem(
    builder: LpBuilder,
    uuid: UUID,
    initial: int,
    window: range,
    duration: int,
    segment_vars: list[highs_var],
    picking_constraint: highs_cons,
)

Bases: WithBuilder

MILP description for a single maintenance item for the synchronization problem.

Attributes:

Name Type Description
uuid UUID

Identifier of the corresponding maintenance item.

initial int

Initial segment.

window range

Segment range where this maintenance should fall within. These may be negative, so you should not index using values taken from this window, but rather enumerate it.

duration int

Duration as a number of segments.

segment_vars list[highs_var]

Variables corresponding to the segment toggles.

picking_constraint highs_cons

Constraint that states exactly one segment should be picked.

builder instance-attribute

builder: LpBuilder

Linear programming builder this project is tied to.

info property

info: HighsInfo

Current solver info.

lp property

lp: Highs

Inner linear programming solver.

model_status property

model_status: HighsModelStatus

Current solver status.

objective property

objective: float

The current solution objective value.

solution property

solution: HighsSolution

Current solver solution.

build staticmethod

build(
    builder: LpBuilder, item: Maintenance | Procedure
) -> LpMoveableItem

Build MILP description for a Maintenance instance.

Adds binary toggles for each segment the maintenance could be planned in, as well as a constraint that only one can be picked.

Parameters:

Name Type Description Default
builder LpBuilder

MILP model builder.

required
item Maintenance | Procedure

Maintenance instance.

required

Returns:

Type Description
LpMoveableItem

Linear programming representation of the maintenance instance.

Source code in src/raplan/milp.py
@staticmethod
def build(
    builder: LpBuilder,
    item: Maintenance | Procedure,
) -> "LpMoveableItem":
    """Build MILP description for a `Maintenance` instance.

    Adds binary toggles for each segment the maintenance could be planned in,
    as well as a constraint that only one can be picked.

    Arguments:
        builder: MILP model builder.
        item: Maintenance instance.

    Returns:
        Linear programming representation of the maintenance instance.
    """

    if builder.only_positive:
        if item.time < 0:
            time_window: Window = Window.new_immovable(item.time)
        else:
            time_window: Window = item.window or builder.window_fallback_fn(item.time)
            if time_window.earliest < 0:
                time_window.max_rush = time_window.initial
    else:
        time_window: Window = item.window or builder.window_fallback_fn(item.time)

    initial: int = builder.get_time_segment(x=time_window.initial)
    window: range = builder.get_window_range(window=time_window)
    if isinstance(item, Procedure):
        segment_vars: list[highs_var] = [
            builder.lp.addBinary(
                obj=sum(
                    builder.get_movement_objective(m.uuid, segment - initial)
                    for m in item.maintenance
                )
            )
            for segment in window
        ]
    else:
        segment_vars: list[highs_var] = [
            builder.lp.addBinary(
                obj=builder.get_movement_objective(item.uuid, segment - initial)
            )
            for segment in window
        ]

    expr = sum(segment_vars) == 1
    assert isinstance(expr, highs_linear_expression), (
        "Expected an expression, are you sure there are segments in this window?"
    )
    picking_constraint = builder.lp.addConstr(expr)

    return LpMoveableItem(
        builder=builder,
        uuid=item.uuid,
        initial=initial,
        window=window,
        duration=builder.get_duration_segments(delta_x=builder.duration_scale * item.duration),
        segment_vars=segment_vars,
        picking_constraint=picking_constraint,
    )

disable_tail_start

disable_tail_start(
    other: LpMoveableItem,
) -> Generator[
    tuple[int, UUID, UUID, highs_cons], None, None
]

Disable starting any maintenance in the tail segments of this maintenance.

Yields:

Type Description
tuple[int, UUID, UUID, highs_cons]

Tuple of segment index, own UUID, other UUID and a disabling constraint.

Source code in src/raplan/milp.py
def disable_tail_start(
    self, other: "LpMoveableItem"
) -> Generator[tuple[int, UUID, UUID, highs_cons], None, None]:
    """Disable starting any maintenance in the tail segments of this maintenance.

    Yields:
        Tuple of segment index, own UUID, other UUID and a disabling constraint.
    """

    for segment, toggle in zip(self.window, self.segment_vars):
        # The disabled range is one past A up until it's duration.
        # The index in B's variable list is offset by it's window start.
        tail = slice(
            max(0, segment + 1 - other.window.start),
            segment + self.duration - other.window.start,
        )
        disabled: list[highs_var] = other.segment_vars[tail]

        # Disabled segments could be empty.
        if disabled:
            yield (
                segment,
                self.uuid,
                other.uuid,
                self.lp.addConstr(sum(disabled) <= (1 - toggle)),  # type: ignore
            )

exclude

exclude(
    other: LpMoveableItem,
) -> Generator[
    tuple[int, UUID, UUID, highs_cons], None, None
]

Disallows the other maintenance item to start during this item.

Yields:

Type Description
tuple[int, UUID, UUID, highs_cons]

Tuple of a starting segment, this item's ID, disabled item's ID and constraint.

Source code in src/raplan/milp.py
def exclude(
    self, other: "LpMoveableItem"
) -> Generator[tuple[int, UUID, UUID, highs_cons], None, None]:
    """Disallows the other maintenance item to start during this item.

    Yields:
        Tuple of a starting segment, this item's ID, disabled item's ID and constraint.
    """
    for segment, toggle in zip(self.window, self.segment_vars):
        to_disable = slice(
            max(0, segment - other.window.start),
            segment + self.duration - other.window.start,
        )
        disabled: list[highs_var] = other.segment_vars[to_disable]

        # Disabled segments could be empty.
        if disabled:
            yield (
                segment,
                self.uuid,
                other.uuid,
                self.lp.addConstr(sum(disabled) <= (1 - toggle)),  # type: ignore
            )

get_value

get_value(
    var: highs_var | highs_linear_expression,
) -> int | bool | float

Get a highs variable or expression value.

Source code in src/raplan/milp.py
def get_value(self, var: highs_var | highs_linear_expression) -> int | bool | float:
    """Get a highs variable or expression value."""
    value = self.lp.variableValue(var)
    return value

is_picked_valid

is_picked_valid() -> bool

Check whether the picking constraint has been satisfied.

Source code in src/raplan/milp.py
def is_picked_valid(self) -> bool:
    """Check whether the picking constraint has been satisfied."""
    value = self.get_value(self.picking_constraint.expr())
    return bool(value)

picked_segment

picked_segment() -> int

The currently picked segment for this maintenance instance.

Source code in src/raplan/milp.py
def picked_segment(self) -> int:
    """The currently picked segment for this maintenance instance."""
    try:
        picked: int | None = next(
            segment
            for segment, var in zip(self.window, self.segment_vars)
            if self.get_value(var=var)
        )
        return self.initial if picked is None else picked
    except StopIteration:
        return self.initial

run

run() -> HighsModelStatus

Run the solver.

Source code in src/raplan/milp.py
def run(self) -> HighsModelStatus:
    """Run the solver."""
    self.lp.solve()
    return self.model_status

solve

solve() -> HighsModelStatus

Run the solver if we're not optimal.

Source code in src/raplan/milp.py
def solve(self) -> HighsModelStatus:
    """Run the solver if we're not optimal."""

    if self.model_status != HighsModelStatus.kOptimal:
        return self.run()

    return self.model_status

LpProcedureProject dataclass

LpProcedureProject(
    builder: LpBuilder,
    uuid: UUID,
    procedures: list[LpMoveableItem],
    occupation: list[LpSegmentOccupation],
    combinations: LpCombinations,
)

Bases: WithBuilder

MILP description of a procedure scheduling problem for a project.

Attributes:

uuid: Unique identifier for the original Project being modeled.
procedures: List of procedures whose schedule is being optimized.
constraints: List of constraints.

builder instance-attribute

builder: LpBuilder

Linear programming builder this project is tied to.

info property

info: HighsInfo

Current solver info.

lp property

lp: Highs

Inner linear programming solver.

model_status property

model_status: HighsModelStatus

Current solver status.

objective property

objective: float

The current solution objective value.

solution property

solution: HighsSolution

Current solver solution.

apply_to

apply_to(project: Project, new: bool = True) -> Project

Apply rescheduled maintenance results to this project.

Parameters:

Name Type Description Default
project Project

Project to reschedule maintenance for.

required
new bool

Whether to create a new deepcopy of this project.

True

Returns:

Type Description
Project

Project with updated maintenance.

Source code in src/raplan/milp.py
def apply_to(self, project: Project, new: bool = True) -> Project:
    """Apply rescheduled maintenance results to this project.

    Arguments:
        project: Project to reschedule maintenance for.
        new: Whether to create a new deepcopy of this project.

    Returns:
        Project with updated maintenance.
    """
    if new:
        project = deepcopy(project)

    procedures: dict[UUID, Procedure] = {proc.uuid: proc for proc in project.gen_procedures()}

    for lproc in self.procedures:
        proc: Procedure = procedures[lproc.uuid]
        new_time: int | float = self.builder.per_segment * lproc.picked_segment()
        proc.reschedule(new_time, PROC_REASON)

    return project

build staticmethod

build(
    builder: LpBuilder, project: Project
) -> LpProcedureProject

Build a MILP description for a procedure planning project.

Constraints can be added using the instance methods on LpSyncProject itself.

Parameters:

Name Type Description Default
project Project

Project to encode as a procedure optimization problem.

required

Returns:

Type Description
LpProcedureProject

MILP description for a procedure planning project.

Source code in src/raplan/milp.py
@staticmethod
def build(builder: LpBuilder, project: Project) -> "LpProcedureProject":
    """Build a MILP description for a procedure planning project.

    Constraints can be added using the instance methods on `LpSyncProject` itself.

    Arguments:
        project: Project to encode as a procedure optimization problem.

    Returns:
        MILP description for a procedure planning project.
    """

    procedures: dict[UUID, LpMoveableItem] = {
        procedure.uuid: LpMoveableItem.build(builder, procedure)
        for procedure in project.gen_procedures()
    }

    # Create a mapping per segment to all variables that would lead to segment occupation.
    candidates: defaultdict[int, list[tuple[UUID, highs_var]]] = defaultdict(list)
    for p in procedures.values():
        # Add segment occupation.
        for segment, toggle in zip(p.window, p.segment_vars):
            for offset in range(p.duration):
                occupation_by_segment: list[tuple[UUID, highs_var]] = candidates[
                    segment + offset
                ]
                occupation_by_segment.append((p.uuid, toggle))

    # Build segment descriptions with variables and constraints regarding the amount of
    # parallel execution. Can be skipped if the objective is 0 regardless.
    if builder.concurrent_objective != 0:
        occupation: list[LpSegmentOccupation] = [
            LpSegmentOccupation.build(builder, segment=segment, candidates=candidates)
            for segment, candidates in candidates.items()
        ]
    else:
        occupation: list[LpSegmentOccupation] = []

    # For the given system maintenance and segment occupation, calculate the potentially
    # overlapping pairs and constrain them such that they either start synchronously or don't
    # overlap at all.
    combinations = LpCombinations.build(
        builder=builder,
        items=procedures,
        candidates=candidates,
    )

    return LpProcedureProject(
        builder=builder,
        uuid=project.uuid,
        procedures=list(procedures.values()),
        occupation=occupation,
        combinations=combinations,
    )

get_value

get_value(
    var: highs_var | highs_linear_expression,
) -> int | bool | float

Get a highs variable or expression value.

Source code in src/raplan/milp.py
def get_value(self, var: highs_var | highs_linear_expression) -> int | bool | float:
    """Get a highs variable or expression value."""
    value = self.lp.variableValue(var)
    return value

run

run() -> HighsModelStatus

Run the solver.

Source code in src/raplan/milp.py
def run(self) -> HighsModelStatus:
    """Run the solver."""
    self.lp.solve()
    return self.model_status

solve

solve() -> HighsModelStatus

Run the solver if we're not optimal.

Source code in src/raplan/milp.py
def solve(self) -> HighsModelStatus:
    """Run the solver if we're not optimal."""

    if self.model_status != HighsModelStatus.kOptimal:
        return self.run()

    return self.model_status

LpSegmentOccupation dataclass

LpSegmentOccupation(
    builder: LpBuilder,
    segment: int,
    occupation: highs_var | Literal[0],
    concurrent: highs_var,
    is_occupied: highs_var,
    occupation_constraint: highs_cons,
    concurrent_constraint: highs_cons,
)

Bases: WithBuilder

Segment occupation within a system.

Attributes:

Name Type Description
segment int

Segment that is encoded.

occupation highs_var | Literal[0]

Variable denoting the sum of tasks occupying this segment.

concurrent highs_var

Variable denoting the concurrent number of tasks: occupation minus 1, but 0 when 0.

is_occupied highs_var

Variable denoting whether this segment is occupied.

occupied_constraint highs_var

Constraint binding occupation to the sum of members using a big-M constraint.

concurrent_constraint highs_cons

Binding concurrency to occupation minus is_occupied.

builder instance-attribute

builder: LpBuilder

Linear programming builder this project is tied to.

info property

info: HighsInfo

Current solver info.

lp property

lp: Highs

Inner linear programming solver.

model_status property

model_status: HighsModelStatus

Current solver status.

objective property

objective: float

The current solution objective value.

solution property

solution: HighsSolution

Current solver solution.

build staticmethod

build(
    builder: LpBuilder,
    segment: int,
    candidates: list[tuple[UUID, highs_var]],
) -> LpSegmentOccupation

Build a MILP description for a segment where system maintenance may be planned in. This method ties the potential benefits of concurrent task execution during a segment to the start time of the candidate tasks.

Source code in src/raplan/milp.py
@staticmethod
def build(
    builder: LpBuilder, segment: int, candidates: list[tuple[UUID, highs_var]]
) -> "LpSegmentOccupation":
    """Build a MILP description for a segment where system maintenance may be
    planned in. This method ties the potential benefits of concurrent task execution during a
    segment to the start time of the candidate tasks.
    """
    upper: int = len(candidates)

    occupation: highs_var | Literal[0] = sum(c[1] for c in candidates)
    concurrent: highs_var = builder.lp.addIntegral(
        ub=len(candidates), obj=builder.per_segment * builder.concurrent_objective
    )
    is_occupied: highs_var = builder.lp.addBinary()

    # Constraint that states that `is_occupied` should turn true when there is occupation.
    occupation_constraint: highs_cons = builder.lp.addConstr(occupation <= is_occupied * upper)  # type: ignore

    # Concurrency equals occupation minus one except when unoccupied.
    concurrent_constraint: highs_cons = builder.lp.addConstr(
        concurrent == occupation - is_occupied
    )

    return LpSegmentOccupation(
        builder=builder,
        segment=segment,
        occupation=occupation,
        concurrent=concurrent,
        is_occupied=is_occupied,
        occupation_constraint=occupation_constraint,
        concurrent_constraint=concurrent_constraint,
    )

get_value

get_value(
    var: highs_var | highs_linear_expression,
) -> int | bool | float

Get a highs variable or expression value.

Source code in src/raplan/milp.py
def get_value(self, var: highs_var | highs_linear_expression) -> int | bool | float:
    """Get a highs variable or expression value."""
    value = self.lp.variableValue(var)
    return value

run

run() -> HighsModelStatus

Run the solver.

Source code in src/raplan/milp.py
def run(self) -> HighsModelStatus:
    """Run the solver."""
    self.lp.solve()
    return self.model_status

solve

solve() -> HighsModelStatus

Run the solver if we're not optimal.

Source code in src/raplan/milp.py
def solve(self) -> HighsModelStatus:
    """Run the solver if we're not optimal."""

    if self.model_status != HighsModelStatus.kOptimal:
        return self.run()

    return self.model_status

LpSyncComponent dataclass

LpSyncComponent(
    builder: LpBuilder,
    uuid: UUID,
    maintenance: list[LpMoveableItem],
)

Bases: WithBuilder

MILP description of a single component for the synchronization problem.

Attributes:

Name Type Description
uuid UUID

Identifier of the corresponding component.

maintenance list[LpMoveableItem]

List of maintenance descriptions.

builder instance-attribute

builder: LpBuilder

Linear programming builder this project is tied to.

info property

info: HighsInfo

Current solver info.

lp property

lp: Highs

Inner linear programming solver.

model_status property

model_status: HighsModelStatus

Current solver status.

objective property

objective: float

The current solution objective value.

solution property

solution: HighsSolution

Current solver solution.

apply_to

apply_to(
    component: Component, new: bool = True
) -> Component

Apply rescheduled maintenance results to this component.

Parameters:

Name Type Description Default
component Component

Component to reschedule maintenance for.

required
new bool

Whether to create a new deepcopy of this component.

True

Returns:

Type Description
Component

Component with updated maintenance.

Source code in src/raplan/milp.py
def apply_to(self, component: Component, new: bool = True) -> Component:
    """Apply rescheduled maintenance results to this component.

    Arguments:
        component: Component to reschedule maintenance for.
        new: Whether to create a new deepcopy of this component.

    Returns:
        Component with updated maintenance.
    """
    if new:
        component = deepcopy(component)
    data: dict[UUID, float] = {k: v for k, v in self.gen_maintenance_times()}
    component.reschedule_maintenance(data=data, reason=SYNC_REASON)
    return component

build staticmethod

build(
    builder: LpBuilder, component: Component
) -> LpSyncComponent

Build a linear programming problem for a Component instance.

Parameters:

Name Type Description Default
component Component

Component instance.

required
Source code in src/raplan/milp.py
@staticmethod
def build(builder: LpBuilder, component: Component) -> "LpSyncComponent":
    """Build a linear programming problem for a `Component` instance.

    Arguments:
        component: Component instance.
    """

    return LpSyncComponent(
        builder=builder,
        uuid=component.uuid,
        maintenance=[
            LpMoveableItem.build(builder, maintenance)
            for maintenance in component.maintenance
            if not builder.only_positive or maintenance.end >= 0
        ],
    )

gen_maintenance_segments

gen_maintenance_segments() -> (
    Generator[tuple[UUID, int], Never, None]
)

Generate all allocated maintenance segments for this component.

Source code in src/raplan/milp.py
def gen_maintenance_segments(self) -> Generator[tuple[UUID, int], Never, None]:
    """Generate all allocated maintenance segments for this component."""
    for m in self.maintenance:
        yield (m.uuid, m.picked_segment())

gen_maintenance_times

gen_maintenance_times() -> (
    Generator[tuple[UUID, int | float], Never, None]
)

Generate all maintenance times for this component.

Source code in src/raplan/milp.py
def gen_maintenance_times(self) -> Generator[tuple[UUID, int | float], Never, None]:
    """Generate all maintenance times for this component."""
    for m in self.maintenance:
        yield (m.uuid, m.picked_time())

get_value

get_value(
    var: highs_var | highs_linear_expression,
) -> int | bool | float

Get a highs variable or expression value.

Source code in src/raplan/milp.py
def get_value(self, var: highs_var | highs_linear_expression) -> int | bool | float:
    """Get a highs variable or expression value."""
    value = self.lp.variableValue(var)
    return value

run

run() -> HighsModelStatus

Run the solver.

Source code in src/raplan/milp.py
def run(self) -> HighsModelStatus:
    """Run the solver."""
    self.lp.solve()
    return self.model_status

solve

solve() -> HighsModelStatus

Run the solver if we're not optimal.

Source code in src/raplan/milp.py
def solve(self) -> HighsModelStatus:
    """Run the solver if we're not optimal."""

    if self.model_status != HighsModelStatus.kOptimal:
        return self.run()

    return self.model_status

LpSyncProject dataclass

LpSyncProject(
    builder: LpBuilder,
    uuid: UUID,
    systems: list[LpSyncSystem],
)

Bases: WithBuilder

MILP description of a project.

It is encoded in such a way to look for the synchronization of maintenance tasks applied to components in a system, such that coinciding tasks can then be grouped into procedures.

Attributes:

Name Type Description
uuid UUID

Unique identifier of the project that has been encoded.

systems list[LpSyncSystem]

MILP descriptions of all partaking systems.

builder instance-attribute

builder: LpBuilder

Linear programming builder this project is tied to.

info property

info: HighsInfo

Current solver info.

lp property

lp: Highs

Inner linear programming solver.

model_status property

model_status: HighsModelStatus

Current solver status.

objective property

objective: float

The current solution objective value.

solution property

solution: HighsSolution

Current solver solution.

apply_to

apply_to(project: Project, new: bool = True) -> Project

Apply rescheduled maintenance results to this project.

Parameters:

Name Type Description Default
project Project

Project to reschedule maintenance for.

required
new bool

Whether to create a new deepcopy of this project.

True

Returns:

Type Description
Project

Project with updated maintenance.

Source code in src/raplan/milp.py
def apply_to(self, project: Project, new: bool = True) -> Project:
    """Apply rescheduled maintenance results to this project.

    Arguments:
        project: Project to reschedule maintenance for.
        new: Whether to create a new deepcopy of this project.

    Returns:
        Project with updated maintenance.
    """
    if new:
        project = deepcopy(project)
    data: dict[UUID, float] = {k: v for k, v in self.gen_maintenance_times()}
    project.reschedule_maintenance(data=data, reason=SYNC_REASON)
    return project

build staticmethod

build(
    builder: LpBuilder, project: Project
) -> LpSyncProject

Build a linear programming problem for a Project.

Parameters:

Name Type Description Default
project Project

Project instance.

required
Source code in src/raplan/milp.py
@staticmethod
def build(builder: LpBuilder, project: Project) -> "LpSyncProject":
    """Build a linear programming problem for a `Project`.

    Arguments:
        project: `Project` instance.
    """
    return LpSyncProject(
        builder=builder,
        uuid=project.uuid,
        systems=[LpSyncSystem.build(builder, system) for system in project.systems],
    )

gen_maintenance_segments

gen_maintenance_segments() -> (
    Generator[tuple[UUID, int], Never, None]
)

Generate all allocated maintenance segments for this project's systems and components.

Yields:

Type Description
tuple[UUID, int]

A tuple of each maintenance UUID and allocated segment index.

Source code in src/raplan/milp.py
def gen_maintenance_segments(self) -> Generator[tuple[UUID, int], Never, None]:
    """Generate all allocated maintenance segments for this project's systems and components.

    Yields:
        A tuple of each maintenance UUID and allocated segment index.
    """
    for s in self.systems:
        yield from s.gen_maintenance_segments()

gen_maintenance_times

gen_maintenance_times() -> (
    Generator[tuple[UUID, int | float], Never, None]
)

Generate all maintenance times for this or this project's systems and components.

Yields:

Type Description
tuple[UUID, int | float]

A tuple of each maintenance UUID and time.

Source code in src/raplan/milp.py
def gen_maintenance_times(self) -> Generator[tuple[UUID, int | float], Never, None]:
    """Generate all maintenance times for this or this project's systems and components.

    Yields:
        A tuple of each maintenance UUID and time.
    """
    for s in self.systems:
        yield from s.gen_maintenance_times()

get_value

get_value(
    var: highs_var | highs_linear_expression,
) -> int | bool | float

Get a highs variable or expression value.

Source code in src/raplan/milp.py
def get_value(self, var: highs_var | highs_linear_expression) -> int | bool | float:
    """Get a highs variable or expression value."""
    value = self.lp.variableValue(var)
    return value

run

run() -> HighsModelStatus

Run the solver.

Source code in src/raplan/milp.py
def run(self) -> HighsModelStatus:
    """Run the solver."""
    self.lp.solve()
    return self.model_status

solve

solve() -> HighsModelStatus

Run the solver if we're not optimal.

Source code in src/raplan/milp.py
def solve(self) -> HighsModelStatus:
    """Run the solver if we're not optimal."""

    if self.model_status != HighsModelStatus.kOptimal:
        return self.run()

    return self.model_status

LpSyncSystem dataclass

LpSyncSystem(
    builder: LpBuilder,
    uuid: UUID,
    components: list[LpSyncComponent],
    occupation: list[LpSegmentOccupation],
    combinations: LpCombinations,
)

Bases: WithBuilder

MILP description of component maintenance and synchronization for a system.

Attributes:

Name Type Description
uuid UUID

Unique identifier of the corresponding system.

components list[LpSyncComponent]

Component descriptions.

segments list[LpSyncComponent]

System segments.

synchronization list[LpSyncComponent]

Synchronization constraints for tasks that prohibit overlapping tasks without a common start.

builder instance-attribute

builder: LpBuilder

Linear programming builder this project is tied to.

info property

info: HighsInfo

Current solver info.

lp property

lp: Highs

Inner linear programming solver.

model_status property

model_status: HighsModelStatus

Current solver status.

objective property

objective: float

The current solution objective value.

solution property

solution: HighsSolution

Current solver solution.

apply_to

apply_to(system: System, new: bool = True) -> System

Apply rescheduled maintenance results to this system.

Parameters:

Name Type Description Default
system System

System to reschedule maintenance for.

required
new bool

Whether to create a new deepcopy of this system.

True

Returns:

Type Description
System

System with updated maintenance.

Source code in src/raplan/milp.py
def apply_to(self, system: System, new: bool = True) -> System:
    """Apply rescheduled maintenance results to this system.

    Arguments:
        system: System to reschedule maintenance for.
        new: Whether to create a new deepcopy of this system.

    Returns:
        System with updated maintenance.
    """
    if new:
        system = deepcopy(system)
    data: dict[UUID, float] = {k: v for k, v in self.gen_maintenance_times()}
    system.reschedule_maintenance(data=data, reason=SYNC_REASON)
    return system

build staticmethod

build(builder: LpBuilder, system: System) -> LpSyncSystem

Build a linear programming problem for a System instance.

Parameters:

Name Type Description Default
system System

System instance.

required
Source code in src/raplan/milp.py
@staticmethod
def build(builder: "LpBuilder", system: System) -> "LpSyncSystem":
    """Build a linear programming problem for a `System` instance.

    Arguments:
        system: System instance.
    """
    components: list[LpSyncComponent] = [
        LpSyncComponent.build(builder, component) for component in system.components
    ]

    # Create a mapping per segment to all variables that would lead to segment occupation.
    maintenance: dict[UUID, LpMoveableItem] = dict()
    candidates: defaultdict[int, list[tuple[UUID, highs_var]]] = defaultdict(list)
    for c in components:
        for m in c.maintenance:
            # Build the maintenance dict for later use.
            maintenance[m.uuid] = m

            # Add segment occupation.
            for segment, toggle in zip(m.window, m.segment_vars):
                for offset in range(m.duration):
                    occupation_by_segment: list[tuple[UUID, highs_var]] = candidates[
                        segment + offset
                    ]
                    occupation_by_segment.append((m.uuid, toggle))

    # Build segment descriptions with variables and constraints regarding the amount of
    # parallel execution. Can be skipped if the objective is 0 regardless.
    if builder.concurrent_objective != 0:
        occupation: list[LpSegmentOccupation] = [
            LpSegmentOccupation.build(builder, segment=segment, candidates=candidates)
            for segment, candidates in candidates.items()
        ]
    else:
        occupation: list[LpSegmentOccupation] = []

    # For the given system maintenance and segment occupation, calculate the potentially
    # overlapping pairs and constrain them such that they either start synchronously or don't
    # overlap at all.
    combinations = LpCombinations.build(
        builder,
        items=maintenance,
        candidates=candidates,
    )

    return LpSyncSystem(
        builder=builder,
        uuid=system.uuid,
        components=components,
        occupation=occupation,
        combinations=combinations,
    )

gen_maintenance_segments

gen_maintenance_segments() -> (
    Generator[tuple[UUID, int], Never, None]
)

Generate all allocated maintenance segments for this system's components.

Yields:

Type Description
tuple[UUID, int]

A tuple of each maintenance UUID and allocated segment index.

Source code in src/raplan/milp.py
def gen_maintenance_segments(self) -> Generator[tuple[UUID, int], Never, None]:
    """Generate all allocated maintenance segments for this system's components.

    Yields:
        A tuple of each maintenance UUID and allocated segment index.
    """
    for c in self.components:
        yield from c.gen_maintenance_segments()

gen_maintenance_times

gen_maintenance_times() -> (
    Generator[tuple[UUID, int | float], Never, None]
)

Generate all allocated maintenance times for this system's components.

Yields:

Type Description
tuple[UUID, int | float]

A tuple of each maintenance UUID and time.

Source code in src/raplan/milp.py
def gen_maintenance_times(self) -> Generator[tuple[UUID, int | float], Never, None]:
    """Generate all allocated maintenance times for this system's components.

    Yields:
        A tuple of each maintenance UUID and time.
    """
    for c in self.components:
        yield from c.gen_maintenance_times()

get_value

get_value(
    var: highs_var | highs_linear_expression,
) -> int | bool | float

Get a highs variable or expression value.

Source code in src/raplan/milp.py
def get_value(self, var: highs_var | highs_linear_expression) -> int | bool | float:
    """Get a highs variable or expression value."""
    value = self.lp.variableValue(var)
    return value

run

run() -> HighsModelStatus

Run the solver.

Source code in src/raplan/milp.py
def run(self) -> HighsModelStatus:
    """Run the solver."""
    self.lp.solve()
    return self.model_status

solve

solve() -> HighsModelStatus

Run the solver if we're not optimal.

Source code in src/raplan/milp.py
def solve(self) -> HighsModelStatus:
    """Run the solver if we're not optimal."""

    if self.model_status != HighsModelStatus.kOptimal:
        return self.run()

    return self.model_status

WithBuilder dataclass

WithBuilder(builder: LpBuilder)

Any class that has a reference to the builder.

builder instance-attribute

builder: LpBuilder

Linear programming builder this project is tied to.

info property

info: HighsInfo

Current solver info.

lp property

lp: Highs

Inner linear programming solver.

model_status property

model_status: HighsModelStatus

Current solver status.

objective property

objective: float

The current solution objective value.

solution property

solution: HighsSolution

Current solver solution.

get_value

get_value(
    var: highs_var | highs_linear_expression,
) -> int | bool | float

Get a highs variable or expression value.

Source code in src/raplan/milp.py
def get_value(self, var: highs_var | highs_linear_expression) -> int | bool | float:
    """Get a highs variable or expression value."""
    value = self.lp.variableValue(var)
    return value

run

run() -> HighsModelStatus

Run the solver.

Source code in src/raplan/milp.py
def run(self) -> HighsModelStatus:
    """Run the solver."""
    self.lp.solve()
    return self.model_status

solve

solve() -> HighsModelStatus

Run the solver if we're not optimal.

Source code in src/raplan/milp.py
def solve(self) -> HighsModelStatus:
    """Run the solver if we're not optimal."""

    if self.model_status != HighsModelStatus.kOptimal:
        return self.run()

    return self.model_status

duration_segments

duration_segments(
    delta_x: float, time_segments: int
) -> int

Calculate the duration as a given number of segments.

Source code in src/raplan/milp.py
def duration_segments(delta_x: float, time_segments: int) -> int:
    """Calculate the duration as a given number of segments."""
    return ceil(time_segments * delta_x)

mul_squared_num

mul_squared_num(num: int, weight=-100.0) -> float

Return n-squared times a multiplication factor.

Source code in src/raplan/milp.py
def mul_squared_num(num: int, weight=-100.0) -> float:
    """Return n-squared times a multiplication factor."""
    return num * num * weight

overlap

overlap(durations: Iterable[int | float]) -> int | float

Calculate the overlapping time for a set of durations.

I.e. the total sum minus the longest duration.

Source code in src/raplan/milp.py
def overlap(durations: Iterable[int | float]) -> int | float:
    """Calculate the overlapping time for a set of durations.

    I.e. the total sum  minus the longest duration.
    """
    upper: int | float = 0
    total: int | float = 0
    for d in durations:
        upper = max(upper, d)
        total += d
    return total - upper

shared_range

shared_range(a: range, b: range) -> range

Return the shared (overlapping) range between two ranges.

Source code in src/raplan/milp.py
def shared_range(a: range, b: range) -> range:
    """Return the shared (overlapping) range between two ranges."""
    return range(max(a.start, b.start), min(a.stop, b.stop))

time_segment

time_segment(x: float, time_segments: int) -> int

Calculate the segment a given relative time would be in w.r.t. to the horizon start as (0.0).

Parameters:

Name Type Description Default
x float

Time relative to the horizon start.

required
time_segments int

Number of segments a unit (1.0) of time should be subdivided in during discrete analyses.

required

Returns:

Type Description
int

Discrete segment corresponding to this time (rounded down).

Source code in src/raplan/milp.py
def time_segment(x: float, time_segments: int) -> int:
    """Calculate the segment a given relative time would be in w.r.t. to the horizon
    start as (0.0).

    Arguments:
        x: Time relative to the horizon start.
        time_segments: Number of segments a unit (1.0) of time should be subdivided in during
            discrete analyses.

    Returns:
        Discrete segment corresponding to this time (rounded down).
    """
    return floor(time_segments * x)

window_segments

window_segments(
    window: Window, time_segments: int
) -> range

Get the range discrete time segments representing this time window.

Parameters:

Name Type Description Default
window Window

Window to convert into a range of segments.

required
time_segments int

Number of segments a unit (1.0) of time should be subdivided in during discrete analyses.

required
Source code in src/raplan/milp.py
def window_segments(window: Window, time_segments: int) -> range:
    """Get the range discrete time segments representing this time window.

    Arguments:
        window: Window to convert into a range of segments.
        time_segments: Number of segments a unit (1.0) of time should be subdivided in during
            discrete analyses.
    """
    return range(
        time_segment(window.earliest, time_segments=time_segments),
        time_segment(window.latest, time_segments=time_segments) + 1,
    )