● 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 name required cleaning_ durations OperatorModel
| Field | Meaning |
|---|
id required LineModel
| Field | Meaning |
|---|
id required name required start_ date_ time required operator_ id JobModel
| Field | Meaning |
|---|
id required name required product_ id required duration required min_ start_ time required ideal_ end_ time required max_ end_ time required pinned PackagingScheduleModel
| Field | Meaning |
|---|
from_ date required to_ date required products required operators required lines required jobs required Solve parameters
What you pass to the solve call. Time limit, seed and parallel count are here.
| Parameter | Default | Meaning |
|---|---|---|
time_ | None | Search budget in seconds, from 0 to 1e9. Left unset, it is the config's spent_limit_seconds, 30 by default. |
random_ | None | Seed for the search. Left unset, it is the config's value, 0 by default. |
parallel_ | 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_ | False | Stop as soon as hard 0 is reached. |
on_ | 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_ | — | Stop early once the best score reaches this value. By default it never fires. |
full_ | False | Check every incremental score against a full recompute. Slow; for debugging. |
late_ | 0 | Late-acceptance history length. 0 picks it from the instance size. |
list_ | 0.25 | Relative share of list swap moves. 0 disables them. |
nearby_ | 0.8 | Probability of drawing move partners from nearby entities. 0 disables it. |
nearby_ | 10 | How many neighbours each entity keeps. 0 picks it automatically or disables it, depending on the use case. |
on_ | None | Callback run on each new best solution. If it raises, the solve stops and raises the same exception. |
operator_ | 0 | Rounds alternating search and operator reassignment. 0 picks it automatically. |
or_ | 0.07 | Relative share of Or-opt moves, which relocate a short segment. 0 disables them. |
parallel_ | 1 | Independent parallel starts, each with its own seed and the full budget. The best one wins. |
random_ | 0 | Seed for the search. |
relocate_ | 0.55 | Relative share of relocate moves. 0 disables them. |
ruin_ | True | Run adaptive ruin-and-recreate kicks when the search stagnates. |
ruin_ | 60 | Absolute cap on entities removed per kick. |
ruin_ | 0.3 | Largest share of the solution one kick removes. |
ruin_ | 0.1 | Smallest share of the solution one kick removes. |
ruin_ | 0.8 | How fast operator weights adapt. |
ruin_ | 5000 | Hill-climbing moves that polish each kick. Not counted against step_count_limit. |
ruin_ | [33.0, 9.0, 3.0, 0.0] | Operator rewards for a new best, a better, an accepted and a rejected result. |
ruin_ | 5000 | Steps without a new best before a kick. |
spent_ | 30.0 | Time budget in seconds, from 0 to 1e9. |
step_ | 0 | Stop after this many steps. 0 means no limit. |
tail_ | 0.05 | Relative share of tail swaps (2-opt*) between two lists. 0 disables them. |
two_ | 0.08 | Relative share of 2-opt moves. 0 disables them. |
unimproved_ | 0.0 | Stop after this many seconds without a new best. 0 means no limit. |
unimproved_ | 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 operator_ id JobModel
| Field | Meaning |
|---|
line_ id start_ cleaning_ date_ time start_ production_ date_ time end_ date_ time PackagingScheduleModel
| Field | Meaning |
|---|
score_ hard score_ medium score_ soft 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_ | Hard | 1 | Minutes a job passes max_end_time. |
after_ | Medium | 1 | Minutes a job passes ideal_end_time. |
squared_ | Soft | 1 | The square of each line's busy time, which pushes toward spreading jobs evenly across lines. |
operator_ | Hard | 1 | Overlapping cleaning minutes on lines that share an operator. |
unassigned_ | Medium | 1 | Production minutes of jobs on no line, so dropping a long job hurts more. |
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_assignmentruns 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 = 0means no limit.
Exceptions and limits
min_start_timeandpriorityare 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 raisesValueError. - Operators are reassigned by a hill climb between search legs, and only on the default solve.