Skip to content

●  How to use · Scheduling

Production line sequencing

Sequence packaging jobs on production lines, accounting for product-dependent cleaning time and operators shared across lines.

What it decides

For every job, the solver picks a line and a position in that line's sequence. It also reassigns the operator of each line on the default solve.

Minimal example

from datetime import date, datetime, timedelta

from optimaru import food_packaging as fp
from optimaru.food_packaging.models import (
    JobModel, LineModel, OperatorModel, PackagingScheduleModel, ProductModel,
)

start = datetime(2026, 1, 5, 8)
minutes = timedelta(minutes=1)

def job(id, product_id, hours):
    return JobModel(id=id, name=id, product_id=product_id, duration=timedelta(hours=hours),
                    min_start_time=start, ideal_end_time=start + timedelta(hours=12),
                    max_end_time=start + timedelta(hours=24))

problem = PackagingScheduleModel(
    from_date=date(2026, 1, 5),
    to_date=date(2026, 1, 7),
    products=[
        ProductModel(id="milk", name="Milk", cleaning_durations={"milk": 0 * minutes, "juice": 30 * minutes}),
        ProductModel(id="juice", name="Juice", cleaning_durations={"juice": 0 * minutes, "milk": 45 * minutes}),
    ],
    operators=[OperatorModel(id="O1"), OperatorModel(id="O2")],
    lines=[
        LineModel(id="L1", name="Line 1", start_date_time=start, operator_id="O1"),
        LineModel(id="L2", name="Line 2", start_date_time=start, operator_id="O2"),
    ],
    jobs=[job("J1", "milk", 3), job("J2", "juice", 2), job("J3", "milk", 4), job("J4", "juice", 3)],
)

core = fp.solve(problem.to_core(), time_limit_seconds=2.0, random_seed=42)
solved = PackagingScheduleModel.from_core(core)
print(solved.score_hard, solved.score_medium, solved.score_soft)
print({line.id: (line.operator_id, line.job_ids) for line in solved.lines})
print([(j.id, j.line_id, j.start_production_date_time, j.end_date_time) for j in solved.jobs])

The same use case as a Jupyter notebook, with charts and saved outputs: examples/food-packaging.ipynb ↗

Input parameters

ProductModel

Field Meaning
id required Product id. name required Product name. cleaning_durations Cleaning time by the id of the product made just before.

OperatorModel

Field Meaning
id required Operator id.

LineModel

Field Meaning
id required Line id. name required Line name. start_date_time required When the line starts. operator_id Operator running the line.

JobModel

Field Meaning
id required Job id. name required Job name. product_id required Product made. duration required Production time. min_start_time required Earliest start, carried with the job. No constraint reads it. ideal_end_time required When the job should end. max_end_time required When the job must end by. pinned Keep the job where it is.

PackagingScheduleModel

Field Meaning
from_date required First day, inclusive. to_date required Last day, exclusive. products required Products. operators required Operators. lines required Production lines. jobs required Packaging jobs.

Solve parameters

What you pass to the solve call. Time limit, seed and parallel count are here.

Parameter Default Meaning
time_limit_seconds None Search budget in seconds, from 0 to 1e9. Left unset, it is the config's spent_limit_seconds, 30 by default.
random_seed None Seed for the search. Left unset, it is the config's value, 0 by default.
parallel_solver_count None Independent parallel starts. Each gets its own seed and the full budget, and the best result wins. Left unset, it is the config's value, 1 by default.
stop_at_first_feasible False Stop as soon as hard 0 is reached.
on_new_best None Callback run on each new best solution. If it raises, the solve stops and raises the same exception.
config None A SolverConfig as the base configuration. Keyword arguments here override it for this solve only; the object you pass is not changed.
constraints None Which constraints to score, and how. Left unset, the defaults apply.
All search options (26)

Set these on a SolverConfig and pass it as config=.

Parameter Default Meaning
best_score_limit — Stop early once the best score reaches this value. By default it never fires.
full_assert False Check every incremental score against a full recompute. Slow; for debugging.
late_acceptance_size 0 Late-acceptance history length. 0 picks it from the instance size.
list_swap_move_weight 0.25 Relative share of list swap moves. 0 disables them.
nearby_probability 0.8 Probability of drawing move partners from nearby entities. 0 disables it.
nearby_size 10 How many neighbours each entity keeps. 0 picks it automatically or disables it, depending on the use case.
on_new_best None Callback run on each new best solution. If it raises, the solve stops and raises the same exception.
operator_rounds 0 Rounds alternating search and operator reassignment. 0 picks it automatically.
or_opt_move_weight 0.07 Relative share of Or-opt moves, which relocate a short segment. 0 disables them.
parallel_solver_count 1 Independent parallel starts, each with its own seed and the full budget. The best one wins.
random_seed 0 Seed for the search.
relocate_move_weight 0.55 Relative share of relocate moves. 0 disables them.
ruin_recreate True Run adaptive ruin-and-recreate kicks when the search stagnates.
ruin_recreate_max_ruin_count 60 Absolute cap on entities removed per kick.
ruin_recreate_max_ruin_fraction 0.3 Largest share of the solution one kick removes.
ruin_recreate_min_ruin_fraction 0.1 Smallest share of the solution one kick removes.
ruin_recreate_operator_decay 0.8 How fast operator weights adapt.
ruin_recreate_polish_steps 5000 Hill-climbing moves that polish each kick. Not counted against step_count_limit.
ruin_recreate_rewards [33.0, 9.0, 3.0, 0.0] Operator rewards for a new best, a better, an accepted and a rejected result.
ruin_recreate_unimproved_step_trigger 5000 Steps without a new best before a kick.
spent_limit_seconds 30.0 Time budget in seconds, from 0 to 1e9.
step_count_limit 0 Stop after this many steps. 0 means no limit.
tail_swap_move_weight 0.05 Relative share of tail swaps (2-opt*) between two lists. 0 disables them.
two_opt_move_weight 0.08 Relative share of 2-opt moves. 0 disables them.
unimproved_spent_limit_seconds 0.0 Stop after this many seconds without a new best. 0 means no limit.
unimproved_step_count_limit 0 Stop after this many steps without a new best. 0 means no limit.

What comes back

solve takes the engine object, solves it in place and returns it. Convert with from_core to read the result.

LineModel

Field Meaning
job_ids The ordered jobs of the line. This list is the assignment. operator_id Operator after the solve.

JobModel

Field Meaning
line_id Line the job landed on. start_cleaning_date_time When cleaning starts. start_production_date_time When production starts. end_date_time When the job ends.

PackagingScheduleModel

Field Meaning
score_hard Hard score. 0 means feasible. score_medium Medium score: unassigned and late work. score_soft Soft score.

Constraints

Pick them by calling their classes from optimaru.food_packaging.constraints, such as MaxEndTimeCost(). Only what you pass is scored; pass nothing to keep the defaults. A list may name a constraint more than once, and each pick is scored on its own. Each one takes these parameters:

Parameter Default Meaning
weight None Multiplier on the constraint's cost, in units of the level in effect. Left unset, the default weight below applies. A constraint whose default weight is 0 is off until you give it a weight.
level None "hard", "medium" or "soft" moves the constraint onto that level, as do Level.HARD, Level.MEDIUM and Level.SOFT. Left unset, it keeps the level below. This use case has a medium level, and any constraint may move onto it.
exponent 1.0 Sign-preserving power on the cost, above 0, taken after offset. 2.0 squares it, so concentrated violations cost much more. It applies per unit, such as a vehicle or a day, or to the total of a constraint scored as one sum.
offset 0.0 Subtracted from the cost x before the exponent, per unit or total like the exponent. It is in the cost's own measure: seconds, minutes, a count and so on. Any finite number of either sign, keyword only. clip decides what happens below it.
clip True True makes offset a threshold: only the part beyond it counts, max(0, x - offset) ** exponent, so a cost at or below it adds nothing. False takes the literal (x - offset) ** exponent, sign kept: a unit below the offset counts negative, as a reward, even one whose cost is 0. On a level that counts toward hard, clip=False with a non-zero offset raises ValueError, since hard 0 must mean no violation. Keyword only.

A medium constraint weighs 1 in medium units by default. The search compares solutions on medium * 1e10 + soft, so medium outranks soft while the soft total stays below 1e10. With level, any constraint moves onto any of the three levels: level="medium" puts it on medium, and "hard" or "soft" takes a medium one off it. A weight counts in units of the level in effect.

This continues the minimal example above.

from optimaru.food_packaging.constraints import (
    AfterIdealEndTimeCost, MaxEndTimeCost, SquaredMakespanCost, UnassignedJobsCost,
    get_constraints,
)

picked = get_constraints([
    MaxEndTimeCost(), UnassignedJobsCost(), AfterIdealEndTimeCost(), SquaredMakespanCost(weight=2),
])
custom = fp.solve(problem.to_core(), time_limit_seconds=2.0, random_seed=42, constraints=picked)
print(custom.score)
Name Level Default weight What it does
max_end_time_cost Hard 1 Minutes a job passes max_end_time.
after_ideal_end_time_cost Medium 1 Minutes a job passes ideal_end_time.
squared_makespan_cost Soft 1 The square of each line's busy time, which pushes toward spreading jobs evenly across lines.
operator_cleaning_conflict_cost Hard 1 Overlapping cleaning minutes on lines that share an operator.
unassigned_jobs_cost Medium 1 Production minutes of jobs on no line, so dropping a long job hurts more.

How constraints work →

Starting from an existing solution

This needs the latest release: pip install -U optimaru.

with_initial_solution(problem, solution) returns a new problem whose lines carry the solution's job_ids and operator_id, replacing the ones the problem had.

This continues the minimal example above.

seeded = fp.with_initial_solution(problem, solved)  # or solved.model_dump(mode="json")
better = fp.solve(seeded.to_core(), time_limit_seconds=2.0, random_seed=42)
print(better.score)
  • A complete solution skips construction: the search starts exactly from it. A partial one keeps what it gives, and only the jobs it leaves out are constructed.
  • Pinned jobs stay as the problem has them: a line in the solution must start with its pinned jobs.
  • An unknown id, a job given twice, a moved pinned job or two neighbouring jobs with no cleaning time between their products raise ValueError.
  • On the default solve, without constraints=, seeded operators can change: improve_operator_assignment runs after the search even with no time to search, and reassigns an operator whenever that strictly cuts the cleaning conflict.
  • On the default solve, a time limit of 1 ms or less returns before construction, so left-out jobs stay unassigned. A complete solution comes back as it went in, but to have a partial one filled, give it a longer limit.
  • step_count_limit = 0 means no limit.

Exceptions and limits

  • min_start_time and priority are stored on a job but read by no constraint.
  • List every product pair that may end up next to each other in cleaning_durations, the same product included. If a pair with no entry ends up adjacent, the solve raises ValueError.
  • Operators are reassigned by a hill climb between search legs, and only on the default solve.