flight-alloc 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flight_alloc-0.0.1.dist-info/METADATA +11 -0
- flight_alloc-0.0.1.dist-info/RECORD +71 -0
- flight_alloc-0.0.1.dist-info/WHEEL +5 -0
- flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
- flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/allocator/__init__.py +6 -0
- src/allocator/caps.py +513 -0
- src/allocator/eligibility.py +302 -0
- src/allocator/greedy_fallback.py +106 -0
- src/allocator/invariants.py +124 -0
- src/allocator/p2f_priority.py +142 -0
- src/allocator/pair_validation.py +108 -0
- src/allocator/pairings.py +554 -0
- src/allocator/postpass_break.py +366 -0
- src/allocator/postpass_intl.py +483 -0
- src/allocator/postpass_p2f.py +723 -0
- src/allocator/postpass_rebalance.py +244 -0
- src/allocator/postsolve.py +549 -0
- src/allocator/recommender.py +348 -0
- src/allocator/windows.py +377 -0
- src/cli.py +41 -0
- src/config.py +168 -0
- src/greedy_fallback.py +102 -0
- src/io/__init__.py +0 -0
- src/io/export.py +270 -0
- src/io/export_xml.py +66 -0
- src/io/readers.py +1048 -0
- src/io/roster_library.py +89 -0
- src/plan.py +192 -0
- src/recommender_staffing.py +329 -0
- src/roster_store.py +159 -0
- src/schemas.py +1244 -0
- src/solver/__init__.py +0 -0
- src/solver/allocator_cpsat.py +1412 -0
- src/staged_overrides.py +468 -0
- src/state.py +494 -0
- src/step1_clean_flights.py +286 -0
- src/step2_extract_roster.py +316 -0
- src/step3_allocate_flights.py +1639 -0
- src/web/__init__.py +47 -0
- src/web/__main__.py +9 -0
- src/web/api/__init__.py +56 -0
- src/web/api/export.py +37 -0
- src/web/api/inputs.py +122 -0
- src/web/api/override_rows.py +138 -0
- src/web/api/pages.py +30 -0
- src/web/api/readbacks.py +72 -0
- src/web/api/recommender.py +72 -0
- src/web/api/runs.py +102 -0
- src/web/api/settings.py +201 -0
- src/web/api/zc.py +117 -0
- src/web/core/__init__.py +5 -0
- src/web/core/responses.py +91 -0
- src/web/core/router.py +167 -0
- src/web/core/static_files.py +85 -0
- src/web/overrides/__init__.py +66 -0
- src/web/overrides/airports.py +261 -0
- src/web/overrides/break_time.py +83 -0
- src/web/overrides/config_yaml.py +21 -0
- src/web/overrides/filters.py +187 -0
- src/web/overrides/rows.py +110 -0
- src/web/readback/__init__.py +67 -0
- src/web/readback/common.py +68 -0
- src/web/readback/dashboard.py +83 -0
- src/web/readback/planning.py +335 -0
- src/web/readback/session.py +158 -0
- src/web/readback/tables.py +163 -0
- src/web/runner.py +168 -0
- src/web/server.py +185 -0
- src/zc_store.py +221 -0
|
@@ -0,0 +1,1412 @@
|
|
|
1
|
+
"""CP-SAT model for Step 4 — flight allocation.
|
|
2
|
+
|
|
3
|
+
Hard constraints (always enforced):
|
|
4
|
+
H1 exactly one staff per flight — Σ x[f, e] = 1
|
|
5
|
+
H10 15-min spacing per staff — optional IntervalVar +
|
|
6
|
+
AddNoOverlap
|
|
7
|
+
H16 per-staff hard caps — Σ_f x[f, e] ≤ cap[e]
|
|
8
|
+
|
|
9
|
+
Soft objectives (opt-in via flags, added one at a time across E6.X):
|
|
10
|
+
S1 asymmetric per-staff count balance — penalty around the preferred
|
|
11
|
+
target: below = 100/short,
|
|
12
|
+
+1 above = 50, +2 above = 200,
|
|
13
|
+
+3 above = 800 (most groups
|
|
14
|
+
cap at +2 per H16 anyway)
|
|
15
|
+
S3 heavy-flight spread — added in E6.2
|
|
16
|
+
S4 pairing stability tie-breaker — added in E6.3
|
|
17
|
+
|
|
18
|
+
Hard constraints NOT encoded here (handled elsewhere):
|
|
19
|
+
H2-H5, H9, H11-H13, H15 — eligibility matrix (E3) shapes the
|
|
20
|
+
decision variable space; only legal
|
|
21
|
+
(flight, staff) pairs become x vars
|
|
22
|
+
H6, H7, H8 — pair generator (E4) + post-solve assembly
|
|
23
|
+
(E7); the solver never sees pairs
|
|
24
|
+
H14 — Step 3's responsibility (24-hr rule)
|
|
25
|
+
|
|
26
|
+
Decision variables: ``x[(flight_id, employee_id)] ∈ {0, 1}``, sparse
|
|
27
|
+
over the eligibility matrix. With ~2000 flights × ~95 staff but only
|
|
28
|
+
~15-25 eligible staff per flight, the variable count is ~40-60k rather
|
|
29
|
+
than ~190k. CP-SAT handled this comfortably under a 60s time budget on
|
|
30
|
+
the original sample day per D5; the equity objective added since (S1b
|
|
31
|
+
bucket-spread) is considerably harder to prove optimal on a full
|
|
32
|
+
production day, which is why ``configs/config.yml`` budgets up to 700s.
|
|
33
|
+
|
|
34
|
+
2026-09-26 (speed pass, no change to what counts as an acceptable
|
|
35
|
+
answer): every cold solve — first Plan/Allocate of the day, with no
|
|
36
|
+
prior-run ``solution_hints`` — is now (a) seeded with a cheap greedy
|
|
37
|
+
warm start (see the "Heuristic warm start" block below) so CP-SAT
|
|
38
|
+
begins from a real feasible point instead of nothing, and (b) run with
|
|
39
|
+
``num_search_workers`` explicitly set (see ``_resolve_num_workers``)
|
|
40
|
+
instead of left at the Python binding's default. Both are pure
|
|
41
|
+
wall-clock levers: same ``max_seconds``, same 0%-gap requirement.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import os
|
|
47
|
+
from collections import defaultdict
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from datetime import date as date_t
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from ortools.sat.python import cp_model
|
|
53
|
+
|
|
54
|
+
from ..allocator.caps import hard_cap_for, preferred_target_for
|
|
55
|
+
from ..allocator.eligibility import EligibilityContext
|
|
56
|
+
from ..allocator.greedy_fallback import greedy_allocate
|
|
57
|
+
from ..allocator.p2f_priority import select_p2f_priority
|
|
58
|
+
from ..allocator.windows import (
|
|
59
|
+
HANDOVER_WINDOW_MIN,
|
|
60
|
+
SHIFT_NOMINAL_MIN,
|
|
61
|
+
SPACING_MAX_MIN,
|
|
62
|
+
ZC_BUFFER_END_MIN,
|
|
63
|
+
ZC_BUFFER_START_MIN,
|
|
64
|
+
awkward_eligible_shifts,
|
|
65
|
+
is_in_rush_band,
|
|
66
|
+
required_spacing_min,
|
|
67
|
+
shift_boundary_within_30min,
|
|
68
|
+
spacing_key,
|
|
69
|
+
std_to_ops_day_minutes,
|
|
70
|
+
)
|
|
71
|
+
from ..schemas import FlightInput, OpsClass, Role, StaffMember
|
|
72
|
+
|
|
73
|
+
# S1 — symmetric distance-to-day-level (Finding 1, 2026-05-15).
|
|
74
|
+
# The prior asymmetric ladder (below × 100, +1 × 50, +2 × 550, +3 × 2050)
|
|
75
|
+
# pulled the solver toward a static "preferred" (typically 22). The
|
|
76
|
+
# manual allocator instead picks ONE level per day from flight volume
|
|
77
|
+
# and pins almost every non-handler staff to it. This single-weight
|
|
78
|
+
# symmetric form makes the solver behave the same way.
|
|
79
|
+
#
|
|
80
|
+
# Weight 200 is between the old +1-above (50) and +2-above (550) —
|
|
81
|
+
# strong enough to dominate the small tiebreakers (S3 × 5, S4 × 1,
|
|
82
|
+
# S5/S6 × 10, S7 × 15) but well below S_OUTER (5000) and the
|
|
83
|
+
# unallocated-penalty (100k).
|
|
84
|
+
S1_WEIGHT_DAY_LEVEL_DISTANCE = 200
|
|
85
|
+
# 2026-05-24: escalating below-target boosts. Above-target stays flat
|
|
86
|
+
# at 200 (don't punish people for being slightly over preferred load),
|
|
87
|
+
# but going way below target gets sharply more expensive so the
|
|
88
|
+
# solver fights harder to redistribute load to under-utilised staff.
|
|
89
|
+
# Triggered by the user observing 2-3 STAFF at 14-15 flights while
|
|
90
|
+
# others were at 24. Tier thresholds picked so 0-2 below stays cheap
|
|
91
|
+
# (small variance OK), 3-5 below moderate, 6+ below heavy.
|
|
92
|
+
# below_dev = max(0, level - actual)
|
|
93
|
+
# below_tier2 = max(0, below_dev - 2) # 3rd, 4th, 5th below
|
|
94
|
+
# below_tier3 = max(0, below_dev - 5) # 6th and beyond
|
|
95
|
+
S1_WEIGHT_BELOW_TIER2_BOOST = 300
|
|
96
|
+
S1_WEIGHT_BELOW_TIER3_BOOST = 500
|
|
97
|
+
# Legacy ladder kept as commented constants for reference / revert.
|
|
98
|
+
# S1_WEIGHT_BELOW_PER_FLIGHT = 100
|
|
99
|
+
# S1_WEIGHT_AT_LEAST_1_ABOVE = 50
|
|
100
|
+
# S1_WEIGHT_AT_LEAST_2_ABOVE = 500
|
|
101
|
+
# S1_WEIGHT_AT_LEAST_3_ABOVE = 1500
|
|
102
|
+
|
|
103
|
+
# S1b per-bucket spread minimization (added 2026-05-10; bumped 2026-05-28).
|
|
104
|
+
# For each (shift, role) bucket of non-handler staff, add soft penalty on
|
|
105
|
+
# (bucket_max - bucket_min). Weight bumped 400 -> 600 (2026-05-28) after
|
|
106
|
+
# real-world solves showed -19 / -7 / -5 outliers in otherwise healthy
|
|
107
|
+
# shifts: the solver was choosing to leave under-loaded staff rather than
|
|
108
|
+
# pay the S1b cost to equalize. Higher weight makes equalization more
|
|
109
|
+
# aggressive per-second of solve time.
|
|
110
|
+
# Example: spread=1 costs +600, spread=2 costs +1200, spread=3 costs +1800.
|
|
111
|
+
# Dominates S1 marginal costs (e.g. 22+24 = 50+550 = 600 + spread=2 1200 = 1800
|
|
112
|
+
# vs 23+23 = 50+50 = 100 + spread=0 = 100 — 18x preference for tight).
|
|
113
|
+
S1B_WEIGHT_BUCKET_SPREAD = 600
|
|
114
|
+
# 2026-09-23 fix: escalating boost once the same-bucket spread exceeds
|
|
115
|
+
# 1 flight. The base S1B_WEIGHT_BUCKET_SPREAD alone let a 3-flight gap
|
|
116
|
+
# (e.g. 20 vs 23 on the same shift/role) survive whenever it was
|
|
117
|
+
# merely the CHEAPEST tie-break rather than something actually forced
|
|
118
|
+
# by H10 spacing / eligibility — 600/unit isn't always enough to beat
|
|
119
|
+
# out other soft terms once a few flights' worth of slack exists.
|
|
120
|
+
# This tier only bites on the spread beyond 1, so it costs nothing
|
|
121
|
+
# extra for the spread=0/1 cases the base term already prefers, and it
|
|
122
|
+
# stays SOFT (never a hard cap) so a bucket that genuinely can't be
|
|
123
|
+
# tightened further (real spacing/eligibility limits) still solves
|
|
124
|
+
# instead of going INFEASIBLE.
|
|
125
|
+
S1B_WEIGHT_SPREAD_TIER2 = 1_000
|
|
126
|
+
|
|
127
|
+
# S3 heavy-flight spread (prompt §soft_objectives + REF_Constraints).
|
|
128
|
+
# A flight with PAX >= S3_HEAVY_THRESHOLD counts as "heavy". The
|
|
129
|
+
# objective penalizes the maximum heavy-count across all staff, so
|
|
130
|
+
# piling heavies on one person costs more than distributing them.
|
|
131
|
+
# Weight 5 is mild — it should sway ties only, never override S1.
|
|
132
|
+
S3_HEAVY_THRESHOLD_PAX = 200
|
|
133
|
+
S3_WEIGHT_MAX_HEAVY = 5
|
|
134
|
+
|
|
135
|
+
# S4 pairing stability tie-breaker (briefing + REF_Constraints).
|
|
136
|
+
# Adds a tiny per-(flight, staff) cost proportional to the staff's
|
|
137
|
+
# position in the input list. Caller passes ``staff`` in IN_Staff row
|
|
138
|
+
# order; lower-row staff get lower S4 cost, so the solver prefers them
|
|
139
|
+
# in ties — making solutions deterministic without affecting balance
|
|
140
|
+
# (weight 1, dwarfed by S1 at 50+ and S3 at 5).
|
|
141
|
+
S4_WEIGHT_PER_ROW = 1
|
|
142
|
+
|
|
143
|
+
# S5 handover-window distribution preference (round-3 update).
|
|
144
|
+
# In the (nominal_end, nominal_end + HANDOVER_WINDOW_MIN] overlap window,
|
|
145
|
+
# both prev and next shift are eligible. S5 tilts the solver: assigning
|
|
146
|
+
# such a flight to the next shift costs S5_WEIGHT_PER_HANDOVER_FLIGHT
|
|
147
|
+
# more than the prev shift, so the prev shift absorbs it unless S1
|
|
148
|
+
# count balance prefers otherwise. Weight 10 sits between S3 (5) and
|
|
149
|
+
# S1 (50+) — actively biases distribution but never overrides count.
|
|
150
|
+
S5_WEIGHT_HANDOVER_NEXT_SHIFT = 10
|
|
151
|
+
|
|
152
|
+
# S6 awkward-window routing preference (round-3 update).
|
|
153
|
+
# Replaces H15 hard routing. For each awkward STD, the awkward_eligible
|
|
154
|
+
# table names ONE preferred shift; assigning to that shift costs 0,
|
|
155
|
+
# assigning to any other shift on duty during the window costs
|
|
156
|
+
# S6_WEIGHT_AWKWARD_NON_PREFERRED. Weight 10 is mild — moves ties.
|
|
157
|
+
S6_WEIGHT_AWKWARD_NON_PREFERRED = 10
|
|
158
|
+
|
|
159
|
+
# S7 ZC report buffer avoidance (round-3 update).
|
|
160
|
+
# Replaces H9 hard exclusion. ZC assignments inside their report
|
|
161
|
+
# buffer cost S7_WEIGHT_ZC_BUFFER per flight; non-buffer ZC slots
|
|
162
|
+
# are free. Weight 15 is higher than S6/S5 because the buffers exist
|
|
163
|
+
# for a real workflow reason (report writing), but lower than S1
|
|
164
|
+
# count-balance — the solver may push ZCs into buffers if their
|
|
165
|
+
# count target can't otherwise be met.
|
|
166
|
+
S7_WEIGHT_ZC_BUFFER = 15
|
|
167
|
+
|
|
168
|
+
# S_outer A/A1 outer-band penalty (user direction 2026-05-12, §1).
|
|
169
|
+
# A staff's STD window has an inner band (hard) and an outer band
|
|
170
|
+
# (soft). Outer-band assignments are allowed only when no inner
|
|
171
|
+
# placement is feasible. Weight is high enough to dominate S1+S1b
|
|
172
|
+
# but low enough that an unallocated-flight penalty (100k) still
|
|
173
|
+
# wins — i.e., better to use the outer band than leave a flight
|
|
174
|
+
# unallocated.
|
|
175
|
+
S_OUTER_BAND_PENALTY = 5_000
|
|
176
|
+
|
|
177
|
+
# ZC workload floor (2026-09-22 fix, second pass).
|
|
178
|
+
# H18 used to enforce band["min"] (e.g. N/ZC = 14) as a HARD per-staff
|
|
179
|
+
# floor. That's fine when the day's flight supply can support it, but
|
|
180
|
+
# a static config number has no idea whether it's actually achievable
|
|
181
|
+
# — a zero-night-ops day (or any day where H10 spacing eats into the
|
|
182
|
+
# raw eligible count) can make band_lo mathematically impossible,
|
|
183
|
+
# which drags the ENTIRE model to INFEASIBLE, not just that bucket.
|
|
184
|
+
# This happened in production 2026-09-22 (Night ops = 0, N/ZC floor
|
|
185
|
+
# forced to 14 -> 2117/2117 flights unallocated).
|
|
186
|
+
#
|
|
187
|
+
# Fix: make it a SOFT shortfall penalty instead of a hard constraint.
|
|
188
|
+
# Weight is high — just under S_OUTER — so the solver still fights
|
|
189
|
+
# hard to reach the floor whenever it's reachable, but a genuinely
|
|
190
|
+
# unreachable floor costs points instead of blowing up the solve.
|
|
191
|
+
ZC_FLOOR_SHORTFALL_WEIGHT = 4_000
|
|
192
|
+
|
|
193
|
+
# P2F priority (2026-09-22, user direction): a P2F flight is worth 10x an
|
|
194
|
+
# ordinary flight when it is left unallocated, so whenever a handler's
|
|
195
|
+
# cap / spacing forces a choice, the normal flight is the one that moves
|
|
196
|
+
# to someone else. Normal flights stay at 100_000 (``unassigned_penalty``
|
|
197
|
+
# in ``solve_allocation``). P2F flights that ``select_p2f_priority`` can
|
|
198
|
+
# reserve are fixed outright; this weight covers the rest.
|
|
199
|
+
P2F_UNASSIGNED_PENALTY = 1_000_000
|
|
200
|
+
|
|
201
|
+
# Phase 4 (2026-05-14, INTL overhaul, Change 5) — INTL spacing policy.
|
|
202
|
+
# Hard floor: same-handler INTL DEP→INTL DEP pairs with gap < 15 min
|
|
203
|
+
# are forbidden. The constant below is the SPEC anchor; enforcement
|
|
204
|
+
# is via H10 (SPACING_HARD_MIN=15) which already forbids any same-
|
|
205
|
+
# staff flight pair within 15 min — the INTL case is a subset.
|
|
206
|
+
H_INTL_SPACING_HARD_MIN = 15
|
|
207
|
+
|
|
208
|
+
# Soft penalty for same-handler INTL DEP pairs in the 15-30 min band:
|
|
209
|
+
# - heavy band [15, 20] min → S_INTL_SPACING_HEAVY (extra buffer needed)
|
|
210
|
+
# - light band [21, 29] min → S_INTL_SPACING_LIGHT (prefer ≥30 min)
|
|
211
|
+
# Sized between S1 marginals (50, 550) and S_outer (5000): heavy at 800
|
|
212
|
+
# is comparable to S1's +2-above marginal; light at 100 is a tie-breaker.
|
|
213
|
+
S_INTL_SPACING_HEAVY = 800
|
|
214
|
+
S_INTL_SPACING_LIGHT = 100
|
|
215
|
+
S_INTL_SPACING_PENALTY = S_INTL_SPACING_HEAVY # alias for the spec test
|
|
216
|
+
|
|
217
|
+
# Spacing policy applies to ALL shifts (amendment 2026-05-14 afternoon:
|
|
218
|
+
# N is no longer exempt — the issue is most acute on Night).
|
|
219
|
+
S_INTL_SPACING_SHIFTS: frozenset[str] = frozenset({"M", "M1", "A", "A1", "N"})
|
|
220
|
+
|
|
221
|
+
# Phase 4 / Change 6 — fair INTL distribution within each shift.
|
|
222
|
+
# Penalize the (max - min) of INTL count per handler within each shift
|
|
223
|
+
# bucket. Weight is modest (mild tiebreaker, dominated by S1 + spacing)
|
|
224
|
+
# so the solver only spreads when it can do so cheaply.
|
|
225
|
+
S_INTL_FAIR_PENALTY = 50
|
|
226
|
+
S_INTL_FAIR_SHIFTS: frozenset[str] = frozenset({"M", "M1", "A", "A1", "N"})
|
|
227
|
+
|
|
228
|
+
# Patch 2026-05-15 — band-targeted "prefer stable shift" preference.
|
|
229
|
+
# Within the rush bands, the solver should prefer staff whose
|
|
230
|
+
# shift is NOT in transition (nominal start or end within ±30 min of
|
|
231
|
+
# the flight's STD). Implemented as a soft penalty: each (flight,
|
|
232
|
+
# staff) pair where the flight is in a rush band AND the staff's
|
|
233
|
+
# shift has a nearby boundary pays this cost.
|
|
234
|
+
#
|
|
235
|
+
# Weight 200 sits above S1-marginal (+1 above = 50, +2 above = 500
|
|
236
|
+
# marginal) — strong enough to bias picks but won't dominate the
|
|
237
|
+
# 100k UNASSIGNED penalty. Dominated by S_OUTER (5000) so a soft
|
|
238
|
+
# outer-band assignment isn't traded against shift stability.
|
|
239
|
+
S_BAND_SHIFT_STABILITY_PENALTY = 200
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True)
|
|
243
|
+
class AllocationSolverResult:
|
|
244
|
+
"""Output of solve_allocation_hard_only."""
|
|
245
|
+
|
|
246
|
+
status: str
|
|
247
|
+
"""One of OPTIMAL | FEASIBLE | INFEASIBLE | TIMEOUT | MODEL_INVALID."""
|
|
248
|
+
|
|
249
|
+
assignments: dict[str, str]
|
|
250
|
+
"""flight_id -> employee_id. Empty when status is INFEASIBLE / TIMEOUT
|
|
251
|
+
without any feasible solution found."""
|
|
252
|
+
|
|
253
|
+
wall_clock_seconds: float
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# Status codes are CpSolverStatus enum values at runtime; we accept Any
|
|
257
|
+
# for the dict key type because the ortools stubs don't expose
|
|
258
|
+
# CpSolverStatus as a public type alias.
|
|
259
|
+
_STATUS_MAP: dict[Any, str] = {
|
|
260
|
+
cp_model.OPTIMAL: "OPTIMAL",
|
|
261
|
+
cp_model.FEASIBLE: "FEASIBLE",
|
|
262
|
+
cp_model.INFEASIBLE: "INFEASIBLE",
|
|
263
|
+
cp_model.MODEL_INVALID: "MODEL_INVALID",
|
|
264
|
+
cp_model.UNKNOWN: "TIMEOUT",
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _build_s1_term(
|
|
269
|
+
model: cp_model.CpModel,
|
|
270
|
+
flights: list[FlightInput],
|
|
271
|
+
staff: list[StaffMember],
|
|
272
|
+
x: dict[tuple[str, str], Any],
|
|
273
|
+
day_level_by_bucket: dict[tuple[str, Role], int] | None = None,
|
|
274
|
+
) -> Any:
|
|
275
|
+
"""S1 — per-staff distance from the day's workload level.
|
|
276
|
+
|
|
277
|
+
2026-05-24 rewrite: ASYMMETRIC with escalating below-target boosts.
|
|
278
|
+
|
|
279
|
+
Above target stays flat at S1_WEIGHT_DAY_LEVEL_DISTANCE = 200 per
|
|
280
|
+
unit (don't penalise minor over-utilisation). Below target uses
|
|
281
|
+
the same base 200 plus two tier boosts that kick in for deeper
|
|
282
|
+
deficits, so the solver fights harder against the "2-3 STAFF
|
|
283
|
+
stuck at 14 while others are at 24" pattern that the prior
|
|
284
|
+
symmetric formula tolerated.
|
|
285
|
+
|
|
286
|
+
Per-staff penalty:
|
|
287
|
+
|
|
288
|
+
above_dev = max(0, actual - level)
|
|
289
|
+
below_dev = max(0, level - actual)
|
|
290
|
+
below_tier2 = max(0, below_dev - 2) # 3rd, 4th, 5th below
|
|
291
|
+
below_tier3 = max(0, below_dev - 5) # 6th and beyond
|
|
292
|
+
penalty_e = 200·above_dev
|
|
293
|
+
+ 200·below_dev
|
|
294
|
+
+ 300·below_tier2
|
|
295
|
+
+ 500·below_tier3
|
|
296
|
+
|
|
297
|
+
Cost gradient at the deep-deficit end is ~5× the old symmetric
|
|
298
|
+
weight, with no change for 0-2 below or any above-target case.
|
|
299
|
+
|
|
300
|
+
Fallback: when ``day_level_by_bucket`` is None or doesn't contain
|
|
301
|
+
a bucket, that staff is excluded from S1 (cost 0). Avoids breaking
|
|
302
|
+
callers that don't pass the new arg yet.
|
|
303
|
+
"""
|
|
304
|
+
if not day_level_by_bucket:
|
|
305
|
+
return 0
|
|
306
|
+
terms: list[Any] = []
|
|
307
|
+
for s in staff:
|
|
308
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
309
|
+
continue
|
|
310
|
+
my_vars = [
|
|
311
|
+
x[(f.unique_id, s.employee_id)]
|
|
312
|
+
for f in flights if (f.unique_id, s.employee_id) in x
|
|
313
|
+
]
|
|
314
|
+
if not my_vars:
|
|
315
|
+
continue
|
|
316
|
+
level = day_level_by_bucket.get((s.shift_today, s.role))
|
|
317
|
+
if level is None:
|
|
318
|
+
continue
|
|
319
|
+
actual_expr = sum(my_vars)
|
|
320
|
+
# above_dev = max(0, actual - level)
|
|
321
|
+
# below_dev = max(0, level - actual)
|
|
322
|
+
# Lower bound 0 is implicit (NonNegativeIntegerVar).
|
|
323
|
+
above_dev = model.new_int_var(0, 30, f"s1_above_{s.employee_id}")
|
|
324
|
+
below_dev = model.new_int_var(0, 30, f"s1_below_{s.employee_id}")
|
|
325
|
+
model.add(above_dev >= actual_expr - level)
|
|
326
|
+
model.add(below_dev >= level - actual_expr)
|
|
327
|
+
# Tier boosts that kick in only when below_dev exceeds the
|
|
328
|
+
# threshold. The lower-bound = 0 on the int_var combined with
|
|
329
|
+
# the inequality gives max(0, below_dev - k).
|
|
330
|
+
below_tier2 = model.new_int_var(0, 30, f"s1_btier2_{s.employee_id}")
|
|
331
|
+
model.add(below_tier2 >= below_dev - 2)
|
|
332
|
+
below_tier3 = model.new_int_var(0, 30, f"s1_btier3_{s.employee_id}")
|
|
333
|
+
model.add(below_tier3 >= below_dev - 5)
|
|
334
|
+
terms.append(
|
|
335
|
+
S1_WEIGHT_DAY_LEVEL_DISTANCE * above_dev
|
|
336
|
+
+ S1_WEIGHT_DAY_LEVEL_DISTANCE * below_dev
|
|
337
|
+
+ S1_WEIGHT_BELOW_TIER2_BOOST * below_tier2
|
|
338
|
+
+ S1_WEIGHT_BELOW_TIER3_BOOST * below_tier3
|
|
339
|
+
)
|
|
340
|
+
return sum(terms) if terms else 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _build_s3_term(
|
|
344
|
+
model: cp_model.CpModel,
|
|
345
|
+
flights: list[FlightInput],
|
|
346
|
+
staff: list[StaffMember],
|
|
347
|
+
x: dict[tuple[str, str], Any],
|
|
348
|
+
) -> Any:
|
|
349
|
+
"""S3 heavy-flight spread. Penalize the MAX heavy-count across
|
|
350
|
+
staff, so piling all PAX≥200 flights on one person is more
|
|
351
|
+
expensive than spreading them. Weight is mild (5) — sways ties
|
|
352
|
+
only.
|
|
353
|
+
"""
|
|
354
|
+
heavy_flights = [f for f in flights if f.load >= S3_HEAVY_THRESHOLD_PAX]
|
|
355
|
+
if not heavy_flights:
|
|
356
|
+
return 0
|
|
357
|
+
max_heavy = model.new_int_var(0, len(heavy_flights), "s3_max_heavy")
|
|
358
|
+
for s in staff:
|
|
359
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
360
|
+
continue
|
|
361
|
+
my_heavies = [
|
|
362
|
+
x[(f.unique_id, s.employee_id)] for f in heavy_flights
|
|
363
|
+
if (f.unique_id, s.employee_id) in x
|
|
364
|
+
]
|
|
365
|
+
if my_heavies:
|
|
366
|
+
model.add(max_heavy >= sum(my_heavies))
|
|
367
|
+
return S3_WEIGHT_MAX_HEAVY * max_heavy
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _build_s4_term(
|
|
371
|
+
flights: list[FlightInput],
|
|
372
|
+
staff: list[StaffMember],
|
|
373
|
+
x: dict[tuple[str, str], Any],
|
|
374
|
+
) -> Any:
|
|
375
|
+
"""S4 pairing stability — make solutions deterministic by adding
|
|
376
|
+
a tiny per-assignment cost proportional to the staff's position
|
|
377
|
+
in the (row-ordered) input list. Solver prefers lower-row staff
|
|
378
|
+
in ties; weight 1 ensures S4 never overrides S1 or S3.
|
|
379
|
+
|
|
380
|
+
Caller is responsible for passing ``staff`` in IN_Staff row order.
|
|
381
|
+
"""
|
|
382
|
+
terms: list[Any] = []
|
|
383
|
+
for idx, s in enumerate(staff):
|
|
384
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
385
|
+
continue
|
|
386
|
+
my_vars = [
|
|
387
|
+
x[(f.unique_id, s.employee_id)] for f in flights
|
|
388
|
+
if (f.unique_id, s.employee_id) in x
|
|
389
|
+
]
|
|
390
|
+
if my_vars and idx > 0:
|
|
391
|
+
terms.append(S4_WEIGHT_PER_ROW * idx * sum(my_vars))
|
|
392
|
+
return sum(terms) if terms else 0
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _build_s5_handover_term(
|
|
396
|
+
flights: list[FlightInput],
|
|
397
|
+
staff: list[StaffMember],
|
|
398
|
+
x: dict[tuple[str, str], Any],
|
|
399
|
+
ops_day: date_t,
|
|
400
|
+
) -> Any:
|
|
401
|
+
"""S5 handover-window distribution preference (round-3).
|
|
402
|
+
|
|
403
|
+
For each flight whose STD falls in the (nominal_end, nominal_end +
|
|
404
|
+
HANDOVER_WINDOW_MIN] handover overlap of some shift S, assigning to
|
|
405
|
+
a NEXT-shift staff (e.g., A handler when STD is in M's handover) is
|
|
406
|
+
penalized; assigning to the prev shift (M handler) is free. This
|
|
407
|
+
creates a default of "handover stays with prev shift unless S1
|
|
408
|
+
count balance prefers otherwise."
|
|
409
|
+
"""
|
|
410
|
+
terms: list[Any] = []
|
|
411
|
+
for f in flights:
|
|
412
|
+
std_min = std_to_ops_day_minutes(f.std, f.date, ops_day)
|
|
413
|
+
# Identify all shifts whose handover-overlap window contains
|
|
414
|
+
# this STD. (A flight in the M→A handover overlap is in BOTH M's
|
|
415
|
+
# tail AND A's nominal — we want to penalize the A side.)
|
|
416
|
+
for shift, (_, nominal_end) in SHIFT_NOMINAL_MIN.items():
|
|
417
|
+
if nominal_end < std_min <= nominal_end + HANDOVER_WINDOW_MIN:
|
|
418
|
+
# `shift` is the prev shift. Penalize assignments to any
|
|
419
|
+
# staff whose shift is NOT `shift` — they're the "next
|
|
420
|
+
# shift" for this STD.
|
|
421
|
+
for s in staff:
|
|
422
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
423
|
+
continue
|
|
424
|
+
if s.shift_today == shift:
|
|
425
|
+
continue
|
|
426
|
+
if (f.unique_id, s.employee_id) in x:
|
|
427
|
+
terms.append(
|
|
428
|
+
S5_WEIGHT_HANDOVER_NEXT_SHIFT
|
|
429
|
+
* x[(f.unique_id, s.employee_id)]
|
|
430
|
+
)
|
|
431
|
+
return sum(terms) if terms else 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _build_s6_awkward_term(
|
|
435
|
+
flights: list[FlightInput],
|
|
436
|
+
staff: list[StaffMember],
|
|
437
|
+
x: dict[tuple[str, str], Any],
|
|
438
|
+
ops_day: date_t,
|
|
439
|
+
) -> Any:
|
|
440
|
+
"""S6 awkward-window routing preference (round-3).
|
|
441
|
+
|
|
442
|
+
Replaces H15 hard routing. For each flight in an awkward window,
|
|
443
|
+
assignments to non-preferred shifts cost extra. The "preferred" shift
|
|
444
|
+
is the first entry in the awkward_eligible_shifts tuple.
|
|
445
|
+
"""
|
|
446
|
+
terms: list[Any] = []
|
|
447
|
+
for f in flights:
|
|
448
|
+
awk = (
|
|
449
|
+
f.awkward_eligible_shifts if f.is_awkward_window else
|
|
450
|
+
awkward_eligible_shifts(f.std, f.date, ops_day)
|
|
451
|
+
)
|
|
452
|
+
if not awk:
|
|
453
|
+
continue
|
|
454
|
+
preferred_shift = awk[0]
|
|
455
|
+
for s in staff:
|
|
456
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
457
|
+
continue
|
|
458
|
+
if s.shift_today == preferred_shift:
|
|
459
|
+
continue
|
|
460
|
+
if (f.unique_id, s.employee_id) in x:
|
|
461
|
+
terms.append(
|
|
462
|
+
S6_WEIGHT_AWKWARD_NON_PREFERRED * x[(f.unique_id, s.employee_id)]
|
|
463
|
+
)
|
|
464
|
+
return sum(terms) if terms else 0
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _build_s7_zc_buffer_term(
|
|
468
|
+
flights: list[FlightInput],
|
|
469
|
+
staff: list[StaffMember],
|
|
470
|
+
x: dict[tuple[str, str], Any],
|
|
471
|
+
ops_day: date_t,
|
|
472
|
+
) -> Any:
|
|
473
|
+
"""S7 ZC report-buffer avoidance (round-3).
|
|
474
|
+
|
|
475
|
+
Replaces H9 hard exclusion. ZC assignments inside their report
|
|
476
|
+
buffers cost S7 per flight; non-buffer ZC assignments are free.
|
|
477
|
+
"""
|
|
478
|
+
terms: list[Any] = []
|
|
479
|
+
for s in staff:
|
|
480
|
+
if s.role != Role.ZC or s.shift_today is None:
|
|
481
|
+
continue
|
|
482
|
+
b1_start, b1_end = ZC_BUFFER_START_MIN[s.shift_today]
|
|
483
|
+
b2_start, b2_end = ZC_BUFFER_END_MIN[s.shift_today]
|
|
484
|
+
for f in flights:
|
|
485
|
+
if (f.unique_id, s.employee_id) not in x:
|
|
486
|
+
continue
|
|
487
|
+
std_min = std_to_ops_day_minutes(f.std, f.date, ops_day)
|
|
488
|
+
in_buffer = (
|
|
489
|
+
(b1_start <= std_min < b1_end)
|
|
490
|
+
or (b2_start <= std_min < b2_end)
|
|
491
|
+
)
|
|
492
|
+
if in_buffer:
|
|
493
|
+
terms.append(
|
|
494
|
+
S7_WEIGHT_ZC_BUFFER * x[(f.unique_id, s.employee_id)]
|
|
495
|
+
)
|
|
496
|
+
return sum(terms) if terms else 0
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _build_outer_band_term(
|
|
500
|
+
flights: list[FlightInput],
|
|
501
|
+
staff: list[StaffMember],
|
|
502
|
+
x: dict[tuple[str, str], Any],
|
|
503
|
+
ops_day: date_t,
|
|
504
|
+
) -> Any:
|
|
505
|
+
"""S_outer A/A1 outer-band penalty (user 2026-05-12, §1).
|
|
506
|
+
|
|
507
|
+
For every (flight, staff) pair where the flight's STD lies in the
|
|
508
|
+
staff's shift's OUTER band (allowed but penalized), pay
|
|
509
|
+
``S_OUTER_BAND_PENALTY`` per assignment. Inner-band assignments are
|
|
510
|
+
free. Only A and A1 have outer bands; M / M1 / N return False from
|
|
511
|
+
``is_in_std_outer_band`` so they contribute zero terms.
|
|
512
|
+
"""
|
|
513
|
+
from ..allocator.windows import is_in_std_outer_band
|
|
514
|
+
terms: list[Any] = []
|
|
515
|
+
for s in staff:
|
|
516
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
517
|
+
continue
|
|
518
|
+
if s.shift_today not in ("A", "A1"):
|
|
519
|
+
continue
|
|
520
|
+
for f in flights:
|
|
521
|
+
if (f.unique_id, s.employee_id) not in x:
|
|
522
|
+
continue
|
|
523
|
+
if is_in_std_outer_band(f.std, f.date, ops_day, s.shift_today):
|
|
524
|
+
terms.append(
|
|
525
|
+
S_OUTER_BAND_PENALTY * x[(f.unique_id, s.employee_id)]
|
|
526
|
+
)
|
|
527
|
+
return sum(terms) if terms else 0
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _build_s_intl_spacing_term(
|
|
531
|
+
model: cp_model.CpModel,
|
|
532
|
+
flights: list[FlightInput],
|
|
533
|
+
staff: list[StaffMember],
|
|
534
|
+
x: dict[tuple[str, str], Any],
|
|
535
|
+
eligibility: dict[str, set[str]],
|
|
536
|
+
ops_day: date_t,
|
|
537
|
+
) -> Any:
|
|
538
|
+
"""Phase 4 / Change 5 — soft penalty for same-handler INTL DEP pairs
|
|
539
|
+
in the 15-30 min spacing band.
|
|
540
|
+
|
|
541
|
+
Mechanics:
|
|
542
|
+
For every pair (i, j) of INTL DEP flights with 15 ≤ |std_i - std_j|
|
|
543
|
+
< 30 min and every staff e eligible for BOTH, introduce a boolean
|
|
544
|
+
``both_e_ij`` that equals AND(x[i,e], x[j,e]). Add
|
|
545
|
+
``weight * both_e_ij`` to the returned objective term.
|
|
546
|
+
|
|
547
|
+
``weight`` is ``S_INTL_SPACING_HEAVY`` when gap ∈ [15, 20]
|
|
548
|
+
(the "tight" band), else ``S_INTL_SPACING_LIGHT``.
|
|
549
|
+
|
|
550
|
+
Gaps < 15 are already forbidden by H10 (SPACING_HARD_MIN).
|
|
551
|
+
Gaps ≥ 30 are the preferred state and receive no penalty.
|
|
552
|
+
Staff on shifts outside ``S_INTL_SPACING_SHIFTS`` are skipped — though
|
|
553
|
+
today the set covers every shift, so this is a no-op filter.
|
|
554
|
+
"""
|
|
555
|
+
intl_flights = [f for f in flights if f.is_international]
|
|
556
|
+
if len(intl_flights) < 2:
|
|
557
|
+
return 0
|
|
558
|
+
intl_flights.sort(
|
|
559
|
+
key=lambda f: std_to_ops_day_minutes(f.std, f.date, ops_day)
|
|
560
|
+
)
|
|
561
|
+
staff_by_id = {s.employee_id: s for s in staff}
|
|
562
|
+
terms: list[Any] = []
|
|
563
|
+
n = len(intl_flights)
|
|
564
|
+
for i in range(n):
|
|
565
|
+
fi = intl_flights[i]
|
|
566
|
+
std_i = std_to_ops_day_minutes(fi.std, fi.date, ops_day)
|
|
567
|
+
elig_i = eligibility.get(fi.unique_id, set())
|
|
568
|
+
for j in range(i + 1, n):
|
|
569
|
+
fj = intl_flights[j]
|
|
570
|
+
std_j = std_to_ops_day_minutes(fj.std, fj.date, ops_day)
|
|
571
|
+
gap = std_j - std_i
|
|
572
|
+
if gap < H_INTL_SPACING_HARD_MIN:
|
|
573
|
+
continue # H10 forbids; no soft term needed
|
|
574
|
+
if gap >= 30:
|
|
575
|
+
break # subsequent j's are even further out — done
|
|
576
|
+
weight = (
|
|
577
|
+
S_INTL_SPACING_HEAVY if gap <= 20 else S_INTL_SPACING_LIGHT
|
|
578
|
+
)
|
|
579
|
+
elig_j = eligibility.get(fj.unique_id, set())
|
|
580
|
+
common = elig_i & elig_j
|
|
581
|
+
for eid in common:
|
|
582
|
+
s = staff_by_id.get(eid)
|
|
583
|
+
if s is None or s.shift_today not in S_INTL_SPACING_SHIFTS:
|
|
584
|
+
continue
|
|
585
|
+
xi = x.get((fi.unique_id, eid))
|
|
586
|
+
xj = x.get((fj.unique_id, eid))
|
|
587
|
+
if xi is None or xj is None:
|
|
588
|
+
continue
|
|
589
|
+
both = model.new_bool_var(
|
|
590
|
+
f"intl_both_{fi.unique_id}_{fj.unique_id}_{eid}"
|
|
591
|
+
)
|
|
592
|
+
# both == xi AND xj
|
|
593
|
+
model.add(both <= xi)
|
|
594
|
+
model.add(both <= xj)
|
|
595
|
+
model.add(both >= xi + xj - 1)
|
|
596
|
+
terms.append(weight * both)
|
|
597
|
+
if not terms:
|
|
598
|
+
return None
|
|
599
|
+
return sum(terms)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _build_s_intl_fair_term(
|
|
603
|
+
model: cp_model.CpModel,
|
|
604
|
+
flights: list[FlightInput],
|
|
605
|
+
staff: list[StaffMember],
|
|
606
|
+
x: dict[tuple[str, str], Any],
|
|
607
|
+
) -> Any:
|
|
608
|
+
"""Phase 4 / Change 6 — soft penalty on INTL DEP distribution variance
|
|
609
|
+
within each shift.
|
|
610
|
+
|
|
611
|
+
For each shift in ``S_INTL_FAIR_SHIFTS``, compute ``intl_max - intl_min``
|
|
612
|
+
across all non-handler staff on that shift; weight by
|
|
613
|
+
``S_INTL_FAIR_PENALTY`` and add to the objective.
|
|
614
|
+
|
|
615
|
+
Uses the same max/min-IntVar pattern as the H18 spread mechanism — cheap
|
|
616
|
+
O(shifts) constraints, no quadratic blowup.
|
|
617
|
+
"""
|
|
618
|
+
intl_flight_ids = {f.unique_id for f in flights if f.is_international}
|
|
619
|
+
if not intl_flight_ids:
|
|
620
|
+
return 0
|
|
621
|
+
# Bucket staff by shift (skip AM, skip off-day, skip handlers' bucket
|
|
622
|
+
# mixing — handlers are kept in their own bucket to avoid pulling the
|
|
623
|
+
# min down with a P2F handler who only does INTL incidentally).
|
|
624
|
+
bucket_by_shift: dict[str, list[StaffMember]] = defaultdict(list)
|
|
625
|
+
for s in staff:
|
|
626
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
627
|
+
continue
|
|
628
|
+
if s.shift_today not in S_INTL_FAIR_SHIFTS:
|
|
629
|
+
continue
|
|
630
|
+
bucket_by_shift[s.shift_today].append(s)
|
|
631
|
+
|
|
632
|
+
terms: list[Any] = []
|
|
633
|
+
for shift_code, bucket in bucket_by_shift.items():
|
|
634
|
+
if len(bucket) < 2:
|
|
635
|
+
continue
|
|
636
|
+
# intl count per staff in this shift; range [0, len(intl)].
|
|
637
|
+
upper = len(intl_flight_ids)
|
|
638
|
+
intl_min = model.new_int_var(
|
|
639
|
+
0, upper, f"intl_min_{shift_code}"
|
|
640
|
+
)
|
|
641
|
+
intl_max = model.new_int_var(
|
|
642
|
+
0, upper, f"intl_max_{shift_code}"
|
|
643
|
+
)
|
|
644
|
+
n_pinned = 0
|
|
645
|
+
for s in bucket:
|
|
646
|
+
intl_vars = [
|
|
647
|
+
x[(fid, s.employee_id)] for fid in intl_flight_ids
|
|
648
|
+
if (fid, s.employee_id) in x
|
|
649
|
+
]
|
|
650
|
+
if not intl_vars:
|
|
651
|
+
continue
|
|
652
|
+
actual = sum(intl_vars)
|
|
653
|
+
model.add(actual >= intl_min)
|
|
654
|
+
model.add(actual <= intl_max)
|
|
655
|
+
n_pinned += 1
|
|
656
|
+
if n_pinned >= 2:
|
|
657
|
+
terms.append(S_INTL_FAIR_PENALTY * (intl_max - intl_min))
|
|
658
|
+
if not terms:
|
|
659
|
+
return None
|
|
660
|
+
return sum(terms)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _build_s_band_shift_stability_term(
|
|
664
|
+
flights: list[FlightInput],
|
|
665
|
+
staff: list[StaffMember],
|
|
666
|
+
x: dict[tuple[str, str], Any],
|
|
667
|
+
ops_day: date_t,
|
|
668
|
+
) -> Any:
|
|
669
|
+
"""Patch 2026-05-15 — band-targeted "prefer stable shift" penalty.
|
|
670
|
+
|
|
671
|
+
For every (flight, staff) pair where:
|
|
672
|
+
* the flight's STD lies in a rush band
|
|
673
|
+
(``windows.RUSH_BANDS_MIN``), AND
|
|
674
|
+
* the staff's shift has its nominal start OR end within ±30 min
|
|
675
|
+
of the flight's STD,
|
|
676
|
+
add a soft penalty of ``S_BAND_SHIFT_STABILITY_PENALTY``.
|
|
677
|
+
|
|
678
|
+
Effect: during rush bands, the
|
|
679
|
+
solver prefers handlers whose shift is mid-stride — not just
|
|
680
|
+
starting or ending. Staff in handover are still eligible, just
|
|
681
|
+
chosen second.
|
|
682
|
+
|
|
683
|
+
Pure tiebreaker — weight 200 is well below the unallocated cost
|
|
684
|
+
(100 000) and the outer-band penalty (5 000), so the solver
|
|
685
|
+
won't leave a flight unallocated to avoid the boundary case.
|
|
686
|
+
"""
|
|
687
|
+
terms: list[Any] = []
|
|
688
|
+
for f in flights:
|
|
689
|
+
if not is_in_rush_band(f.std, f.date, ops_day):
|
|
690
|
+
continue
|
|
691
|
+
for s in staff:
|
|
692
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
693
|
+
continue
|
|
694
|
+
if (f.unique_id, s.employee_id) not in x:
|
|
695
|
+
continue
|
|
696
|
+
if shift_boundary_within_30min(
|
|
697
|
+
s.shift_today, f.std, f.date, ops_day,
|
|
698
|
+
):
|
|
699
|
+
terms.append(
|
|
700
|
+
S_BAND_SHIFT_STABILITY_PENALTY
|
|
701
|
+
* x[(f.unique_id, s.employee_id)]
|
|
702
|
+
)
|
|
703
|
+
if not terms:
|
|
704
|
+
return None
|
|
705
|
+
return sum(terms)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _resolve_num_workers(configured: int = 0) -> int:
|
|
709
|
+
"""CPU cores to hand CP-SAT for parallel portfolio search.
|
|
710
|
+
|
|
711
|
+
Speed-only lever — does not change acceptance criteria. Left unset,
|
|
712
|
+
CP-SAT's Python bindings do not reliably use every core on every
|
|
713
|
+
platform/version, so we set this explicitly rather than rely on a
|
|
714
|
+
default. ``configured`` (from ``configs/config.yml``'s
|
|
715
|
+
``solver.num_workers``) overrides auto-detection when > 0; otherwise
|
|
716
|
+
we use every available core, capped at 8 — OR-Tools' own guidance is
|
|
717
|
+
that CP-SAT's portfolio search gets most of its benefit from ~8
|
|
718
|
+
diverse workers, with more mostly costing RAM rather than buying
|
|
719
|
+
time on problems this size.
|
|
720
|
+
"""
|
|
721
|
+
if configured and configured > 0:
|
|
722
|
+
return configured
|
|
723
|
+
cores = os.cpu_count() or 4
|
|
724
|
+
return max(1, min(cores, 8))
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def solve_allocation(
|
|
728
|
+
flights: list[FlightInput],
|
|
729
|
+
staff: list[StaffMember],
|
|
730
|
+
eligibility: dict[str, set[str]],
|
|
731
|
+
*,
|
|
732
|
+
ops_day: date_t,
|
|
733
|
+
max_seconds: int = 60,
|
|
734
|
+
num_workers: int = 0,
|
|
735
|
+
enable_s1_count_balance: bool = False,
|
|
736
|
+
enable_s3_heavy_spread: bool = False,
|
|
737
|
+
enable_s4_pair_stability: bool = False,
|
|
738
|
+
enable_s5_handover_preference: bool = False,
|
|
739
|
+
enable_s6_awkward_preference: bool = False,
|
|
740
|
+
enable_s7_zc_buffer_avoidance: bool = False,
|
|
741
|
+
enable_s_intl_spacing: bool = False,
|
|
742
|
+
enable_s_intl_fair: bool = False,
|
|
743
|
+
enable_s_band_shift_stability: bool = False,
|
|
744
|
+
elig_ctx: EligibilityContext | None = None,
|
|
745
|
+
solution_hints: dict[str, str] | None = None,
|
|
746
|
+
waive_h10_triples: frozenset[tuple[str, str, str]] = frozenset(),
|
|
747
|
+
raise_cap_uids: frozenset[tuple[str, str]] = frozenset(),
|
|
748
|
+
day_level_by_bucket: dict[tuple[str, Role], int] | None = None,
|
|
749
|
+
pinned_assignments: dict[str, str] | None = None,
|
|
750
|
+
hard_bucket_spread_max: int | None = None,
|
|
751
|
+
) -> AllocationSolverResult:
|
|
752
|
+
"""Solve the allocation problem.
|
|
753
|
+
|
|
754
|
+
With all soft-objective flags False (default), the solver returns
|
|
755
|
+
the first feasible assignment that satisfies H1, H10, H16, and (if
|
|
756
|
+
``elig_ctx`` provides P2F handler info) H17 and the P2F-first
|
|
757
|
+
reservations. With soft flags enabled, it minimizes the chosen
|
|
758
|
+
weighted sum. The H4 P2F buffer is already in ``eligibility``.
|
|
759
|
+
|
|
760
|
+
Pass ``elig_ctx`` (the same one used to build the eligibility
|
|
761
|
+
matrix) so the solver knows the P2F handlers.
|
|
762
|
+
|
|
763
|
+
``hard_bucket_spread_max``, if set (2026-09-23), turns the H18
|
|
764
|
+
same-(shift, role) spread into a HARD ceiling: no bucket may have
|
|
765
|
+
``max(actual) - min(actual)`` exceed this value, on top of the
|
|
766
|
+
existing soft S1b penalty. This is a genuine "nobody gets left more
|
|
767
|
+
than N behind their least-loaded peer" guarantee — but because
|
|
768
|
+
CP-SAT solves the whole day as ONE combined model, a single bucket
|
|
769
|
+
that truly cannot be tightened this far (real H10 spacing /
|
|
770
|
+
eligibility limits) makes the ENTIRE day's model INFEASIBLE, not
|
|
771
|
+
just that bucket. Callers that want the guarantee should retry
|
|
772
|
+
without it (soft-only) on an INFEASIBLE status rather than falling
|
|
773
|
+
straight through to the greedy fallback, which drops every soft
|
|
774
|
+
objective for the whole day. Left ``None`` (default), spread stays
|
|
775
|
+
soft-only, as before.
|
|
776
|
+
|
|
777
|
+
Caller is responsible for:
|
|
778
|
+
- filtering flights to today's ops scope (D + N tail to 05:00 D+1)
|
|
779
|
+
- filtering staff to assignable today (off-day staff dropped)
|
|
780
|
+
- building the eligibility matrix via allocator/eligibility.py
|
|
781
|
+
- aborting with W201 if any flight has empty eligibility (this
|
|
782
|
+
function will return INFEASIBLE in that case as a safety net)
|
|
783
|
+
"""
|
|
784
|
+
model = cp_model.CpModel()
|
|
785
|
+
staff_by_id = {s.employee_id: s for s in staff}
|
|
786
|
+
|
|
787
|
+
# ---- Decision variables + per-staff flight lists ----
|
|
788
|
+
x: dict[tuple[str, str], cp_model.IntVar] = {}
|
|
789
|
+
# Phase R: switched from intervals_by_staff (used with add_no_overlap)
|
|
790
|
+
# to flights_by_staff (used to build per-pair H10 constraints below).
|
|
791
|
+
# The pairwise formulation lets us skip exactly the pairs the operator
|
|
792
|
+
# waived via an override of type=waive_h10_pair, without compromising H10
|
|
793
|
+
# against the staff's other flights.
|
|
794
|
+
flights_by_staff: dict[str, list[FlightInput]] = defaultdict(list)
|
|
795
|
+
flight_by_uid: dict[str, FlightInput] = {f.unique_id: f for f in flights}
|
|
796
|
+
|
|
797
|
+
for flight in flights:
|
|
798
|
+
eligible_ids = eligibility.get(flight.unique_id, set())
|
|
799
|
+
for emp_id in eligible_ids:
|
|
800
|
+
if emp_id not in staff_by_id:
|
|
801
|
+
# Defensive: eligibility names a staff we don't have.
|
|
802
|
+
# Skip rather than crash — caller's bug, surface as
|
|
803
|
+
# missing eligibility downstream.
|
|
804
|
+
continue
|
|
805
|
+
v = model.new_bool_var(f"x_{flight.unique_id}_{emp_id}")
|
|
806
|
+
x[(flight.unique_id, emp_id)] = v
|
|
807
|
+
flights_by_staff[emp_id].append(flight)
|
|
808
|
+
|
|
809
|
+
# ---- H1 (relaxed): at most one staff per flight ----
|
|
810
|
+
# Hard constraint: a flight cannot be assigned to two staff.
|
|
811
|
+
# Soft preference: every flight SHOULD be assigned, but if aggregate
|
|
812
|
+
# capacity is short, some flights are left unallocated rather than
|
|
813
|
+
# the whole problem becoming infeasible. The objective rewards
|
|
814
|
+
# assignment via _UNASSIGNED_PENALTY below.
|
|
815
|
+
unassigned_indicators: list[Any] = []
|
|
816
|
+
p2f_unassigned_indicators: list[Any] = []
|
|
817
|
+
for flight in flights:
|
|
818
|
+
eligible_ids = eligibility.get(flight.unique_id, set())
|
|
819
|
+
present = [
|
|
820
|
+
x[(flight.unique_id, eid)] for eid in eligible_ids
|
|
821
|
+
if (flight.unique_id, eid) in x
|
|
822
|
+
]
|
|
823
|
+
if not present:
|
|
824
|
+
# Treat as unconditionally unassigned (matrix had no eligible
|
|
825
|
+
# staff). The orchestrator should already have logged W201;
|
|
826
|
+
# we just need a sentinel so the count is right.
|
|
827
|
+
const_one = model.new_constant(1)
|
|
828
|
+
unassigned_indicators.append(const_one)
|
|
829
|
+
else:
|
|
830
|
+
model.add(sum(present) <= 1)
|
|
831
|
+
# 1 - sum(present) is 1 when the flight is unassigned,
|
|
832
|
+
# 0 otherwise. We sum these into the objective.
|
|
833
|
+
ind = model.new_bool_var(f"unassigned_{flight.unique_id}")
|
|
834
|
+
model.add(ind == 1 - sum(present))
|
|
835
|
+
# P2F flights carry a heavier penalty (see
|
|
836
|
+
# P2F_UNASSIGNED_PENALTY) so they win any tie against a
|
|
837
|
+
# normal flight for the handler's cap / spacing.
|
|
838
|
+
if flight.ops_class == OpsClass.P2F:
|
|
839
|
+
p2f_unassigned_indicators.append(ind)
|
|
840
|
+
else:
|
|
841
|
+
unassigned_indicators.append(ind)
|
|
842
|
+
|
|
843
|
+
# ---- H10: per-staff same-flight spacing, waiver-aware ----
|
|
844
|
+
# Replaces the prior model.add_no_overlap formulation. For every pair
|
|
845
|
+
# of flights eligible to the same staff with gap < required spacing
|
|
846
|
+
# (15 min, or 30 for domestic→INTL and for two P2F flights), add an
|
|
847
|
+
# at-most-one constraint UNLESS the operator waived this pair via an
|
|
848
|
+
# override of type=waive_h10_pair.
|
|
849
|
+
n_h10_pairs = 0
|
|
850
|
+
n_h10_waived = 0
|
|
851
|
+
for emp_id, emp_flights in flights_by_staff.items():
|
|
852
|
+
if len(emp_flights) < 2:
|
|
853
|
+
continue
|
|
854
|
+
# Sort by STD so we iterate close-in-time pairs first.
|
|
855
|
+
with_key = sorted(
|
|
856
|
+
((spacing_key(f, ops_day), f) for f in emp_flights),
|
|
857
|
+
key=lambda t: t[0][0],
|
|
858
|
+
)
|
|
859
|
+
for i in range(len(with_key)):
|
|
860
|
+
key_i, fi = with_key[i]
|
|
861
|
+
for j in range(i + 1, len(with_key)):
|
|
862
|
+
key_j, fj = with_key[j]
|
|
863
|
+
gap = key_j[0] - key_i[0]
|
|
864
|
+
if gap >= SPACING_MAX_MIN:
|
|
865
|
+
# Pairs from here on are even further apart (sorted).
|
|
866
|
+
break
|
|
867
|
+
if gap >= required_spacing_min(key_i, key_j):
|
|
868
|
+
continue
|
|
869
|
+
# Phase R waiver check. Order-insensitive — both
|
|
870
|
+
# (uid_a, uid_b, emp) and (uid_b, uid_a, emp) accepted.
|
|
871
|
+
if (
|
|
872
|
+
(fi.unique_id, fj.unique_id, emp_id) in waive_h10_triples
|
|
873
|
+
or (fj.unique_id, fi.unique_id, emp_id) in waive_h10_triples
|
|
874
|
+
):
|
|
875
|
+
n_h10_waived += 1
|
|
876
|
+
continue
|
|
877
|
+
model.add(
|
|
878
|
+
x[(fi.unique_id, emp_id)] + x[(fj.unique_id, emp_id)] <= 1
|
|
879
|
+
)
|
|
880
|
+
n_h10_pairs += 1
|
|
881
|
+
if waive_h10_triples:
|
|
882
|
+
print(
|
|
883
|
+
f" H10 pairwise: {n_h10_pairs} forbidden, "
|
|
884
|
+
f"{n_h10_waived} waived via override"
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
from ..schemas import OpsClass as _OpsClass
|
|
888
|
+
|
|
889
|
+
# ---- H18: tight workload spread per shift/role bucket ----
|
|
890
|
+
# Per user direction 2026-05-10: people on the same shift+role
|
|
891
|
+
# should get the same number of flights "as much as possible".
|
|
892
|
+
# We add a HARD constraint: within each (shift, role) bucket,
|
|
893
|
+
# max(actual) - min(actual) <= 2. P2F handlers are
|
|
894
|
+
# EXCLUDED — their workload mix differs (handler flights count
|
|
895
|
+
# differently per the cap rules) and they'd otherwise drag the
|
|
896
|
+
# min down and force everyone else under their target.
|
|
897
|
+
excluded_handlers: set[str] = set()
|
|
898
|
+
if elig_ctx is not None:
|
|
899
|
+
excluded_handlers = set(elig_ctx.p2f_handler_by_shift.values())
|
|
900
|
+
spread_groups: dict[tuple[str, Role], list[StaffMember]] = defaultdict(list)
|
|
901
|
+
for s in staff:
|
|
902
|
+
if s.shift_today is None or s.role == Role.AM:
|
|
903
|
+
continue
|
|
904
|
+
if s.employee_id in excluded_handlers:
|
|
905
|
+
continue
|
|
906
|
+
spread_groups[(s.shift_today, s.role)].append(s)
|
|
907
|
+
# Per (shift, role) bucket, pin every assigned staff's count to
|
|
908
|
+
# [target - 1, target + 1] so spread is at most 2. Direct integer
|
|
909
|
+
# bounds — no IntVar middlemen — so the constraint is unambiguous.
|
|
910
|
+
# Plus a soft spread-min term (bucket_max - bucket_min) to push the
|
|
911
|
+
# solver toward spread=1 or 0 within that hard range.
|
|
912
|
+
# Handlers excluded (their workload mix is different).
|
|
913
|
+
#
|
|
914
|
+
# 2026-05-18 (sick re-solve): when ``pinned_assignments`` is
|
|
915
|
+
# active, BYPASS H18 entirely. The pin already locks every staff
|
|
916
|
+
# to their prior count; H18 bounds (recomputed against a possibly
|
|
917
|
+
# different day_level after the sick removal) routinely conflict
|
|
918
|
+
# with the pins and make the model infeasible. Pin is the source
|
|
919
|
+
# of truth in this mode.
|
|
920
|
+
n_h18_buckets = 0
|
|
921
|
+
spread_obj_terms: list[Any] = []
|
|
922
|
+
skip_h18 = bool(pinned_assignments)
|
|
923
|
+
if skip_h18:
|
|
924
|
+
print(
|
|
925
|
+
f" H18 bypassed: pinned-assignments mode (sick re-solve) — "
|
|
926
|
+
f"pins are the bucket bounds"
|
|
927
|
+
)
|
|
928
|
+
for (shift_code, role), bucket in spread_groups.items():
|
|
929
|
+
if skip_h18:
|
|
930
|
+
continue
|
|
931
|
+
if len(bucket) < 2:
|
|
932
|
+
# 2026-05-16 (user direction): single-staff buckets — most
|
|
933
|
+
# commonly a lone ZC on a shift (e.g. KULDEEP SINGH at N/ZC)
|
|
934
|
+
# — were previously unconstrained. Without H18, the soft
|
|
935
|
+
# S1 penalty alone was too weak to pull them toward target
|
|
936
|
+
# and they ended at 5/11. Now: still no bucket-spread
|
|
937
|
+
# (nothing to spread over), but we DO add a hard
|
|
938
|
+
# actual >= band.min on the staff. Only for ZC role —
|
|
939
|
+
# STAFF singletons rarely happen and have larger pools
|
|
940
|
+
# to draw from. CP-SAT will report INFEASIBLE if the lone
|
|
941
|
+
# ZC genuinely doesn't have band.min eligible flights;
|
|
942
|
+
# that's the right loud-failure signal.
|
|
943
|
+
if role == Role.ZC and bucket:
|
|
944
|
+
from ..allocator.caps import get_band
|
|
945
|
+
band = get_band(shift_code, role)
|
|
946
|
+
if band is not None:
|
|
947
|
+
s = bucket[0]
|
|
948
|
+
my_vars = [
|
|
949
|
+
x[(f.unique_id, s.employee_id)] for f in flights
|
|
950
|
+
if (f.unique_id, s.employee_id) in x
|
|
951
|
+
]
|
|
952
|
+
if my_vars:
|
|
953
|
+
# 2026-09-22 fix (2nd pass): SOFT floor, not
|
|
954
|
+
# hard. A hard `model.add(sum >= band["min"])`
|
|
955
|
+
# can make the WHOLE model INFEASIBLE whenever
|
|
956
|
+
# the day's supply (or H10 spacing exclusions
|
|
957
|
+
# among ``my_vars``) can't actually reach
|
|
958
|
+
# band["min"] — a raw eligible-flight count
|
|
959
|
+
# isn't proof that count is simultaneously
|
|
960
|
+
# selectable. Shortfall costs points instead.
|
|
961
|
+
shortfall = model.new_int_var(
|
|
962
|
+
0, band["min"],
|
|
963
|
+
f"zc_floor_short_{s.employee_id}",
|
|
964
|
+
)
|
|
965
|
+
model.add(shortfall >= band["min"] - sum(my_vars))
|
|
966
|
+
spread_obj_terms.append(
|
|
967
|
+
ZC_FLOOR_SHORTFALL_WEIGHT * shortfall
|
|
968
|
+
)
|
|
969
|
+
print(
|
|
970
|
+
f" H18 single-ZC soft floor: "
|
|
971
|
+
f"{shift_code}/{role.value} ({s.name}) "
|
|
972
|
+
f"target >= {band['min']} (soft)"
|
|
973
|
+
)
|
|
974
|
+
continue
|
|
975
|
+
sample_staff = bucket[0]
|
|
976
|
+
target = preferred_target_for(sample_staff)
|
|
977
|
+
cap = hard_cap_for(sample_staff)
|
|
978
|
+
# Finding 1 (2026-05-15): hard UPPER bound now slides with the
|
|
979
|
+
# day's saturation level. On heavy days (level == cap) the
|
|
980
|
+
# bucket can reach cap directly; on lighter days the bound
|
|
981
|
+
# stays close to the level so the solver can't over-pack a
|
|
982
|
+
# single staff when the day's volume doesn't demand it. Falls
|
|
983
|
+
# back to the static target+1 when day_level_by_bucket isn't
|
|
984
|
+
# passed (e.g. tests).
|
|
985
|
+
day_level = (
|
|
986
|
+
day_level_by_bucket.get((shift_code, role))
|
|
987
|
+
if day_level_by_bucket else None
|
|
988
|
+
)
|
|
989
|
+
if day_level is not None:
|
|
990
|
+
hi = min(day_level + 1, cap)
|
|
991
|
+
else:
|
|
992
|
+
hi = min(target + 1, cap)
|
|
993
|
+
# 2026-05-16 (user direction): ZC buckets also get a hard
|
|
994
|
+
# lower bound = band.min. Without it the solver was leaving
|
|
995
|
+
# ZCs at near-zero while STAFF saturated at target. STAFF
|
|
996
|
+
# buckets stay at lo=0 because their pools are large and the
|
|
997
|
+
# S1 day-level penalty is sufficient there.
|
|
998
|
+
if role == Role.ZC:
|
|
999
|
+
from ..allocator.caps import get_band
|
|
1000
|
+
band = get_band(shift_code, role)
|
|
1001
|
+
band_lo = band["min"] if band else 0
|
|
1002
|
+
else:
|
|
1003
|
+
band_lo = 0
|
|
1004
|
+
# bucket_min / bucket_max IntVars track the bucket's distribution
|
|
1005
|
+
# and feed the soft S1b spread-min penalty added to objective.
|
|
1006
|
+
# 2026-09-22 fix: domain floor is always 0 here, NOT band_lo.
|
|
1007
|
+
# band_lo is enforced per-staff below, clamped to that staff's
|
|
1008
|
+
# own eligible-flight supply — if the IntVar's own domain
|
|
1009
|
+
# floor were band_lo, a supply-starved staff (actual_expr forced
|
|
1010
|
+
# below band_lo) would still be constrained via
|
|
1011
|
+
# ``actual_expr >= bucket_min >= band_lo``, silently
|
|
1012
|
+
# reintroducing the same infeasibility this fix removes.
|
|
1013
|
+
bucket_min = model.new_int_var(
|
|
1014
|
+
0, hi, f"bmin_{shift_code}_{role.value}",
|
|
1015
|
+
)
|
|
1016
|
+
bucket_max = model.new_int_var(
|
|
1017
|
+
0, hi, f"bmax_{shift_code}_{role.value}",
|
|
1018
|
+
)
|
|
1019
|
+
n_constrained = 0
|
|
1020
|
+
for s in bucket:
|
|
1021
|
+
emp_vars = [
|
|
1022
|
+
x[(f.unique_id, s.employee_id)] for f in flights
|
|
1023
|
+
if (f.unique_id, s.employee_id) in x
|
|
1024
|
+
]
|
|
1025
|
+
if not emp_vars:
|
|
1026
|
+
continue
|
|
1027
|
+
actual_expr = sum(emp_vars)
|
|
1028
|
+
model.add(actual_expr <= hi)
|
|
1029
|
+
model.add(actual_expr >= bucket_min)
|
|
1030
|
+
model.add(actual_expr <= bucket_max)
|
|
1031
|
+
if band_lo > 0:
|
|
1032
|
+
# 2026-09-22 fix (2nd pass): SOFT floor, not hard. See
|
|
1033
|
+
# ZC_FLOOR_SHORTFALL_WEIGHT comment — a hard floor here
|
|
1034
|
+
# is what caused the 2026-09-22 all-flights-unallocated
|
|
1035
|
+
# incident (Night ops = 0, N/ZC floor = 14 -> INFEASIBLE).
|
|
1036
|
+
# shortfall = max(0, band_lo - actual_expr); penalized,
|
|
1037
|
+
# never blocks the solve.
|
|
1038
|
+
shortfall = model.new_int_var(
|
|
1039
|
+
0, band_lo, f"zc_floor_short_{s.employee_id}",
|
|
1040
|
+
)
|
|
1041
|
+
model.add(shortfall >= band_lo - actual_expr)
|
|
1042
|
+
spread_obj_terms.append(
|
|
1043
|
+
ZC_FLOOR_SHORTFALL_WEIGHT * shortfall
|
|
1044
|
+
)
|
|
1045
|
+
n_constrained += 1
|
|
1046
|
+
if n_constrained >= 2:
|
|
1047
|
+
spread_obj_terms.append(
|
|
1048
|
+
S1B_WEIGHT_BUCKET_SPREAD * (bucket_max - bucket_min)
|
|
1049
|
+
)
|
|
1050
|
+
# Escalating tier: spread beyond 1 gets hit again, on top
|
|
1051
|
+
# of the base per-unit cost above, so a 3-flight gap costs
|
|
1052
|
+
# noticeably more than two separate 1-flight gaps would.
|
|
1053
|
+
spread_excess = model.new_int_var(
|
|
1054
|
+
0, hi, f"spread_excess_{shift_code}_{role.value}",
|
|
1055
|
+
)
|
|
1056
|
+
model.add(spread_excess >= (bucket_max - bucket_min) - 1)
|
|
1057
|
+
spread_obj_terms.append(S1B_WEIGHT_SPREAD_TIER2 * spread_excess)
|
|
1058
|
+
# 2026-09-23: optional HARD ceiling on top of the soft
|
|
1059
|
+
# terms above — see hard_bucket_spread_max docstring for
|
|
1060
|
+
# the whole-day-INFEASIBLE trade-off. Callers that pass
|
|
1061
|
+
# this should be ready to retry without it on INFEASIBLE.
|
|
1062
|
+
if hard_bucket_spread_max is not None:
|
|
1063
|
+
model.add(bucket_max - bucket_min <= hard_bucket_spread_max)
|
|
1064
|
+
n_h18_buckets += 1
|
|
1065
|
+
floor_note = f" (soft floor={band_lo})" if band_lo > 0 else ""
|
|
1066
|
+
print(f" H18 spread bucket {shift_code}/{role.value}: "
|
|
1067
|
+
f"{len(bucket)} staff ({n_constrained} pinned), "
|
|
1068
|
+
f"target={target}, range=[0, {hi}]{floor_note}")
|
|
1069
|
+
print(f" H18 total buckets pinned: {n_h18_buckets}")
|
|
1070
|
+
|
|
1071
|
+
# ---- H19 removed 2026-05-11 ----
|
|
1072
|
+
# Per user direction 2026-05-11: TEST and FERRY flights are now
|
|
1073
|
+
# distributed as normal flights and counted under workload — no
|
|
1074
|
+
# per-staff cap separate from H16. CHARTER (B-type) is also a
|
|
1075
|
+
# normal flight. The H16 cap is the only ceiling.
|
|
1076
|
+
|
|
1077
|
+
# ---- H17: P2F-per-handler cap of 8 ----
|
|
1078
|
+
# Per user direction 2026-05-10: no single P2F handler should be
|
|
1079
|
+
# asked to do more than 8 P2F flights in a day — that's the
|
|
1080
|
+
# operational ceiling. If a shift has more than 8 P2F flights,
|
|
1081
|
+
# the assigner should nominate an additional handler for that
|
|
1082
|
+
# shift (W215 warning surfaces this need).
|
|
1083
|
+
p2f_cap_per_handler = 8
|
|
1084
|
+
if elig_ctx is not None:
|
|
1085
|
+
p2f_handler_set_for_cap = set(
|
|
1086
|
+
elig_ctx.p2f_handler_by_shift.values()
|
|
1087
|
+
)
|
|
1088
|
+
for emp_id in p2f_handler_set_for_cap:
|
|
1089
|
+
p2f_vars = [
|
|
1090
|
+
x[(f.unique_id, emp_id)]
|
|
1091
|
+
for f in flights
|
|
1092
|
+
if f.ops_class == _OpsClass.P2F
|
|
1093
|
+
and (f.unique_id, emp_id) in x
|
|
1094
|
+
]
|
|
1095
|
+
if p2f_vars:
|
|
1096
|
+
model.add(sum(p2f_vars) <= p2f_cap_per_handler)
|
|
1097
|
+
|
|
1098
|
+
# ---- H16: per-staff hard caps ----
|
|
1099
|
+
# Per user direction 2026-05-10 (clarified):
|
|
1100
|
+
# P2F flights DO count against the P2F handler's cap. A P2F
|
|
1101
|
+
# handler's total work (P2F + regular) is capped at the peer
|
|
1102
|
+
# limit (ZC=16, STAFF=24, etc.) — this naturally keeps them
|
|
1103
|
+
# at or below the workload of non-handler peers since their
|
|
1104
|
+
# P2F flights eat into the cap.
|
|
1105
|
+
|
|
1106
|
+
for emp_id in flights_by_staff:
|
|
1107
|
+
cap = hard_cap_for(staff_by_id[emp_id])
|
|
1108
|
+
# Phase R: ``raise_cap_uids`` lets the operator nominate one
|
|
1109
|
+
# specific (flight, staff) pair to land OUTSIDE the H16 cap.
|
|
1110
|
+
# Effectively "this flight is on top of cap".
|
|
1111
|
+
emp_vars = [
|
|
1112
|
+
x[(f.unique_id, emp_id)]
|
|
1113
|
+
for f in flights
|
|
1114
|
+
if (f.unique_id, emp_id) in x
|
|
1115
|
+
and (f.unique_id, emp_id) not in raise_cap_uids
|
|
1116
|
+
]
|
|
1117
|
+
if emp_vars:
|
|
1118
|
+
model.add(sum(emp_vars) <= cap)
|
|
1119
|
+
|
|
1120
|
+
# ---- P2F first: reserve each handler's P2F flights ----
|
|
1121
|
+
# 2026-09-22 (user direction): the nominated P2F handler must get his
|
|
1122
|
+
# P2F flights BEFORE any normal flight is considered. Previously a P2F
|
|
1123
|
+
# flight was just another flight with the same unallocated cost, so a
|
|
1124
|
+
# handler could be filled with normal flights (cap H16 / spacing H10)
|
|
1125
|
+
# and have P2F left over. ``select_p2f_priority`` returns only the
|
|
1126
|
+
# P2F flights that can be fixed without conflicting with H17 / H16 /
|
|
1127
|
+
# H10 / existing pins, so this cannot make the model infeasible.
|
|
1128
|
+
# Normal flights are then solved around them; any P2F flight not
|
|
1129
|
+
# reserved still gets the heavier P2F_UNASSIGNED_PENALTY above.
|
|
1130
|
+
if elig_ctx is not None and elig_ctx.p2f_handler_by_shift:
|
|
1131
|
+
reserved_p2f, skipped_p2f = select_p2f_priority(
|
|
1132
|
+
flights,
|
|
1133
|
+
eligibility,
|
|
1134
|
+
set(elig_ctx.p2f_handler_by_shift.values()),
|
|
1135
|
+
ops_day=ops_day,
|
|
1136
|
+
cap_by_staff={
|
|
1137
|
+
eid: hard_cap_for(s) for eid, s in staff_by_id.items()
|
|
1138
|
+
},
|
|
1139
|
+
pinned_assignments=pinned_assignments,
|
|
1140
|
+
waive_h10_triples=waive_h10_triples,
|
|
1141
|
+
)
|
|
1142
|
+
n_p2f_reserved = 0
|
|
1143
|
+
for _uid, _eid in reserved_p2f.items():
|
|
1144
|
+
_var = x.get((_uid, _eid))
|
|
1145
|
+
if _var is None:
|
|
1146
|
+
continue
|
|
1147
|
+
model.add(_var == 1)
|
|
1148
|
+
n_p2f_reserved += 1
|
|
1149
|
+
if n_p2f_reserved or skipped_p2f:
|
|
1150
|
+
print(
|
|
1151
|
+
f" P2F-first: {n_p2f_reserved} P2F flight(s) reserved for "
|
|
1152
|
+
f"their handlers; {len(skipped_p2f)} left to the solver"
|
|
1153
|
+
)
|
|
1154
|
+
for _uid, _why in skipped_p2f:
|
|
1155
|
+
print(f" P2F {_uid} not reserved: {_why}")
|
|
1156
|
+
|
|
1157
|
+
# ---- Soft objectives ----
|
|
1158
|
+
# Unassigned-flight penalty dominates everything else, so the solver
|
|
1159
|
+
# always prefers to allocate over leaving idle. Weight = 100_000 so a
|
|
1160
|
+
# single un-assigned flight is more costly than every other soft
|
|
1161
|
+
# term combined at typical problem sizes.
|
|
1162
|
+
unassigned_penalty = 100_000
|
|
1163
|
+
obj_terms: list[Any] = []
|
|
1164
|
+
if unassigned_indicators:
|
|
1165
|
+
obj_terms.append(unassigned_penalty * sum(unassigned_indicators))
|
|
1166
|
+
if p2f_unassigned_indicators:
|
|
1167
|
+
obj_terms.append(
|
|
1168
|
+
P2F_UNASSIGNED_PENALTY * sum(p2f_unassigned_indicators)
|
|
1169
|
+
)
|
|
1170
|
+
# S1b bucket spread minimization — wired alongside S1 count balance.
|
|
1171
|
+
# Always on when S1 is enabled (they're complementary: S1 pins each
|
|
1172
|
+
# staff near their preferred target, S1b pushes the whole bucket toward
|
|
1173
|
+
# uniform).
|
|
1174
|
+
if enable_s1_count_balance and spread_obj_terms:
|
|
1175
|
+
obj_terms.append(sum(spread_obj_terms))
|
|
1176
|
+
if enable_s1_count_balance:
|
|
1177
|
+
obj_terms.append(
|
|
1178
|
+
_build_s1_term(model, flights, staff, x, day_level_by_bucket)
|
|
1179
|
+
)
|
|
1180
|
+
if enable_s3_heavy_spread:
|
|
1181
|
+
obj_terms.append(_build_s3_term(model, flights, staff, x))
|
|
1182
|
+
if enable_s4_pair_stability:
|
|
1183
|
+
obj_terms.append(_build_s4_term(flights, staff, x))
|
|
1184
|
+
if enable_s5_handover_preference:
|
|
1185
|
+
obj_terms.append(_build_s5_handover_term(flights, staff, x, ops_day))
|
|
1186
|
+
if enable_s6_awkward_preference:
|
|
1187
|
+
obj_terms.append(_build_s6_awkward_term(flights, staff, x, ops_day))
|
|
1188
|
+
if enable_s7_zc_buffer_avoidance:
|
|
1189
|
+
obj_terms.append(_build_s7_zc_buffer_term(flights, staff, x, ops_day))
|
|
1190
|
+
# Phase 4 / Changes 5 + 6 (2026-05-14, INTL overhaul).
|
|
1191
|
+
# The helpers return ``None`` when there's nothing to penalize; a
|
|
1192
|
+
# CP-SAT LinearExpr otherwise. CP-SAT LinearExpr raises on bool() and
|
|
1193
|
+
# on equality with int, so test with ``is not None`` only.
|
|
1194
|
+
if enable_s_intl_spacing:
|
|
1195
|
+
spacing_term = _build_s_intl_spacing_term(
|
|
1196
|
+
model, flights, staff, x, eligibility, ops_day,
|
|
1197
|
+
)
|
|
1198
|
+
if spacing_term is not None:
|
|
1199
|
+
obj_terms.append(spacing_term)
|
|
1200
|
+
if enable_s_intl_fair:
|
|
1201
|
+
fair_term = _build_s_intl_fair_term(model, flights, staff, x)
|
|
1202
|
+
if fair_term is not None:
|
|
1203
|
+
obj_terms.append(fair_term)
|
|
1204
|
+
# Patch 2026-05-15 — band-targeted "prefer stable shift" within
|
|
1205
|
+
# the rush bands.
|
|
1206
|
+
if enable_s_band_shift_stability:
|
|
1207
|
+
stability_term = _build_s_band_shift_stability_term(
|
|
1208
|
+
flights, staff, x, ops_day,
|
|
1209
|
+
)
|
|
1210
|
+
if stability_term is not None:
|
|
1211
|
+
obj_terms.append(stability_term)
|
|
1212
|
+
# S_outer is always on (no flag) — penalizes A/A1 outer-band STDs
|
|
1213
|
+
# so the solver only uses them when the inner band can't absorb
|
|
1214
|
+
# the flight. Required by user direction 2026-05-12 §1.
|
|
1215
|
+
obj_terms.append(_build_outer_band_term(flights, staff, x, ops_day))
|
|
1216
|
+
if obj_terms:
|
|
1217
|
+
model.minimize(sum(obj_terms))
|
|
1218
|
+
|
|
1219
|
+
# ---- Pinned assignments (2026-05-18 — sick-call re-solve) ----
|
|
1220
|
+
# Pinned pairs become HARD constraints (model.add x == 1) — every
|
|
1221
|
+
# other staff for the same flight is implicitly forced to 0 by H1.
|
|
1222
|
+
# Used by the sick-call iteration flow in step3: prior allocation
|
|
1223
|
+
# is locked in place; only the sick staff's flights have free
|
|
1224
|
+
# decision variables, so the solver redistributes ONLY those.
|
|
1225
|
+
#
|
|
1226
|
+
# Pins that can't be applied (flight or staff disappeared between
|
|
1227
|
+
# runs) are silently skipped; the solver is otherwise free for
|
|
1228
|
+
# those rows. Returns to a cold solve when ``pinned_assignments``
|
|
1229
|
+
# is None or empty.
|
|
1230
|
+
n_pinned_applied = 0
|
|
1231
|
+
n_pinned_skipped = 0
|
|
1232
|
+
if pinned_assignments:
|
|
1233
|
+
for fid, eid in pinned_assignments.items():
|
|
1234
|
+
target = x.get((fid, eid))
|
|
1235
|
+
if target is None:
|
|
1236
|
+
n_pinned_skipped += 1
|
|
1237
|
+
continue
|
|
1238
|
+
model.add(target == 1)
|
|
1239
|
+
n_pinned_applied += 1
|
|
1240
|
+
if n_pinned_applied or n_pinned_skipped:
|
|
1241
|
+
print(
|
|
1242
|
+
f" solver: pinned {n_pinned_applied} prior assignment(s); "
|
|
1243
|
+
f"{n_pinned_skipped} skipped (no longer eligible)"
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
# ---- Warm-start hints (Phase R — recommender re-solve speedup) ----
|
|
1247
|
+
# When ``solution_hints`` is supplied, mark each (flight, staff) pair
|
|
1248
|
+
# in it with x=1 and every other variable for that flight with x=0.
|
|
1249
|
+
# CP-SAT uses the hint as the search's starting point — if the hint
|
|
1250
|
+
# is still feasible under the (possibly-changed) constraints, the
|
|
1251
|
+
# solver typically returns in 30-60 % of the cold-start time.
|
|
1252
|
+
#
|
|
1253
|
+
# Hints are SUGGESTIONS, not constraints. If a hint pair is no longer
|
|
1254
|
+
# eligible (e.g. an override removed that staff's slot), the solver
|
|
1255
|
+
# silently ignores that hint and searches normally.
|
|
1256
|
+
n_hints_applied = 0
|
|
1257
|
+
n_caller_hints = 0
|
|
1258
|
+
hinted_fids: set[str] = set()
|
|
1259
|
+
if solution_hints:
|
|
1260
|
+
for fid, eid in solution_hints.items():
|
|
1261
|
+
target = x.get((fid, eid))
|
|
1262
|
+
if target is None:
|
|
1263
|
+
continue
|
|
1264
|
+
model.add_hint(target, 1)
|
|
1265
|
+
n_hints_applied += 1
|
|
1266
|
+
n_caller_hints += 1
|
|
1267
|
+
hinted_fids.add(fid)
|
|
1268
|
+
if n_caller_hints:
|
|
1269
|
+
print(
|
|
1270
|
+
f" solver: warm-start with {n_caller_hints} solution "
|
|
1271
|
+
f"hints (of {len(solution_hints)} requested)"
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
# ---- Heuristic warm start for genuinely cold solves (speed only) ----
|
|
1275
|
+
# A caller-supplied ``solution_hints`` (above) comes from a PRIOR
|
|
1276
|
+
# CP-SAT solve, so high coverage of it is a trustworthy near-optimal
|
|
1277
|
+
# upper bound — that's what earns the aggressive 200s/5%-gap
|
|
1278
|
+
# "re-solve mode" below. A first-ever Plan/Allocate for the day has
|
|
1279
|
+
# no such hints and CP-SAT starts its search from nothing, spending
|
|
1280
|
+
# real time just to find ANY feasible point before it can start
|
|
1281
|
+
# improving. We close that gap with a cheap greedy seed (H1/H10/H16
|
|
1282
|
+
# only — allocator/greedy_fallback.py) for whatever flights the
|
|
1283
|
+
# caller didn't already hint.
|
|
1284
|
+
#
|
|
1285
|
+
# This is deliberately NOT allowed to change the acceptance bar: it
|
|
1286
|
+
# is excluded from the ``n_caller_hints`` count used by the re-solve
|
|
1287
|
+
# gate just below, so a cold solve still gets the full max_seconds
|
|
1288
|
+
# budget and the full (0%-gap / proven-optimal-or-timeout) standard —
|
|
1289
|
+
# it just starts the search from a much better point, which tends to
|
|
1290
|
+
# improve both how fast a good answer is found AND how good the
|
|
1291
|
+
# answer is if the clock does run out.
|
|
1292
|
+
if n_caller_hints < max(1, len(flights) // 2):
|
|
1293
|
+
# Also skip anything already pinned (hard-fixed) above — hinting
|
|
1294
|
+
# a different staff there would just be a contradictory no-op.
|
|
1295
|
+
pinned_fids = set(pinned_assignments) if pinned_assignments else set()
|
|
1296
|
+
remaining_flights = [
|
|
1297
|
+
f for f in flights
|
|
1298
|
+
if f.unique_id not in hinted_fids and f.unique_id not in pinned_fids
|
|
1299
|
+
]
|
|
1300
|
+
if remaining_flights:
|
|
1301
|
+
greedy_seed = greedy_allocate(
|
|
1302
|
+
remaining_flights, staff, eligibility, ops_day=ops_day,
|
|
1303
|
+
)
|
|
1304
|
+
n_greedy_applied = 0
|
|
1305
|
+
for fid, eid in greedy_seed.items():
|
|
1306
|
+
target = x.get((fid, eid))
|
|
1307
|
+
if target is None:
|
|
1308
|
+
continue
|
|
1309
|
+
model.add_hint(target, 1)
|
|
1310
|
+
n_hints_applied += 1
|
|
1311
|
+
n_greedy_applied += 1
|
|
1312
|
+
if n_greedy_applied:
|
|
1313
|
+
print(
|
|
1314
|
+
f" solver: cold-solve warm start — seeded "
|
|
1315
|
+
f"{n_greedy_applied} greedy-heuristic hint(s) for "
|
|
1316
|
+
f"{len(remaining_flights)} unhinted flight(s) (speed "
|
|
1317
|
+
"only; does not relax the optimality bar)"
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
# ---- Solve ----
|
|
1321
|
+
# Per user direction 2026-05-11: time budget removed — solver runs
|
|
1322
|
+
# to completion (OPTIMAL or proven INFEASIBLE). max_seconds = 0 or
|
|
1323
|
+
# negative disables the time bound; positive values still cap.
|
|
1324
|
+
solver = cp_model.CpSolver()
|
|
1325
|
+
if max_seconds and max_seconds > 0:
|
|
1326
|
+
solver.parameters.max_time_in_seconds = float(max_seconds)
|
|
1327
|
+
# Parallel portfolio search — pure speed lever, see
|
|
1328
|
+
# _resolve_num_workers docstring. Does not change what counts as an
|
|
1329
|
+
# acceptable answer.
|
|
1330
|
+
solver.parameters.num_search_workers = _resolve_num_workers(num_workers)
|
|
1331
|
+
# 2026-05-24: keep stochastic search by design. A previous attempt
|
|
1332
|
+
# to pin random_seed=42 backfired — user pointed out: if 42 happens
|
|
1333
|
+
# to be unlucky for a given problem, every re-run produces the same
|
|
1334
|
+
# bad result and "re-allocate to get a better answer" stops being
|
|
1335
|
+
# an escape hatch. We rely on CP-SAT's default randomness so the
|
|
1336
|
+
# operator can simply re-run when a result looks thin.
|
|
1337
|
+
solver.parameters.stop_after_first_solution = False
|
|
1338
|
+
# 2026-05-28 (user direction — slow re-solve fix): when warm-start
|
|
1339
|
+
# hints are present in meaningful quantity, the prior allocation is
|
|
1340
|
+
# a tight upper bound on the objective. Without an early-exit
|
|
1341
|
+
# signal, CP-SAT runs to max_seconds polishing the objective even
|
|
1342
|
+
# though the warm-start already provided a near-optimal solution.
|
|
1343
|
+
# A relative gap limit tells the solver to stop when proving better
|
|
1344
|
+
# would be operationally pointless.
|
|
1345
|
+
#
|
|
1346
|
+
# Two-tier behavior:
|
|
1347
|
+
# * n_caller_hints >= 50% of total flights → "re-solve mode":
|
|
1348
|
+
# cap wall-clock at 200s AND accept 5% gap. Sick / recommender
|
|
1349
|
+
# re-solves complete in 30-60s typical (vs 700s cold) because
|
|
1350
|
+
# CP-SAT proves the warm-started solution near-optimal fast.
|
|
1351
|
+
# * Fewer hints → "cold-solve mode": full max_seconds, full proof.
|
|
1352
|
+
# Gated on ``n_caller_hints`` (genuine prior-solution hints) only —
|
|
1353
|
+
# the greedy heuristic seed above is not a trustworthy upper bound
|
|
1354
|
+
# on the objective and must never trigger the relaxed gap/time cap.
|
|
1355
|
+
if n_caller_hints >= max(1, len(flights) // 2):
|
|
1356
|
+
solver.parameters.relative_gap_limit = 0.05
|
|
1357
|
+
prev_max = solver.parameters.max_time_in_seconds
|
|
1358
|
+
if prev_max <= 0 or prev_max > 200.0:
|
|
1359
|
+
solver.parameters.max_time_in_seconds = 200.0
|
|
1360
|
+
print(
|
|
1361
|
+
f" solver: re-solve mode — warm-start has {n_caller_hints} "
|
|
1362
|
+
f"hints of {len(flights)} flights; capped at 200s + 5% gap "
|
|
1363
|
+
f"(was {prev_max:.0f}s full budget)"
|
|
1364
|
+
)
|
|
1365
|
+
import time as _stime
|
|
1366
|
+
_solve_t0 = _stime.monotonic()
|
|
1367
|
+
status = solver.solve(model)
|
|
1368
|
+
_solve_elapsed = _stime.monotonic() - _solve_t0
|
|
1369
|
+
print(f" solver: status={_STATUS_MAP.get(status, 'UNKNOWN')}, elapsed={_solve_elapsed:.1f}s")
|
|
1370
|
+
status_str = _STATUS_MAP.get(status, "UNKNOWN")
|
|
1371
|
+
|
|
1372
|
+
assignments: dict[str, str] = {}
|
|
1373
|
+
if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
|
1374
|
+
for (fid, eid), v in x.items():
|
|
1375
|
+
if solver.value(v) == 1:
|
|
1376
|
+
# No flight should ever have two staff (H1), but assert
|
|
1377
|
+
# to surface bugs in the constraint encoding early.
|
|
1378
|
+
assert fid not in assignments, (
|
|
1379
|
+
f"H1 violated: {fid} assigned to both "
|
|
1380
|
+
f"{assignments[fid]} and {eid}"
|
|
1381
|
+
)
|
|
1382
|
+
assignments[fid] = eid
|
|
1383
|
+
|
|
1384
|
+
# H1 is now a soft preference, so a feasible solution may legitimately
|
|
1385
|
+
# leave some flights unassigned (the UNASSIGNED_PENALTY objective
|
|
1386
|
+
# term makes the solver minimize this). No post-solve assertion.
|
|
1387
|
+
|
|
1388
|
+
return AllocationSolverResult(
|
|
1389
|
+
status=status_str,
|
|
1390
|
+
assignments=assignments,
|
|
1391
|
+
wall_clock_seconds=solver.wall_time,
|
|
1392
|
+
)
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def solve_allocation_hard_only(
|
|
1396
|
+
flights: list[FlightInput],
|
|
1397
|
+
staff: list[StaffMember],
|
|
1398
|
+
eligibility: dict[str, set[str]],
|
|
1399
|
+
*,
|
|
1400
|
+
ops_day: date_t,
|
|
1401
|
+
max_seconds: int = 60,
|
|
1402
|
+
) -> AllocationSolverResult:
|
|
1403
|
+
"""E5 entry point — hard constraints only, no soft objective.
|
|
1404
|
+
|
|
1405
|
+
Thin wrapper around solve_allocation with all soft-objective flags
|
|
1406
|
+
False. Kept for tests that assert pure constraint behavior in
|
|
1407
|
+
isolation.
|
|
1408
|
+
"""
|
|
1409
|
+
return solve_allocation(
|
|
1410
|
+
flights, staff, eligibility,
|
|
1411
|
+
ops_day=ops_day, max_seconds=max_seconds,
|
|
1412
|
+
)
|