File size: 6,769 Bytes
50f82a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
from solverforge_legacy.solver.score import (
    constraint_provider,
    HardSoftScore,
    Joiners,
    ConstraintFactory,
    Constraint,
)

from .domain import Job

# Constraint names
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 [
        # Hard constraints
        crew_conflict(constraint_factory),
        min_start_date(constraint_factory),
        max_end_date(constraint_factory),
        # Soft constraints
        before_ideal_end_date(constraint_factory),
        after_ideal_end_date(constraint_factory),
        tag_conflict(constraint_factory),
    ]


# ************************************************************************
# Hard constraints
# ************************************************************************


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)
    )


# ************************************************************************
# Soft constraints
# ************************************************************************


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)
    )


# ************************************************************************
# Helper functions
# ************************************************************************


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

    # Calculate overlap range
    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