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,348 @@
|
|
|
1
|
+
"""Per-unallocated-flight override recommender (Phase R).
|
|
2
|
+
|
|
3
|
+
After a solver run leaves some flights unallocated, this module
|
|
4
|
+
produces a ranked list of operator-approvable override rows that
|
|
5
|
+
would unblock each flight on the *next* re-solve.
|
|
6
|
+
|
|
7
|
+
API.
|
|
8
|
+
``recommend_for_unallocated(flights, staff_today, matrix,
|
|
9
|
+
assignments, elig_ctx, ops_day)`` returns a
|
|
10
|
+
``list[FlightRecommendations]`` — one entry per flight that the
|
|
11
|
+
solver left unallocated. Each entry holds up to three
|
|
12
|
+
``OverrideSuggestion`` records sorted by intrusiveness (least
|
|
13
|
+
invasive first).
|
|
14
|
+
|
|
15
|
+
Read-only. This module reads engine outputs and produces
|
|
16
|
+
suggestions; the operator (via the chat layer in
|
|
17
|
+
``src.agent.tools``) decides which to apply. The apply path
|
|
18
|
+
writes override rows and triggers a re-solve.
|
|
19
|
+
|
|
20
|
+
Intrusiveness ranking (lowest = most preferred):
|
|
21
|
+
|
|
22
|
+
1. WaiveH10Pair — one 5-14 min gap allowed on one staff.
|
|
23
|
+
Operational cost: small (one tight
|
|
24
|
+
sequence).
|
|
25
|
+
2. RaiseCapForFlight — +1 cap relief for one staff.
|
|
26
|
+
Operational cost: moderate (one person
|
|
27
|
+
does 25 instead of 24 today).
|
|
28
|
+
3. SkipP2FBuffer — waive P2F handler's D-3h buffer for one
|
|
29
|
+
flight. Operational cost: moderate
|
|
30
|
+
(handler's briefing-time buffer broken
|
|
31
|
+
for that flight).
|
|
32
|
+
4. SkipINTLRemoval — keep both INTL DEP and its would-be-
|
|
33
|
+
removed preceding flight on the same
|
|
34
|
+
handler. Operational cost: high
|
|
35
|
+
(handler does 2 back-to-back without
|
|
36
|
+
buffer relief).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from datetime import date as date_t
|
|
43
|
+
|
|
44
|
+
from ..schemas import FlightInput, StaffMember
|
|
45
|
+
from .caps import hard_cap_for
|
|
46
|
+
from .eligibility import (
|
|
47
|
+
EligibilityContext,
|
|
48
|
+
ExcludeReason,
|
|
49
|
+
check as elig_check,
|
|
50
|
+
)
|
|
51
|
+
from .windows import (
|
|
52
|
+
in_p2f_block,
|
|
53
|
+
spacing_clear,
|
|
54
|
+
spacing_key,
|
|
55
|
+
std_to_ops_day_minutes,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class OverrideSuggestion:
|
|
61
|
+
"""One proposed override row + its rationale + intrusiveness.
|
|
62
|
+
|
|
63
|
+
``override_type`` is one of: ``waive_h10_pair``, ``raise_cap``,
|
|
64
|
+
``skip_p2f_buffer``, ``skip_intl_removal``. ``payload`` is the
|
|
65
|
+
dict the apply-tool will use to construct the override row.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
intrusiveness: int # lower = more preferred
|
|
69
|
+
override_type: str
|
|
70
|
+
rationale: str # plain-English why this would work
|
|
71
|
+
payload: dict # override row values
|
|
72
|
+
target_staff_name: str = "" # display only — empty for INTL removal
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class FlightRecommendations:
|
|
77
|
+
"""All viable override suggestions for one unallocated flight.
|
|
78
|
+
|
|
79
|
+
``suggestions`` is sorted ascending by ``intrusiveness``.
|
|
80
|
+
Operators are expected to pick at most one per flight.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
flight_id: str # ``f"{flt}|{std.iso}"`` join key
|
|
84
|
+
flt: str # bare flight number for display
|
|
85
|
+
dep: str
|
|
86
|
+
arr: str
|
|
87
|
+
std_iso: str # ``HH:MM`` string for display
|
|
88
|
+
is_international: bool
|
|
89
|
+
suggestions: list[OverrideSuggestion] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------- intrusiveness weights ----------
|
|
93
|
+
_W_WAIVE_H10 = 10
|
|
94
|
+
_W_RAISE_CAP = 20
|
|
95
|
+
_W_SKIP_P2F_BUFFER = 30
|
|
96
|
+
_W_SKIP_INTL_REMOVAL = 40
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------- internals ----------
|
|
100
|
+
|
|
101
|
+
def _h10_conflict_other_std(
|
|
102
|
+
candidate_assignments: list[FlightInput],
|
|
103
|
+
flight: FlightInput,
|
|
104
|
+
ops_day: date_t,
|
|
105
|
+
) -> FlightInput | None:
|
|
106
|
+
"""Return the FIRST flight on a candidate staff's day-list whose
|
|
107
|
+
STD is within the H10 spacing floor of ``flight``'s STD (15 min,
|
|
108
|
+
30 for domestic→INTL and for two P2F flights).
|
|
109
|
+
None if no conflict.
|
|
110
|
+
"""
|
|
111
|
+
key = spacing_key(flight, ops_day)
|
|
112
|
+
for other in candidate_assignments:
|
|
113
|
+
if other.unique_id == flight.unique_id:
|
|
114
|
+
continue
|
|
115
|
+
if not spacing_clear(key, [spacing_key(other, ops_day)]):
|
|
116
|
+
return other
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_p2f_buffer_blocked(
|
|
121
|
+
flight: FlightInput,
|
|
122
|
+
staff: StaffMember,
|
|
123
|
+
ctx: EligibilityContext,
|
|
124
|
+
) -> bool:
|
|
125
|
+
"""True iff F6's P2F buffer (STD-2hrs to STD+1hr) would reject this
|
|
126
|
+
(flight, staff) pair. Mirrors the check in ``eligibility.check`` F6
|
|
127
|
+
so we can detect it for the recommender."""
|
|
128
|
+
anchors = ctx.p2f_flight_minutes_by_handler.get(
|
|
129
|
+
staff.employee_id, [],
|
|
130
|
+
)
|
|
131
|
+
if not anchors:
|
|
132
|
+
return False
|
|
133
|
+
target = std_to_ops_day_minutes(
|
|
134
|
+
flight.std, flight.date, ctx.ops_day,
|
|
135
|
+
)
|
|
136
|
+
return in_p2f_block(target, anchors)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _suggestions_for_flight(
|
|
140
|
+
flight: FlightInput,
|
|
141
|
+
staff_today: list[StaffMember],
|
|
142
|
+
matrix: dict[str, set[str]],
|
|
143
|
+
assignments: dict[str, str],
|
|
144
|
+
elig_ctx: EligibilityContext,
|
|
145
|
+
flights_per_staff: dict[str, list[FlightInput]],
|
|
146
|
+
ops_day: date_t,
|
|
147
|
+
) -> list[OverrideSuggestion]:
|
|
148
|
+
"""Build the ranked suggestion list for one unallocated flight."""
|
|
149
|
+
out: list[OverrideSuggestion] = []
|
|
150
|
+
flight_std_iso = flight.std.isoformat(timespec="minutes")
|
|
151
|
+
|
|
152
|
+
# Path A: the flight passed eligibility for some staff but the
|
|
153
|
+
# solver chose to leave it unallocated. Inspect each such
|
|
154
|
+
# candidate to find which hard constraint blocked them.
|
|
155
|
+
eligible_ids = matrix.get(flight.unique_id, set())
|
|
156
|
+
staff_by_id = {s.employee_id: s for s in staff_today}
|
|
157
|
+
# 2026-05-22: only count flights that go against the cap. P2F
|
|
158
|
+
# flights are exempt for the P2F handler. If we don't filter, the
|
|
159
|
+
# recommender wrongly flags handlers as overflow candidates for
|
|
160
|
+
# every unallocated domestic flight in their morning.
|
|
161
|
+
from ..schemas import OpsClass as _OC
|
|
162
|
+
def _cap_relevant_count(eid: str) -> int:
|
|
163
|
+
n = 0
|
|
164
|
+
for f in flights_per_staff.get(eid, []):
|
|
165
|
+
if f.ops_class == _OC.P2F:
|
|
166
|
+
continue # P2F handler exemption (4-slot adjustment already removes domestic neighbours)
|
|
167
|
+
n += 1
|
|
168
|
+
return n
|
|
169
|
+
for eid in sorted(eligible_ids):
|
|
170
|
+
s = staff_by_id.get(eid)
|
|
171
|
+
if s is None:
|
|
172
|
+
continue
|
|
173
|
+
cap = hard_cap_for(s)
|
|
174
|
+
current_count = _cap_relevant_count(eid)
|
|
175
|
+
# H10: candidate already has a flight within spacing-floor.
|
|
176
|
+
conflict = _h10_conflict_other_std(
|
|
177
|
+
flights_per_staff.get(eid, []), flight, ops_day,
|
|
178
|
+
)
|
|
179
|
+
if conflict is not None:
|
|
180
|
+
other_iso = conflict.std.isoformat(timespec="minutes")
|
|
181
|
+
out.append(OverrideSuggestion(
|
|
182
|
+
intrusiveness=_W_WAIVE_H10,
|
|
183
|
+
override_type="waive_h10_pair",
|
|
184
|
+
target_staff_name=s.name,
|
|
185
|
+
rationale=(
|
|
186
|
+
f"{s.name} (shift {s.shift_today}) is at "
|
|
187
|
+
f"{current_count}/{cap}; their flt {conflict.flt} "
|
|
188
|
+
f"at {other_iso} is the H10 blocker (gap "
|
|
189
|
+
f"{abs(std_to_ops_day_minutes(conflict.std, conflict.date, ops_day) - std_to_ops_day_minutes(flight.std, flight.date, ops_day))} min)."
|
|
190
|
+
),
|
|
191
|
+
payload={
|
|
192
|
+
"type": "waive_h10_pair",
|
|
193
|
+
"flight": flight.flt,
|
|
194
|
+
"std": flight_std_iso,
|
|
195
|
+
"employee": s.name,
|
|
196
|
+
"other_std": other_iso,
|
|
197
|
+
},
|
|
198
|
+
))
|
|
199
|
+
continue
|
|
200
|
+
# H16: candidate is at the cap (no H10 conflict).
|
|
201
|
+
if current_count >= cap:
|
|
202
|
+
out.append(OverrideSuggestion(
|
|
203
|
+
intrusiveness=_W_RAISE_CAP,
|
|
204
|
+
override_type="raise_cap",
|
|
205
|
+
target_staff_name=s.name,
|
|
206
|
+
rationale=(
|
|
207
|
+
f"{s.name} (shift {s.shift_today}) is at the H16 "
|
|
208
|
+
f"cap {current_count}/{cap}. Allowing this flight "
|
|
209
|
+
"to land 'on top' raises their effective cap by 1 "
|
|
210
|
+
"for this run only."
|
|
211
|
+
),
|
|
212
|
+
payload={
|
|
213
|
+
"type": "raise_cap",
|
|
214
|
+
"flight": flight.flt,
|
|
215
|
+
"std": flight_std_iso,
|
|
216
|
+
"employee": s.name,
|
|
217
|
+
},
|
|
218
|
+
))
|
|
219
|
+
|
|
220
|
+
# Path B: every staff failed eligibility (zero matrix). Inspect
|
|
221
|
+
# diagnose-style: find staff who pass everything EXCEPT F6 (P2F
|
|
222
|
+
# buffer) and propose SkipP2FBuffer.
|
|
223
|
+
if not eligible_ids:
|
|
224
|
+
for s in staff_today:
|
|
225
|
+
if s.shift_today is None:
|
|
226
|
+
continue
|
|
227
|
+
if not _is_p2f_buffer_blocked(flight, s, elig_ctx):
|
|
228
|
+
continue
|
|
229
|
+
# Verify this is the ONLY reason — re-run eligibility with
|
|
230
|
+
# the buffer pretended to be skipped.
|
|
231
|
+
elig_no_buffer = EligibilityContext(
|
|
232
|
+
ops_day=elig_ctx.ops_day,
|
|
233
|
+
p2f_handler_by_shift=elig_ctx.p2f_handler_by_shift,
|
|
234
|
+
p2f_flight_minutes_by_handler={}, # blank → no buffer
|
|
235
|
+
)
|
|
236
|
+
reason = elig_check(flight, s, elig_no_buffer)
|
|
237
|
+
if reason is None:
|
|
238
|
+
out.append(OverrideSuggestion(
|
|
239
|
+
intrusiveness=_W_SKIP_P2F_BUFFER,
|
|
240
|
+
override_type="skip_p2f_buffer",
|
|
241
|
+
target_staff_name=s.name,
|
|
242
|
+
rationale=(
|
|
243
|
+
f"{s.name} (P2F handler) is blocked only by "
|
|
244
|
+
"their D-3hr buffer for this flight. Skipping "
|
|
245
|
+
"the buffer for this single flight would let "
|
|
246
|
+
"them take it."
|
|
247
|
+
),
|
|
248
|
+
payload={
|
|
249
|
+
"type": "skip_p2f_buffer",
|
|
250
|
+
"flight": flight.flt,
|
|
251
|
+
"std": flight_std_iso,
|
|
252
|
+
"employee": s.name,
|
|
253
|
+
},
|
|
254
|
+
))
|
|
255
|
+
|
|
256
|
+
# Path C (INTL only): SkipINTLRemoval doesn't unblock the INTL
|
|
257
|
+
# flight itself (that's already allocated when it's INTL), but
|
|
258
|
+
# if the flight got displaced into UNALLOCATED via a post-pass
|
|
259
|
+
# removal, suggest the override. Detection: flight is plain
|
|
260
|
+
# domestic AND there's an INTL DEP on the same handler within
|
|
261
|
+
# the post-pass removal window.
|
|
262
|
+
if not flight.is_international and not eligible_ids:
|
|
263
|
+
# Check if any INTL DEP within ~3 hours after this flight could
|
|
264
|
+
# be the post-pass remover.
|
|
265
|
+
for assigned_uid, assigned_eid in assignments.items():
|
|
266
|
+
if assigned_uid == flight.unique_id:
|
|
267
|
+
continue
|
|
268
|
+
other_flight = next(
|
|
269
|
+
(f for f in flights_per_staff.get(assigned_eid, [])
|
|
270
|
+
if f.unique_id == assigned_uid),
|
|
271
|
+
None,
|
|
272
|
+
)
|
|
273
|
+
if other_flight is None or not other_flight.is_international:
|
|
274
|
+
continue
|
|
275
|
+
f_min = std_to_ops_day_minutes(
|
|
276
|
+
flight.std, flight.date, ops_day,
|
|
277
|
+
)
|
|
278
|
+
o_min = std_to_ops_day_minutes(
|
|
279
|
+
other_flight.std, other_flight.date, ops_day,
|
|
280
|
+
)
|
|
281
|
+
if 0 <= o_min - f_min <= 180: # within 3 hrs
|
|
282
|
+
out.append(OverrideSuggestion(
|
|
283
|
+
intrusiveness=_W_SKIP_INTL_REMOVAL,
|
|
284
|
+
override_type="skip_intl_removal",
|
|
285
|
+
target_staff_name="",
|
|
286
|
+
rationale=(
|
|
287
|
+
f"INTL flt {other_flight.flt} at "
|
|
288
|
+
f"{other_flight.std.isoformat(timespec='minutes')} "
|
|
289
|
+
"displaced this flight via the post-pass "
|
|
290
|
+
"preceding-flight removal. Skipping that "
|
|
291
|
+
"removal keeps both flights on the same "
|
|
292
|
+
"handler."
|
|
293
|
+
),
|
|
294
|
+
payload={
|
|
295
|
+
"type": "skip_intl_removal",
|
|
296
|
+
"flight": other_flight.flt,
|
|
297
|
+
"std": other_flight.std.isoformat(timespec="minutes"),
|
|
298
|
+
},
|
|
299
|
+
))
|
|
300
|
+
break
|
|
301
|
+
|
|
302
|
+
out.sort(key=lambda s: s.intrusiveness)
|
|
303
|
+
return out[:3] # keep top 3 per flight to avoid choice overload
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------- public entry ----------
|
|
307
|
+
|
|
308
|
+
def recommend_for_unallocated(
|
|
309
|
+
flights: list[FlightInput],
|
|
310
|
+
staff_today: list[StaffMember],
|
|
311
|
+
matrix: dict[str, set[str]],
|
|
312
|
+
assignments: dict[str, str],
|
|
313
|
+
elig_ctx: EligibilityContext,
|
|
314
|
+
ops_day: date_t,
|
|
315
|
+
) -> list[FlightRecommendations]:
|
|
316
|
+
"""Produce ranked override suggestions for every unallocated flight.
|
|
317
|
+
|
|
318
|
+
Returns a list ordered by flight STD ascending. Each entry has at
|
|
319
|
+
most 3 suggestions, sorted by intrusiveness (least first).
|
|
320
|
+
"""
|
|
321
|
+
# Group assigned flights per staff so candidate checks are O(1).
|
|
322
|
+
flights_per_staff: dict[str, list[FlightInput]] = {}
|
|
323
|
+
flight_by_uid = {f.unique_id: f for f in flights}
|
|
324
|
+
for uid, eid in assignments.items():
|
|
325
|
+
f = flight_by_uid.get(uid)
|
|
326
|
+
if f is None:
|
|
327
|
+
continue
|
|
328
|
+
flights_per_staff.setdefault(eid, []).append(f)
|
|
329
|
+
|
|
330
|
+
out: list[FlightRecommendations] = []
|
|
331
|
+
for f in flights:
|
|
332
|
+
if f.unique_id in assignments:
|
|
333
|
+
continue
|
|
334
|
+
suggestions = _suggestions_for_flight(
|
|
335
|
+
f, staff_today, matrix, assignments,
|
|
336
|
+
elig_ctx, flights_per_staff, ops_day,
|
|
337
|
+
)
|
|
338
|
+
out.append(FlightRecommendations(
|
|
339
|
+
flight_id=f.unique_id,
|
|
340
|
+
flt=f.flt,
|
|
341
|
+
dep=f.dep,
|
|
342
|
+
arr=f.arr,
|
|
343
|
+
std_iso=f.std.isoformat(timespec="minutes"),
|
|
344
|
+
is_international=f.is_international,
|
|
345
|
+
suggestions=suggestions,
|
|
346
|
+
))
|
|
347
|
+
out.sort(key=lambda r: r.std_iso)
|
|
348
|
+
return out
|