Skip to content

●  How to use · Scheduling

Maintenance scheduling

Pick a crew and a start date for each maintenance job so that work is neither too early nor too late and crews are never double-booked.

What it decides

For every job, the solver picks a crew and a start date.

Minimal example

from datetime import date

from optimaru import maintenance_scheduling as mnt
from optimaru.maintenance_scheduling.models import CrewModel, JobModel, MaintenanceScheduleModel

problem = MaintenanceScheduleModel(
    from_date=date(2026, 1, 5),  # a Monday
    to_date=date(2026, 1, 31),
    crews=[CrewModel(id="C1", name="Crew 1"), CrewModel(id="C2", name="Crew 2")],
    jobs=[
        JobModel(id="J1", name="Pump overhaul", duration_in_days=3, tags=["north"],
                 min_start_date=date(2026, 1, 5), max_end_date=date(2026, 1, 24),
                 ideal_end_date=date(2026, 1, 14)),
        JobModel(id="J2", name="Valve check", duration_in_days=2, tags=["north"],
                 min_start_date=date(2026, 1, 5), max_end_date=date(2026, 1, 24),
                 ideal_end_date=date(2026, 1, 14)),
        JobModel(id="J3", name="Line inspection", duration_in_days=4,
                 min_start_date=date(2026, 1, 12), max_end_date=date(2026, 1, 31),
                 ideal_end_date=date(2026, 1, 21)),
    ],
)

core = problem.to_core()
mnt.solve(core, time_limit_seconds=2.0, random_seed=42)
solved = MaintenanceScheduleModel.from_core(core)
print(solved.score_hard, solved.score_soft)
print([(j.id, j.crew_id, j.start_date, j.end_date) for j in solved.jobs])

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

Input parameters

CrewModel

Field Meaning
id required Crew id. name required Crew name.

JobModel

Field Meaning
id required Job id. name required Job name. duration_in_days required Working days the job takes. Weekends do not count. min_start_date Earliest start, inclusive. max_end_date End deadline, exclusive. ideal_end_date Date the job should end, exclusive. tags Jobs that share a tag should not overlap. pinned Keep the job's crew and start date.

MaintenanceScheduleModel

Field Meaning
from_date required First day of the calendar, inclusive. to_date required End of the calendar, exclusive. crews required Crews. jobs required 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 (23)

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.
change_move_weight 0.75 Relative share of change moves. 0 disables them.
free_crew_candidates 4 Random crews a guided change move tries, looking for one free for the job's span. 0 disables it.
full_assert False Check every incremental score against a full recompute. Slow; for debugging.
hot_job_probability 0.5 Probability that a move starts from a job inside a tag conflict. 0 disables it.
late_acceptance_size 0 Late-acceptance history length. 0 picks it from the instance size.
on_new_best None Callback run on each new best solution. If it raises, the solve stops and raises the same exception.
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.
ruin_recreate True Run adaptive ruin-and-recreate kicks when the search stagnates.
ruin_recreate_max_ruin_count 24 Absolute cap on entities removed per kick.
ruin_recreate_max_ruin_fraction 0.6 Largest share of the solution one kick removes.
ruin_recreate_min_ruin_fraction 0.2 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.
swap_move_weight 0.25 Relative share of swap 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.
window_value_probability 1.0 Probability that a change move draws its date from the job's own window. 0 disables it.

What comes back

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

JobModel

Field Meaning
crew_id Assigned crew. start_date Assigned start date. end_date Computed end, exclusive, skipping weekends.

MaintenanceScheduleModel

Field Meaning
score_hard Hard score. 0 means feasible. score_soft Soft score.

Constraints

Pick them by calling their classes from optimaru.maintenance_scheduling.constraints, such as MinStartDateCost(). 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" makes the constraint a must and "soft" a preference, as do Level.HARD and Level.SOFT. Left unset, it keeps the level below. This use case has no medium level, so "medium" raises ValueError. A reward refuses "hard", and a fixed level refuses any change; both raise ValueError.
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.

This continues the minimal example above.

from optimaru.maintenance_scheduling.constraints import (
    CrewConflictCost, MaxEndDateCost, MinStartDateCost, TagConflictCost, get_constraints,
)

picked = get_constraints([
    CrewConflictCost(), MinStartDateCost(), MaxEndDateCost(), TagConflictCost(weight=5000),
])
custom = problem.to_core()
mnt.solve(custom, time_limit_seconds=2.0, random_seed=42, constraints=picked)
print(custom.score)
Name Level Default weight What it does
min_start_date_cost Hard 1 Calendar days a job starts before min_start_date.
max_end_date_cost Hard 1 Calendar days a job ends after max_end_date.
before_ideal_end_date_cost Soft 1 Calendar days a job ends before ideal_end_date. The cheapest soft term.
after_ideal_end_date_cost Soft 1e6 Calendar days a job ends after ideal_end_date. The heaviest soft term.
crew_conflict_cost Hard 1 Calendar days two jobs of the same crew overlap.
tag_conflict_cost Soft 1000 Calendar days two jobs sharing a tag overlap, counted per shared tag.

How constraints work →

Starting from an existing solution

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

with_initial_solution(problem, solution) returns a copy of problem whose search starts from solution, a solved MaintenanceScheduleModel or that model as a JSON dict. solve takes the engine object, so convert the result with to_core().

This continues the minimal example above.

restart = mnt.with_initial_solution(problem, solved).to_core()
mnt.solve(restart, time_limit_seconds=2.0, random_seed=43)
print(restart.score)
  • Jobs are matched by id.
  • 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.
  • With time_limit_seconds=0 the solve stops at once, so a complete solution comes back as it went in. Use the time limit for this: step_count_limit = 0 means no limit.
  • Pinned jobs keep their crew and start date on problem, whatever the solution says.
  • ValueError is raised for an unknown id, for a job with only one of crew_id and start_date, and for a start date that is not a weekday inside the work calendar.

Exceptions and limits

  • Jobs start on weekdays only, inside from_date to to_date, and durations skip weekends.
  • A job uses one crew. A crew does at most one job per day.