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,302 @@
|
|
|
1
|
+
"""Eligibility matrix for Step 4 (round-3 update).
|
|
2
|
+
|
|
3
|
+
For every (flight, staff) pair, answer one question: is this staff eligible
|
|
4
|
+
to be assigned this flight, given the *hard* constraints H2, H3, H4, H11,
|
|
5
|
+
H12, H13? The matrix is a sparse mapping ``flight_id -> set[employee_id]``
|
|
6
|
+
that the CP-SAT solver consumes to define decision variables.
|
|
7
|
+
|
|
8
|
+
Each filter is a tiny pure function. Filter ordering does not change
|
|
9
|
+
correctness (all filters must pass — AND semantics) but it does change the
|
|
10
|
+
``ExcludeReason`` returned for diagnostic logs.
|
|
11
|
+
|
|
12
|
+
Hard constraints enforced HERE:
|
|
13
|
+
H2 shift coverage (incl. handover overlap window — both prev and next
|
|
14
|
+
shift are eligible in the (nominal_end, nominal_end+30] window;
|
|
15
|
+
solver picks via S1+S5)
|
|
16
|
+
H3 P2F license match
|
|
17
|
+
H4 P2F dedication + buffer (no normal flights for the handler
|
|
18
|
+
from STD-2hrs to STD+1hr of each of their P2F flights)
|
|
19
|
+
H11 per-day overrides (STD cutoff, max_flights via H16 cap)
|
|
20
|
+
H12 international newbie + shift-edge exclusions
|
|
21
|
+
H13 AM exclusion
|
|
22
|
+
H20 nominated P2F handlers never take international flights (frees
|
|
23
|
+
up their day for more P2F work)
|
|
24
|
+
|
|
25
|
+
Hard constraints NOT enforced here (deferred to the solver):
|
|
26
|
+
H1 exactly one staff per flight — solver objective
|
|
27
|
+
H10 15-min spacing (30 domestic→INTL) — solver at-most-one pairs
|
|
28
|
+
H16 hard caps — solver constraint
|
|
29
|
+
|
|
30
|
+
Hard constraints removed in round-3 (now SOFT, handled in the solver):
|
|
31
|
+
H7 handover window (was tail-ext hard) — soft via S5
|
|
32
|
+
H9 ZC report buffers (was hard exclusion) — soft via S7
|
|
33
|
+
H15 awkward routing (was hard pre-filter) — soft via S6
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from datetime import date as date_t
|
|
40
|
+
from datetime import time as time_t
|
|
41
|
+
from enum import StrEnum
|
|
42
|
+
|
|
43
|
+
from ..schemas import (
|
|
44
|
+
AllocationSheet,
|
|
45
|
+
FlightInput,
|
|
46
|
+
OpsClass,
|
|
47
|
+
Role,
|
|
48
|
+
ShiftCode,
|
|
49
|
+
StaffMember,
|
|
50
|
+
)
|
|
51
|
+
from .windows import (
|
|
52
|
+
SHIFT_NOMINAL_MIN,
|
|
53
|
+
awkward_eligible_shifts,
|
|
54
|
+
in_p2f_block,
|
|
55
|
+
is_in_std_window,
|
|
56
|
+
std_to_ops_day_minutes,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# D-marker offset: planning happens 75 minutes before STD.
|
|
60
|
+
_D75_OFFSET_MIN: int = 75
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _intl_handler_covers_d75_through_std(
|
|
64
|
+
staff_shift: ShiftCode,
|
|
65
|
+
flight_std: time_t,
|
|
66
|
+
flight_date: date_t,
|
|
67
|
+
ops_day: date_t,
|
|
68
|
+
) -> bool:
|
|
69
|
+
"""For INTL same-handler rule (Change 4): does ``staff_shift``'s
|
|
70
|
+
nominal working window cover the full span from D-75 (the planning
|
|
71
|
+
marker) through STD?
|
|
72
|
+
|
|
73
|
+
Used by:
|
|
74
|
+
* Phase 2 (2026-05-14) — postsolve's INTL self-pair override
|
|
75
|
+
skips assigning PLANNED_BY / RELIEVED_BY when the handler can't
|
|
76
|
+
actually cover the planning window. Phase 3 makes this an
|
|
77
|
+
eligibility filter that rejects the assignment entirely.
|
|
78
|
+
|
|
79
|
+
Pure function — no side effects. Returns True iff:
|
|
80
|
+
* shift's nominal start ≤ (STD - 75 min), AND
|
|
81
|
+
* STD ≤ shift's nominal end.
|
|
82
|
+
|
|
83
|
+
Both bounds in ops-day minutes (handles N's midnight wrap via
|
|
84
|
+
``std_to_ops_day_minutes``)."""
|
|
85
|
+
nominal = SHIFT_NOMINAL_MIN.get(staff_shift)
|
|
86
|
+
if nominal is None:
|
|
87
|
+
return False
|
|
88
|
+
start_min, end_min = nominal
|
|
89
|
+
std_min = std_to_ops_day_minutes(flight_std, flight_date, ops_day)
|
|
90
|
+
d75_min = std_min - _D75_OFFSET_MIN
|
|
91
|
+
return start_min <= d75_min and std_min <= end_min
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ExcludeReason(StrEnum):
|
|
95
|
+
"""Diagnostic tag returned when a filter rejects a (flight, staff)
|
|
96
|
+
pair. Used by W201 diagnostic messages when a flight has zero
|
|
97
|
+
eligible staff."""
|
|
98
|
+
|
|
99
|
+
AM_ROLE = "AM (no flights, H13)"
|
|
100
|
+
NOT_ASSIGNABLE_TODAY = "staff not on shift today"
|
|
101
|
+
P2F_NOT_LICENSED = "P2F flight requires P2F license (H3)"
|
|
102
|
+
P2F_NOT_HANDLER = "P2F flight only goes to shift's nominated handler (H4)"
|
|
103
|
+
P2F_HANDLER_HARD_BLOCK = (
|
|
104
|
+
"normal flight within 2 hrs before / 1 hr after one of the "
|
|
105
|
+
"handler's P2F flights (H4)"
|
|
106
|
+
)
|
|
107
|
+
OUT_OF_SHIFT = "STD outside shift's distribution window (user 2026-05-12)"
|
|
108
|
+
STD_OUTSIDE_ALL_WINDOWS = "STD outside all shift windows"
|
|
109
|
+
OVERRIDE_CUTOFF = "STD past staff's per-day STD cutoff (H11)"
|
|
110
|
+
NEWBIE_INTERNATIONAL = "newbies excluded from international flights (H12a)"
|
|
111
|
+
INTL_SHIFT_EDGE = "international: staff within ±60 min of shift start/end (H12b — retired)"
|
|
112
|
+
INTL_SHIFT_COVERAGE = (
|
|
113
|
+
"international DEP: handler's shift does not cover D-75 through "
|
|
114
|
+
"STD (Change 4 — same handler start-to-finish)"
|
|
115
|
+
)
|
|
116
|
+
P2F_HANDLER_NO_INTL = (
|
|
117
|
+
"nominated P2F handlers do not take international flights (H20)"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class EligibilityContext:
|
|
123
|
+
"""Runtime context used by the filters. Computed once by the
|
|
124
|
+
orchestrator at preprocessing time."""
|
|
125
|
+
|
|
126
|
+
ops_day: date_t
|
|
127
|
+
p2f_handler_by_shift: dict[ShiftCode, str] = field(default_factory=dict)
|
|
128
|
+
"""shift -> employee_id of the P2F handler nominated for that shift.
|
|
129
|
+
Per user direction 2026-05-10: only M / A / N have P2F handlers;
|
|
130
|
+
M1 and A1 P2F flights drop the handler restriction (any P2F-licensed
|
|
131
|
+
staff on shift can take them)."""
|
|
132
|
+
|
|
133
|
+
p2f_flight_minutes_by_handler: dict[str, list[int]] = field(default_factory=dict)
|
|
134
|
+
"""employee_id -> list of P2F flight STDs (in ops-day minutes) the
|
|
135
|
+
handler is dedicated to. Drives H4's no-normal-flight window."""
|
|
136
|
+
|
|
137
|
+
# Phase R per-flight overrides — populated by step3 from the override list
|
|
138
|
+
# rows of type ``skip_p2f_buffer``. Each entry is (flight_key,
|
|
139
|
+
# employee_id) where flight_key = ``f"{flt}|{std.isoformat()}"``.
|
|
140
|
+
# When present, F6 skips the P2F buffer check for that exact pair.
|
|
141
|
+
skip_p2f_buffer_pairs: frozenset[tuple[str, str]] = frozenset()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def check(
|
|
145
|
+
flight: FlightInput, staff: StaffMember, ctx: EligibilityContext,
|
|
146
|
+
) -> ExcludeReason | None:
|
|
147
|
+
"""Return the first failing exclusion reason, or None if eligible."""
|
|
148
|
+
# F1: AM exclusion (H13). Cheapest; AMs never fly.
|
|
149
|
+
if staff.role == Role.AM:
|
|
150
|
+
return ExcludeReason.AM_ROLE
|
|
151
|
+
# F2: shift assignability. Off today => not eligible.
|
|
152
|
+
if staff.shift_today is None:
|
|
153
|
+
return ExcludeReason.NOT_ASSIGNABLE_TODAY
|
|
154
|
+
# F3: P2F license (H3). P2F flights require P2F-licensed staff.
|
|
155
|
+
if flight.ops_class == OpsClass.P2F and not staff.is_p2f_licensed:
|
|
156
|
+
return ExcludeReason.P2F_NOT_LICENSED
|
|
157
|
+
# F4: P2F dedication (H4). P2F is strictly M / A / N only — per
|
|
158
|
+
# user direction 2026-05-10, M1 and A1 staff are NEVER eligible for
|
|
159
|
+
# P2F flights regardless of license. Among M/A/N staff, only the
|
|
160
|
+
# shift's nominated handler is eligible.
|
|
161
|
+
if flight.ops_class == OpsClass.P2F:
|
|
162
|
+
if staff.shift_today not in ("M", "A", "N"):
|
|
163
|
+
return ExcludeReason.P2F_NOT_HANDLER # M1/A1 excluded
|
|
164
|
+
nominated = ctx.p2f_handler_by_shift.get(staff.shift_today)
|
|
165
|
+
if nominated != staff.employee_id:
|
|
166
|
+
return ExcludeReason.P2F_NOT_HANDLER
|
|
167
|
+
# F5: STD distribution window (user direction 2026-05-12, §1).
|
|
168
|
+
# A flight is eligible for a shift only if its STD falls in that
|
|
169
|
+
# shift's distribution window (M / M1 / N hard-bounded; A and A1
|
|
170
|
+
# have a soft outer band that the solver penalizes via S_outer).
|
|
171
|
+
if not is_in_std_window(
|
|
172
|
+
flight.std, flight.date, ctx.ops_day, staff.shift_today,
|
|
173
|
+
):
|
|
174
|
+
return ExcludeReason.OUT_OF_SHIFT
|
|
175
|
+
# F6: P2F handler buffer (H4, user direction 2026-09-26). For each
|
|
176
|
+
# P2F flight the handler runs, no normal flight from 2 hrs before
|
|
177
|
+
# to 1 hr after its STD (prep and turnaround time). Applies to
|
|
178
|
+
# every handler, ZC+P2F included.
|
|
179
|
+
#
|
|
180
|
+
# Phase R override: if the (flight_key, employee_id) pair is in
|
|
181
|
+
# ``ctx.skip_p2f_buffer_pairs``, skip the buffer check entirely.
|
|
182
|
+
if flight.ops_class != OpsClass.P2F:
|
|
183
|
+
anchors = ctx.p2f_flight_minutes_by_handler.get(staff.employee_id, [])
|
|
184
|
+
if anchors:
|
|
185
|
+
flight_key = f"{flight.flt}|{flight.std.isoformat(timespec='minutes')}"
|
|
186
|
+
override_active = (
|
|
187
|
+
(flight_key, staff.employee_id) in ctx.skip_p2f_buffer_pairs
|
|
188
|
+
)
|
|
189
|
+
if not override_active:
|
|
190
|
+
target = std_to_ops_day_minutes(
|
|
191
|
+
flight.std, flight.date, ctx.ops_day,
|
|
192
|
+
)
|
|
193
|
+
if in_p2f_block(target, anchors):
|
|
194
|
+
return ExcludeReason.P2F_HANDLER_HARD_BLOCK
|
|
195
|
+
# F7: per-staff STD window (H11).
|
|
196
|
+
# 2026-05-27: extended to a windowed constraint — flights must fall
|
|
197
|
+
# in [std_start, std_cutoff] when those are set. Either bound may be
|
|
198
|
+
# None, in which case the staff's normal shift bound applies for
|
|
199
|
+
# that side (i.e., no extra constraint).
|
|
200
|
+
if staff.std_cutoff is not None and flight.std > staff.std_cutoff:
|
|
201
|
+
return ExcludeReason.OVERRIDE_CUTOFF
|
|
202
|
+
if staff.std_start is not None and flight.std < staff.std_start:
|
|
203
|
+
return ExcludeReason.OVERRIDE_CUTOFF
|
|
204
|
+
# F8: international newbie exclusion (H12a).
|
|
205
|
+
if flight.is_international and staff.is_newbie:
|
|
206
|
+
return ExcludeReason.NEWBIE_INTERNATIONAL
|
|
207
|
+
# F9 (Phase 3, Change 4 — 2026-05-14): INTL DEP same-handler
|
|
208
|
+
# start-to-finish. For an INTL flight, the handler's shift must
|
|
209
|
+
# nominally cover D-75 through STD (strict). If not, exclude.
|
|
210
|
+
# No buffer, no fallback (per user direction). Replaces the retired
|
|
211
|
+
# F9 INTL_SHIFT_EDGE rule.
|
|
212
|
+
if flight.is_international and not _intl_handler_covers_d75_through_std(
|
|
213
|
+
staff.shift_today, flight.std, flight.date, ctx.ops_day,
|
|
214
|
+
):
|
|
215
|
+
return ExcludeReason.INTL_SHIFT_COVERAGE
|
|
216
|
+
# F10 (H20): the staff member nominated as *their own shift's* P2F
|
|
217
|
+
# handler never takes a *normal* international flight. Keeping
|
|
218
|
+
# ordinary INTL work off their plate frees up the time/slots
|
|
219
|
+
# they'd otherwise spend on it for more P2F flights instead. This
|
|
220
|
+
# must NOT touch the handler's own P2F flights — a P2F flight can
|
|
221
|
+
# itself be international (e.g. HAN-CCU), and F4 above is already
|
|
222
|
+
# the sole authority on who may take it; excluding it here would
|
|
223
|
+
# leave that flight with zero eligible staff. Mirrors F4's lookup
|
|
224
|
+
# — only the handler nominated for the shift they're working
|
|
225
|
+
# today is affected.
|
|
226
|
+
if (
|
|
227
|
+
flight.is_international
|
|
228
|
+
and flight.ops_class != OpsClass.P2F
|
|
229
|
+
and ctx.p2f_handler_by_shift.get(staff.shift_today) == staff.employee_id
|
|
230
|
+
):
|
|
231
|
+
return ExcludeReason.P2F_HANDLER_NO_INTL
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def build_matrix(
|
|
236
|
+
flights: list[FlightInput],
|
|
237
|
+
staff_today: list[StaffMember],
|
|
238
|
+
ctx: EligibilityContext,
|
|
239
|
+
) -> dict[str, set[str]]:
|
|
240
|
+
"""Return ``{flight.unique_id: {employee_id, ...}}``. Sparse: only
|
|
241
|
+
includes eligible pairs.
|
|
242
|
+
|
|
243
|
+
Per user direction 2026-05-12: keyed by ``unique_id`` (flt + dep +
|
|
244
|
+
arr + STD) so multi-leg rotations with the same FLT number stay
|
|
245
|
+
independent. Earlier code keyed by ``flt`` alone, which caused
|
|
246
|
+
later legs to silently overwrite earlier ones in the dict.
|
|
247
|
+
|
|
248
|
+
Flight rows with empty sets are still emitted; the orchestrator
|
|
249
|
+
scans for these and aborts with W201 before invoking the solver.
|
|
250
|
+
"""
|
|
251
|
+
matrix: dict[str, set[str]] = {}
|
|
252
|
+
for f in flights:
|
|
253
|
+
eligible: set[str] = set()
|
|
254
|
+
for s in staff_today:
|
|
255
|
+
if check(f, s, ctx) is None:
|
|
256
|
+
eligible.add(s.employee_id)
|
|
257
|
+
matrix[f.unique_id] = eligible
|
|
258
|
+
return matrix
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def diagnose_unassignable(
|
|
262
|
+
flight: FlightInput, staff_today: list[StaffMember], ctx: EligibilityContext,
|
|
263
|
+
) -> list[tuple[str, ExcludeReason]]:
|
|
264
|
+
"""For a flight with zero eligible staff, return the list of
|
|
265
|
+
(employee_id, reason) tuples explaining why each candidate was excluded.
|
|
266
|
+
Drives W201 diagnostic messages.
|
|
267
|
+
"""
|
|
268
|
+
out: list[tuple[str, ExcludeReason]] = []
|
|
269
|
+
for s in staff_today:
|
|
270
|
+
reason = check(flight, s, ctx)
|
|
271
|
+
if reason is not None:
|
|
272
|
+
out.append((s.employee_id, reason))
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------- preprocessing helpers ----------
|
|
277
|
+
|
|
278
|
+
def tag_awkward_window(flight: FlightInput, ops_day: date_t) -> FlightInput:
|
|
279
|
+
"""Tag a flight with is_awkward_window + awkward_eligible_shifts for
|
|
280
|
+
the solver's soft S6 routing preference. No longer drives eligibility
|
|
281
|
+
(awkward routing is soft now), but the solver consults these tags to
|
|
282
|
+
add a small penalty for non-preferred shifts."""
|
|
283
|
+
awk = awkward_eligible_shifts(flight.std, flight.date, ops_day)
|
|
284
|
+
if awk is None:
|
|
285
|
+
return flight
|
|
286
|
+
return flight.model_copy(update={
|
|
287
|
+
"is_awkward_window": True,
|
|
288
|
+
"awkward_eligible_shifts": awk,
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def sheet_target_for(flight: FlightInput, staff: StaffMember) -> AllocationSheet:
|
|
293
|
+
"""Resolve which allocation lane the row belongs to. Routing is
|
|
294
|
+
by ops_class (P2F) with the day/night split decided by the
|
|
295
|
+
assigned STAFF's shift (clarification 1)."""
|
|
296
|
+
if flight.ops_class == OpsClass.P2F:
|
|
297
|
+
return AllocationSheet.P2F
|
|
298
|
+
return (
|
|
299
|
+
AllocationSheet.NIGHT_OPS
|
|
300
|
+
if staff.shift_today == "N"
|
|
301
|
+
else AllocationSheet.DAY_OPS
|
|
302
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Greedy fallback allocator — Step 4 safety net.
|
|
2
|
+
|
|
3
|
+
CP-SAT (``solver/allocator_cpsat.py``) is the primary allocator: it
|
|
4
|
+
enforces every hard rule (H1/H10/H16/H4/H17/H18...) simultaneously and,
|
|
5
|
+
when it reaches OPTIMAL, provably gives the best weighted result. But
|
|
6
|
+
it is an EXACT solver — if any combination of hard constraints turns
|
|
7
|
+
out to be mutually unsatisfiable (a mis-tuned config value, a day with
|
|
8
|
+
an unusual shape, a bug not yet caught), it reports INFEASIBLE and
|
|
9
|
+
hands back ZERO assignments for the WHOLE day, even for the 99% of
|
|
10
|
+
flights that had nothing to do with the conflict. That is what
|
|
11
|
+
happened in production on 2026-09-22 (a static N/ZC workload floor of
|
|
12
|
+
14 with zero actual night-ops flights).
|
|
13
|
+
|
|
14
|
+
This module is the fallback for exactly that situation. It is invoked
|
|
15
|
+
ONLY when CP-SAT returns a non-feasible status. It ignores every soft
|
|
16
|
+
objective (workload balance, handover preference, INTL spacing
|
|
17
|
+
fairness, day-level targeting...) — it exists purely so the operator
|
|
18
|
+
gets a usable, physically-valid allocation instead of a wall of
|
|
19
|
+
unallocated flights, with a loud warning that manual review is needed.
|
|
20
|
+
|
|
21
|
+
Hard rules this DOES still enforce (the ones that are non-negotiable
|
|
22
|
+
even in a degraded mode):
|
|
23
|
+
H1 one staff per flight — never double-books a flight
|
|
24
|
+
H10 same-staff spacing — never overlaps/underspaces
|
|
25
|
+
one person's flights
|
|
26
|
+
H16 per-staff hard cap — never exceeds a staff's max
|
|
27
|
+
|
|
28
|
+
Hard rules this does NOT enforce (left to the operator to review via
|
|
29
|
+
the Warnings / Unallocated tabs when a greedy-fallback run happens):
|
|
30
|
+
H17 P2F-per-handler cap of 8, H18
|
|
31
|
+
workload-spread bucketing, INTL D-75 coverage nuances.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from datetime import date as date_t
|
|
38
|
+
|
|
39
|
+
from ..schemas import FlightInput, OpsClass, StaffMember
|
|
40
|
+
from .caps import hard_cap_for
|
|
41
|
+
from .windows import SpacingKey, spacing_clear, spacing_key, std_to_ops_day_minutes
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def greedy_allocate(
|
|
45
|
+
flights: list[FlightInput],
|
|
46
|
+
staff: list[StaffMember],
|
|
47
|
+
eligibility: dict[str, set[str]],
|
|
48
|
+
*,
|
|
49
|
+
ops_day: date_t,
|
|
50
|
+
) -> dict[str, str]:
|
|
51
|
+
"""Best-effort assignment respecting H1, H10, H16 only.
|
|
52
|
+
|
|
53
|
+
Places every P2F flight first (so the nominated handler is never
|
|
54
|
+
crowded out by normal flights), then all other flights, each group
|
|
55
|
+
in chronological (STD) order; for each, picks the least-loaded
|
|
56
|
+
eligible staff who doesn't violate that staff's
|
|
57
|
+
spacing or hard cap. No optimality guarantee and no workload
|
|
58
|
+
balancing beyond "prefer whoever has fewer flights so far" — this
|
|
59
|
+
is a safety net, not a replacement for the CP-SAT model.
|
|
60
|
+
|
|
61
|
+
Returns ``{flight.unique_id: employee_id}`` for whatever it
|
|
62
|
+
managed to place. A flight with no eligible-and-available staff is
|
|
63
|
+
simply absent from the result, exactly like a partial CP-SAT
|
|
64
|
+
solve — the caller's existing UNALLOCATED bookkeeping picks it up
|
|
65
|
+
the same way either way.
|
|
66
|
+
"""
|
|
67
|
+
staff_by_id = {s.employee_id: s for s in staff}
|
|
68
|
+
caps = {s.employee_id: hard_cap_for(s) for s in staff}
|
|
69
|
+
counts: dict[str, int] = defaultdict(int)
|
|
70
|
+
# Per-staff SpacingKey list for H10 spacing checks.
|
|
71
|
+
assigned_by_staff: dict[str, list[SpacingKey]] = defaultdict(list)
|
|
72
|
+
|
|
73
|
+
# P2F FIRST (2026-09-22, user direction): the nominated handler's P2F
|
|
74
|
+
# flights are placed before any normal flight is considered.
|
|
75
|
+
# Walking purely by time let a handler fill up on earlier normal
|
|
76
|
+
# flights and then hit his cap / spacing when his P2F flights came
|
|
77
|
+
# up. Within each group the order is still chronological.
|
|
78
|
+
ordered = sorted(
|
|
79
|
+
flights,
|
|
80
|
+
key=lambda f: (
|
|
81
|
+
f.ops_class != OpsClass.P2F,
|
|
82
|
+
std_to_ops_day_minutes(f.std, f.date, ops_day),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assignments: dict[str, str] = {}
|
|
87
|
+
for f in ordered:
|
|
88
|
+
key = spacing_key(f, ops_day)
|
|
89
|
+
candidates: list[str] = []
|
|
90
|
+
for eid in eligibility.get(f.unique_id, ()):
|
|
91
|
+
if staff_by_id.get(eid) is None:
|
|
92
|
+
continue
|
|
93
|
+
cap = caps.get(eid, 0)
|
|
94
|
+
if cap <= 0 or counts[eid] >= cap:
|
|
95
|
+
continue
|
|
96
|
+
if spacing_clear(key, assigned_by_staff[eid]):
|
|
97
|
+
candidates.append(eid)
|
|
98
|
+
if not candidates:
|
|
99
|
+
continue
|
|
100
|
+
candidates.sort(key=lambda eid: (counts[eid], eid))
|
|
101
|
+
pick = candidates[0]
|
|
102
|
+
assignments[f.unique_id] = pick
|
|
103
|
+
counts[pick] += 1
|
|
104
|
+
assigned_by_staff[pick].append(key)
|
|
105
|
+
|
|
106
|
+
return assignments
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Independent re-check of the hard rules on a finished allocation.
|
|
2
|
+
|
|
3
|
+
The CP-SAT model and the two post-passes (P2F, INTL) each enforce parts of
|
|
4
|
+
H1 / H10 / H16, and the post-passes move flights *after* the solver has
|
|
5
|
+
finished. This module re-derives the rules from the final rows alone, so a
|
|
6
|
+
regression in any stage shows up as a concrete list of violations instead
|
|
7
|
+
of a quietly worse roster.
|
|
8
|
+
|
|
9
|
+
It is deliberately decoupled from the solver: it only needs rows that carry
|
|
10
|
+
``date``, ``std``, ``flt``, ``dep``, ``arr`` and ``staff_employee_id``
|
|
11
|
+
(``AllocationRow`` qualifies, so does a ``SimpleNamespace`` in a test),
|
|
12
|
+
plus ``is_international`` and ``sheet_target`` when a row has them.
|
|
13
|
+
|
|
14
|
+
Rules checked
|
|
15
|
+
-------------
|
|
16
|
+
H1 a flight appears against at most one staff member
|
|
17
|
+
H10 same-staff spacing (15 min, or 30 min for a domestic flight followed
|
|
18
|
+
by an international one and for two P2F flights — see
|
|
19
|
+
``windows.required_spacing_min``)
|
|
20
|
+
H16 per-staff hard cap (only when ``caps`` is supplied)
|
|
21
|
+
|
|
22
|
+
Rows with an empty ``staff_employee_id`` (pre-plan-only / unallocated) are
|
|
23
|
+
ignored: they are reported elsewhere and have no owner to check.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections import Counter, defaultdict
|
|
29
|
+
from collections.abc import Collection, Iterable, Mapping
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import date as date_t
|
|
32
|
+
|
|
33
|
+
from .windows import (
|
|
34
|
+
SPACING_MAX_MIN,
|
|
35
|
+
SpacingKey,
|
|
36
|
+
required_spacing_min,
|
|
37
|
+
std_to_ops_day_minutes,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _spacing_key(row, ops_day: date_t) -> SpacingKey:
|
|
42
|
+
"""A row's H10 identity. Rows carry no ops class, so P2F is read
|
|
43
|
+
off the lane they were routed to; missing fields read as a plain
|
|
44
|
+
domestic flight."""
|
|
45
|
+
return (
|
|
46
|
+
std_to_ops_day_minutes(row.std, row.date, ops_day),
|
|
47
|
+
bool(getattr(row, "is_international", False)),
|
|
48
|
+
str(getattr(row, "sheet_target", "")) == "P2F",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Violation:
|
|
54
|
+
"""One broken rule. ``employee_id`` is empty for flight-level rules."""
|
|
55
|
+
|
|
56
|
+
rule: str
|
|
57
|
+
employee_id: str
|
|
58
|
+
detail: str
|
|
59
|
+
|
|
60
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
61
|
+
who = f" [{self.employee_id}]" if self.employee_id else ""
|
|
62
|
+
return f"{self.rule}{who}: {self.detail}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def flight_key(row) -> str:
|
|
66
|
+
"""Stable identity for a flight row (mirrors ``FlightInput.unique_id``)."""
|
|
67
|
+
return f"{row.flt}|{row.dep}|{row.arr}|{row.std.isoformat()}|{row.date.isoformat()}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_allocation(
|
|
71
|
+
rows: Iterable,
|
|
72
|
+
*,
|
|
73
|
+
ops_day: date_t,
|
|
74
|
+
caps: Mapping[str, int] | None = None,
|
|
75
|
+
waived_pairs: Collection[frozenset[str]] = (),
|
|
76
|
+
) -> list[Violation]:
|
|
77
|
+
"""Return every H1 / H10 / H16 violation found in ``rows``.
|
|
78
|
+
|
|
79
|
+
``caps`` maps employee_id -> hard cap (H16). Staff missing from the
|
|
80
|
+
mapping are not cap-checked.
|
|
81
|
+
``waived_pairs`` holds ``frozenset({key_a, key_b})`` entries for pairs
|
|
82
|
+
an operator explicitly waived (the ``waive_h10_pair`` override); those
|
|
83
|
+
pairs are not reported for H10.
|
|
84
|
+
"""
|
|
85
|
+
assigned = [r for r in rows if getattr(r, "staff_employee_id", "")]
|
|
86
|
+
violations: list[Violation] = []
|
|
87
|
+
|
|
88
|
+
# ---- H1: no flight owned twice ----
|
|
89
|
+
seen = Counter(flight_key(r) for r in assigned)
|
|
90
|
+
for key, n in sorted(seen.items()):
|
|
91
|
+
if n > 1:
|
|
92
|
+
violations.append(Violation("H1", "", f"{key} assigned {n} times"))
|
|
93
|
+
|
|
94
|
+
by_staff: dict[str, list] = defaultdict(list)
|
|
95
|
+
for r in assigned:
|
|
96
|
+
by_staff[r.staff_employee_id].append(r)
|
|
97
|
+
|
|
98
|
+
for emp_id, flights in sorted(by_staff.items()):
|
|
99
|
+
# ---- H16: hard cap ----
|
|
100
|
+
if caps is not None and emp_id in caps and len(flights) > caps[emp_id]:
|
|
101
|
+
violations.append(
|
|
102
|
+
Violation("H16", emp_id, f"{len(flights)} flights > cap {caps[emp_id]}")
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ---- H10: spacing (all pairs inside the max window, not just
|
|
106
|
+
# neighbours: a domestic flight 20 min before an INTL one breaks
|
|
107
|
+
# the 30-min rule even with a flight in between) ----
|
|
108
|
+
timed = sorted((_spacing_key(r, ops_day), flight_key(r)) for r in flights)
|
|
109
|
+
for i, (a, a_key) in enumerate(timed):
|
|
110
|
+
for b, b_key in timed[i + 1:]:
|
|
111
|
+
gap = b[0] - a[0]
|
|
112
|
+
if gap >= SPACING_MAX_MIN:
|
|
113
|
+
break
|
|
114
|
+
if gap < required_spacing_min(a, b):
|
|
115
|
+
if frozenset({a_key, b_key}) in waived_pairs:
|
|
116
|
+
continue
|
|
117
|
+
violations.append(
|
|
118
|
+
Violation(
|
|
119
|
+
"H10",
|
|
120
|
+
emp_id,
|
|
121
|
+
f"{a_key} and {b_key} only {gap} min apart",
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return violations
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""P2F-first priority: reserve each handler's P2F flights before anything else.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
A P2F flight can only go to the shift's nominated handler (eligibility F4).
|
|
6
|
+
That same handler is also eligible for ordinary flights, and every one of
|
|
7
|
+
those competes with his P2F flights for the same cap (H16) and the same
|
|
8
|
+
15-minute spacing (H10). With every flight costing the same when left
|
|
9
|
+
unallocated, nothing told the allocator that the handler's P2F work comes
|
|
10
|
+
first, so he could end up filled with normal flights and a P2F left over.
|
|
11
|
+
|
|
12
|
+
``select_p2f_priority`` decides, per handler, which P2F flights are
|
|
13
|
+
*reserved* for him. The CP-SAT model then fixes those assignments and the
|
|
14
|
+
normal flights are solved around them; the greedy fallback places them
|
|
15
|
+
before it looks at any normal flight.
|
|
16
|
+
|
|
17
|
+
Only flights that can be reserved *safely* are returned, so fixing them
|
|
18
|
+
can never make the model infeasible:
|
|
19
|
+
|
|
20
|
+
* the flight must have exactly one nominated handler eligible for it
|
|
21
|
+
(if two handlers could take it, the choice is left to the solver);
|
|
22
|
+
* at most ``per_handler_limit`` (H17: 8) per handler, and never more than
|
|
23
|
+
the handler's own hard cap (H16);
|
|
24
|
+
* two reserved P2F flights for one handler must be at least 30 min
|
|
25
|
+
apart (H10), unless the operator waived that pair;
|
|
26
|
+
* a flight is skipped when a normal flight already pinned to the handler
|
|
27
|
+
(re-solve mode) is too close to it, or when it is pinned elsewhere.
|
|
28
|
+
|
|
29
|
+
Anything skipped is returned with a reason so the caller can log it. Those
|
|
30
|
+
flights are still allocated by the solver, just without the reservation.
|
|
31
|
+
|
|
32
|
+
The module only needs flights with ``unique_id``, ``date``, ``std``,
|
|
33
|
+
``ops_class`` and ``is_international`` — no solver dependency — so it is
|
|
34
|
+
cheap to unit test.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections import defaultdict
|
|
40
|
+
from collections.abc import Collection, Iterable, Mapping
|
|
41
|
+
from datetime import date as date_t
|
|
42
|
+
|
|
43
|
+
from ..schemas import OpsClass
|
|
44
|
+
from .windows import SpacingKey, spacing_clear, spacing_key
|
|
45
|
+
|
|
46
|
+
# H17: no handler is asked to do more than this many P2F flights a day.
|
|
47
|
+
P2F_PER_HANDLER_LIMIT = 8
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def select_p2f_priority(
|
|
51
|
+
flights: Iterable,
|
|
52
|
+
eligibility: Mapping[str, Collection[str]],
|
|
53
|
+
handler_ids: Collection[str],
|
|
54
|
+
*,
|
|
55
|
+
ops_day: date_t,
|
|
56
|
+
cap_by_staff: Mapping[str, int],
|
|
57
|
+
per_handler_limit: int = P2F_PER_HANDLER_LIMIT,
|
|
58
|
+
pinned_assignments: Mapping[str, str] | None = None,
|
|
59
|
+
waive_h10_triples: Collection[tuple[str, str, str]] = frozenset(),
|
|
60
|
+
) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
|
61
|
+
"""Pick the P2F flights to reserve for their handlers.
|
|
62
|
+
|
|
63
|
+
Returns ``(reserved, skipped)``:
|
|
64
|
+
|
|
65
|
+
* ``reserved`` — ``{flight_unique_id: handler_employee_id}``
|
|
66
|
+
* ``skipped`` — ``[(flight_unique_id, reason), ...]`` for every P2F
|
|
67
|
+
flight that was *not* reserved.
|
|
68
|
+
"""
|
|
69
|
+
pinned = dict(pinned_assignments or {})
|
|
70
|
+
handler_set = set(handler_ids)
|
|
71
|
+
flight_list = list(flights)
|
|
72
|
+
key_of = {f.unique_id: spacing_key(f, ops_day) for f in flight_list}
|
|
73
|
+
|
|
74
|
+
reserved: dict[str, str] = {}
|
|
75
|
+
skipped: list[tuple[str, str]] = []
|
|
76
|
+
|
|
77
|
+
# Group each P2F flight under the single handler eligible for it.
|
|
78
|
+
by_handler: dict[str, list] = defaultdict(list)
|
|
79
|
+
for f in flight_list:
|
|
80
|
+
if f.ops_class != OpsClass.P2F:
|
|
81
|
+
continue
|
|
82
|
+
candidates = set(eligibility.get(f.unique_id, ())) & handler_set
|
|
83
|
+
if not candidates:
|
|
84
|
+
skipped.append((f.unique_id, "no nominated handler is eligible"))
|
|
85
|
+
elif len(candidates) > 1:
|
|
86
|
+
skipped.append((f.unique_id, "more than one handler is eligible"))
|
|
87
|
+
else:
|
|
88
|
+
by_handler[next(iter(candidates))].append(f)
|
|
89
|
+
|
|
90
|
+
# Times of ordinary flights already pinned to each handler (re-solve
|
|
91
|
+
# mode). A reserved P2F flight must keep H10 spacing from these.
|
|
92
|
+
p2f_uids = {f.unique_id for fl in by_handler.values() for f in fl}
|
|
93
|
+
pinned_normal: dict[str, list[SpacingKey]] = defaultdict(list)
|
|
94
|
+
for uid, eid in pinned.items():
|
|
95
|
+
if eid in by_handler and uid in key_of and uid not in p2f_uids:
|
|
96
|
+
pinned_normal[eid].append(key_of[uid])
|
|
97
|
+
|
|
98
|
+
def _waived(uid_a: str, uid_b: str, emp: str) -> bool:
|
|
99
|
+
return (
|
|
100
|
+
(uid_a, uid_b, emp) in waive_h10_triples
|
|
101
|
+
or (uid_b, uid_a, emp) in waive_h10_triples
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
for handler, p2f_flights in by_handler.items():
|
|
105
|
+
limit = min(per_handler_limit, cap_by_staff.get(handler, 0))
|
|
106
|
+
# Flights already pinned to this handler go first (they are fixed
|
|
107
|
+
# anyway), then earliest STD.
|
|
108
|
+
ordered = sorted(
|
|
109
|
+
p2f_flights,
|
|
110
|
+
key=lambda f: (pinned.get(f.unique_id) != handler, key_of[f.unique_id][0]),
|
|
111
|
+
)
|
|
112
|
+
taken: list = []
|
|
113
|
+
for f in ordered:
|
|
114
|
+
uid = f.unique_id
|
|
115
|
+
pinned_to = pinned.get(uid)
|
|
116
|
+
if pinned_to is not None and pinned_to != handler:
|
|
117
|
+
skipped.append((uid, f"pinned to {pinned_to}, not the handler"))
|
|
118
|
+
continue
|
|
119
|
+
if len(taken) >= limit:
|
|
120
|
+
skipped.append((uid, f"handler already has {limit} reserved (H17/H16)"))
|
|
121
|
+
continue
|
|
122
|
+
here = key_of[uid]
|
|
123
|
+
clash = next(
|
|
124
|
+
(
|
|
125
|
+
o for o in taken
|
|
126
|
+
if not _waived(uid, o.unique_id, handler)
|
|
127
|
+
and not spacing_clear(here, [key_of[o.unique_id]])
|
|
128
|
+
),
|
|
129
|
+
None,
|
|
130
|
+
)
|
|
131
|
+
if clash is not None:
|
|
132
|
+
skipped.append(
|
|
133
|
+
(uid, f"too close to reserved P2F {clash.unique_id} (H10)")
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
if not spacing_clear(here, pinned_normal.get(handler, ())):
|
|
137
|
+
skipped.append((uid, "too close to a flight already pinned to the handler"))
|
|
138
|
+
continue
|
|
139
|
+
taken.append(f)
|
|
140
|
+
reserved[uid] = handler
|
|
141
|
+
|
|
142
|
+
return reserved, skipped
|