|
|
from solverforge_legacy.solver.score import ( |
|
|
constraint_provider, |
|
|
HardSoftScore, |
|
|
Joiners, |
|
|
ConstraintFactory, |
|
|
Constraint, |
|
|
) |
|
|
|
|
|
from .domain import Job |
|
|
|
|
|
|
|
|
CREW_CONFLICT = "Crew conflict" |
|
|
MIN_START_DATE = "Min start date" |
|
|
MAX_END_DATE = "Max end date" |
|
|
BEFORE_IDEAL_END_DATE = "Before ideal end date" |
|
|
AFTER_IDEAL_END_DATE = "After ideal end date" |
|
|
TAG_CONFLICT = "Tag conflict" |
|
|
|
|
|
|
|
|
@constraint_provider |
|
|
def define_constraints(constraint_factory: ConstraintFactory): |
|
|
""" |
|
|
Defines all constraints for the maintenance scheduling problem. |
|
|
|
|
|
Args: |
|
|
constraint_factory: The constraint factory. |
|
|
|
|
|
Returns: |
|
|
List of all defined constraints. |
|
|
""" |
|
|
return [ |
|
|
|
|
|
crew_conflict(constraint_factory), |
|
|
min_start_date(constraint_factory), |
|
|
max_end_date(constraint_factory), |
|
|
|
|
|
before_ideal_end_date(constraint_factory), |
|
|
after_ideal_end_date(constraint_factory), |
|
|
tag_conflict(constraint_factory), |
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def crew_conflict(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
A crew can do at most one maintenance job at the same time. |
|
|
|
|
|
Penalizes overlapping jobs assigned to the same crew, proportional to the |
|
|
number of overlapping days. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_unique_pair( |
|
|
Job, |
|
|
Joiners.equal(lambda job: job.crew), |
|
|
Joiners.overlapping( |
|
|
lambda job: job.start_date, |
|
|
lambda job: job.get_end_date(), |
|
|
), |
|
|
) |
|
|
.filter(lambda job1, job2: job1.crew is not None) |
|
|
.penalize( |
|
|
HardSoftScore.ONE_HARD, |
|
|
lambda job1, job2: _calculate_overlap_days(job1, job2), |
|
|
) |
|
|
.as_constraint(CREW_CONFLICT) |
|
|
) |
|
|
|
|
|
|
|
|
def min_start_date(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Don't start a maintenance job before it's ready to start. |
|
|
|
|
|
Penalizes jobs that start before their minimum start date, proportional |
|
|
to the number of days early. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each(Job) |
|
|
.filter( |
|
|
lambda job: job.start_date is not None |
|
|
and job.min_start_date is not None |
|
|
and job.start_date < job.min_start_date |
|
|
) |
|
|
.penalize( |
|
|
HardSoftScore.ONE_HARD, |
|
|
lambda job: (job.min_start_date - job.start_date).days, |
|
|
) |
|
|
.as_constraint(MIN_START_DATE) |
|
|
) |
|
|
|
|
|
|
|
|
def max_end_date(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Don't end a maintenance job after it's due. |
|
|
|
|
|
Penalizes jobs that end after their maximum end date, proportional |
|
|
to the number of days late. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each(Job) |
|
|
.filter( |
|
|
lambda job: job.get_end_date() is not None |
|
|
and job.max_end_date is not None |
|
|
and job.get_end_date() > job.max_end_date |
|
|
) |
|
|
.penalize( |
|
|
HardSoftScore.ONE_HARD, |
|
|
lambda job: (job.get_end_date() - job.max_end_date).days, |
|
|
) |
|
|
.as_constraint(MAX_END_DATE) |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def before_ideal_end_date(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Early maintenance is expensive because maintenance cycles restart sooner. |
|
|
|
|
|
Penalizes jobs that finish before their ideal end date. |
|
|
Weight: 1 point per day early (lowest priority soft constraint). |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each(Job) |
|
|
.filter( |
|
|
lambda job: job.get_end_date() is not None |
|
|
and job.ideal_end_date is not None |
|
|
and job.get_end_date() < job.ideal_end_date |
|
|
) |
|
|
.penalize( |
|
|
HardSoftScore.ONE_SOFT, |
|
|
lambda job: (job.ideal_end_date - job.get_end_date()).days, |
|
|
) |
|
|
.as_constraint(BEFORE_IDEAL_END_DATE) |
|
|
) |
|
|
|
|
|
|
|
|
def after_ideal_end_date(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Late maintenance is risky because delays can push it over the due date. |
|
|
|
|
|
Penalizes jobs that finish after their ideal end date. |
|
|
Weight: 1,000,000 points per day late (high priority soft constraint). |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each(Job) |
|
|
.filter( |
|
|
lambda job: job.get_end_date() is not None |
|
|
and job.ideal_end_date is not None |
|
|
and job.get_end_date() > job.ideal_end_date |
|
|
) |
|
|
.penalize( |
|
|
HardSoftScore.of_soft(1_000_000), |
|
|
lambda job: (job.get_end_date() - job.ideal_end_date).days, |
|
|
) |
|
|
.as_constraint(AFTER_IDEAL_END_DATE) |
|
|
) |
|
|
|
|
|
|
|
|
def tag_conflict(constraint_factory: ConstraintFactory) -> Constraint: |
|
|
""" |
|
|
Avoid overlapping maintenance jobs with the same tag. |
|
|
|
|
|
For example, road maintenance in the same area should not overlap. |
|
|
Penalizes overlapping jobs that share tags, proportional to the number |
|
|
of shared tags and the overlap duration. |
|
|
Weight: 1,000 points per tag per day of overlap. |
|
|
""" |
|
|
return ( |
|
|
constraint_factory.for_each_unique_pair( |
|
|
Job, |
|
|
Joiners.overlapping( |
|
|
lambda job: job.start_date, |
|
|
lambda job: job.get_end_date(), |
|
|
), |
|
|
) |
|
|
.filter(lambda job1, job2: len(job1.get_common_tags(job2)) > 0) |
|
|
.penalize( |
|
|
HardSoftScore.of_soft(1_000), |
|
|
lambda job1, job2: len(job1.get_common_tags(job2)) |
|
|
* _calculate_overlap_days(job1, job2), |
|
|
) |
|
|
.as_constraint(TAG_CONFLICT) |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _calculate_overlap_days(job1: Job, job2: Job) -> int: |
|
|
""" |
|
|
Calculate the number of overlapping calendar days between two jobs. |
|
|
|
|
|
Args: |
|
|
job1: First job |
|
|
job2: Second job |
|
|
|
|
|
Returns: |
|
|
Number of overlapping days (calendar days, not working days) |
|
|
""" |
|
|
if job1.start_date is None or job2.start_date is None: |
|
|
return 0 |
|
|
|
|
|
end1 = job1.get_end_date() |
|
|
end2 = job2.get_end_date() |
|
|
|
|
|
if end1 is None or end2 is None: |
|
|
return 0 |
|
|
|
|
|
|
|
|
overlap_start = max(job1.start_date, job2.start_date) |
|
|
overlap_end = min(end1, end2) |
|
|
|
|
|
if overlap_start >= overlap_end: |
|
|
return 0 |
|
|
|
|
|
return (overlap_end - overlap_start).days |
|
|
|