● How to use · Scheduling
Project scheduling (RCPSP)
Sequence project activities under precedence and shared resource limits, choosing an execution mode for each, to finish as early as possible.
What it decides
For every job, the solver picks an execution mode and a delay. Start and end dates follow from those choices and from the precedence between jobs.
Minimal example
from optimaru import project_job_scheduling as pj
from optimaru.project_job_scheduling.models import (
AllocationModel, ExecutionModeModel, JobModel, ProjectJobScheduleModel, ProjectModel,
ResourceModel, ResourceRequirementModel,
)
def needs(amount):
return [ResourceRequirementModel(resource_id="crew", requirement=amount)]
jobs = [
JobModel(id="start", project_id="P1", job_type="SOURCE", successor_job_ids=["dig", "wire"],
execution_modes=[ExecutionModeModel(id="start-0")]),
JobModel(id="dig", project_id="P1", successor_job_ids=["end"], execution_modes=[
ExecutionModeModel(id="dig-fast", duration=2, resource_requirements=needs(3)),
ExecutionModeModel(id="dig-slow", duration=4, resource_requirements=needs(1)),
]),
JobModel(id="wire", project_id="P1", successor_job_ids=["end"], execution_modes=[
ExecutionModeModel(id="wire-0", duration=3, resource_requirements=needs(2)),
]),
JobModel(id="end", project_id="P1", job_type="SINK",
execution_modes=[ExecutionModeModel(id="end-0")]),
]
problem = ProjectJobScheduleModel(
projects=[ProjectModel(id="P1", critical_path_duration=5)],
resources=[ResourceModel(id="crew", capacity=3)],
jobs=jobs,
allocations=[AllocationModel(id=f"A-{job.id}", job_id=job.id) for job in jobs],
)
result = pj.solve(problem, time_limit_seconds=2.0, random_seed=42)
print(result.score_hard, result.score_medium, result.score_soft)
print([(a.job_id, a.execution_mode_id, a.start_date, a.end_date) for a in result.allocations]) The same use case as a Jupyter notebook, with charts and saved outputs: examples/project-job-scheduling.ipynb ↗
Input parameters
ProjectModel
| Field | Meaning |
|---|
id required release_ date critical_ path_ duration release_date plus this.ResourceModel
| Field | Meaning |
|---|
id required capacity required renewable project_ id None means a global resource, which must be renewable.ExecutionModeModel
| Field | Meaning |
|---|
id required duration resource_ requirements resource_id and requirement.JobModel
| Field | Meaning |
|---|
id required project_ id required job_ type SOURCE, STANDARD or SINK.execution_ modes SOURCE and SINK use one zero-duration mode.successor_ job_ ids AllocationModel
| Field | Meaning |
|---|
id required job_ id required pinned SOURCE and SINK are always pinned.ProjectJobScheduleModel
| Field | Meaning |
|---|
projects required resources required jobs required allocations required delay_ range_ size 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. |
full_ | False | Check every incremental score against a full recompute. Slow; for debugging. |
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 (20)
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. |
change_ | 0.75 | Relative share of change moves. 0 disables them. |
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. |
on_ | None | Callback run on each new best solution. If it raises, the solve stops and raises the same exception. |
parallel_ | 1 | Independent parallel starts, each with its own seed and the full budget. The best one wins. |
random_ | 0 | Seed for the search. |
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. |
swap_ | 0.25 | Relative share of swap 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 model and returns the solved model.
AllocationModel
| Field | Meaning |
|---|
execution_ mode_ id delay start_ date end_ date ProjectJobScheduleModel
| Field | Meaning |
|---|
score_ hard score_ medium score_ soft Constraints
Pick them by calling their classes from optimaru.project_job_scheduling.constraints, such as NonRenewableResourceCapacityCost(). 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 * 1e9 + soft, so medium outranks soft while the soft total stays below 1e9. 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.project_job_scheduling.constraints import (
MakespanCost, ProjectDelayCost, RenewableResourceCapacityCost, get_constraints,
)
picked = get_constraints([RenewableResourceCapacityCost(), ProjectDelayCost(), MakespanCost(weight=2)])
custom = pj.solve(problem, time_limit_seconds=2.0, random_seed=42, constraints=picked)
print(custom.score_hard, custom.score_medium, custom.score_soft) | Name | Level | Default weight | What it does |
|---|---|---|---|
non_ | Hard | 1 | Total demand on a non-renewable resource above its capacity. |
renewable_ | Hard | 1 | Per resource and day, demand above capacity. |
project_ | Medium | 1 | Days each project's SINK passes its critical-path end. Finishing early costs nothing. |
makespan_ | Soft | 1 | Latest SINK end date, once for the whole schedule. |
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, the model a solve returned or that model as a JSON dict.
This continues the minimal example above.
seeded = pj.with_initial_solution(problem, result.model_dump(mode="json"))
again = pj.solve(seeded, time_limit_seconds=2.0, random_seed=43)
print(again.score_hard, again.score_medium, again.score_soft) - Allocations 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 allocations it leaves out are constructed.
- With
time_limit_seconds=0the solve stops at once, so a complete solution comes back as it went in. Use the time limit for this:step_count_limit = 0means no limit. - Pinned allocations, which always include
SOURCEandSINK, keep their value onproblem, whatever the solution says. ValueErroris raised for an unknown id, for a mode that is not one of the job's, for a delay without a mode and for a delay outside the delay range.
Exceptions and limits
- Give each project a
SOURCEand aSINKjob. Delay and makespan are read fromSINK; without one, those terms score nothing and no error is raised. - Allocations must be 1:1 with jobs.
release_dateis only a reference for delay. It does not hold a project back.