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.
Files changed (71) hide show
  1. flight_alloc-0.0.1.dist-info/METADATA +11 -0
  2. flight_alloc-0.0.1.dist-info/RECORD +71 -0
  3. flight_alloc-0.0.1.dist-info/WHEEL +5 -0
  4. flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
  5. flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/allocator/__init__.py +6 -0
  8. src/allocator/caps.py +513 -0
  9. src/allocator/eligibility.py +302 -0
  10. src/allocator/greedy_fallback.py +106 -0
  11. src/allocator/invariants.py +124 -0
  12. src/allocator/p2f_priority.py +142 -0
  13. src/allocator/pair_validation.py +108 -0
  14. src/allocator/pairings.py +554 -0
  15. src/allocator/postpass_break.py +366 -0
  16. src/allocator/postpass_intl.py +483 -0
  17. src/allocator/postpass_p2f.py +723 -0
  18. src/allocator/postpass_rebalance.py +244 -0
  19. src/allocator/postsolve.py +549 -0
  20. src/allocator/recommender.py +348 -0
  21. src/allocator/windows.py +377 -0
  22. src/cli.py +41 -0
  23. src/config.py +168 -0
  24. src/greedy_fallback.py +102 -0
  25. src/io/__init__.py +0 -0
  26. src/io/export.py +270 -0
  27. src/io/export_xml.py +66 -0
  28. src/io/readers.py +1048 -0
  29. src/io/roster_library.py +89 -0
  30. src/plan.py +192 -0
  31. src/recommender_staffing.py +329 -0
  32. src/roster_store.py +159 -0
  33. src/schemas.py +1244 -0
  34. src/solver/__init__.py +0 -0
  35. src/solver/allocator_cpsat.py +1412 -0
  36. src/staged_overrides.py +468 -0
  37. src/state.py +494 -0
  38. src/step1_clean_flights.py +286 -0
  39. src/step2_extract_roster.py +316 -0
  40. src/step3_allocate_flights.py +1639 -0
  41. src/web/__init__.py +47 -0
  42. src/web/__main__.py +9 -0
  43. src/web/api/__init__.py +56 -0
  44. src/web/api/export.py +37 -0
  45. src/web/api/inputs.py +122 -0
  46. src/web/api/override_rows.py +138 -0
  47. src/web/api/pages.py +30 -0
  48. src/web/api/readbacks.py +72 -0
  49. src/web/api/recommender.py +72 -0
  50. src/web/api/runs.py +102 -0
  51. src/web/api/settings.py +201 -0
  52. src/web/api/zc.py +117 -0
  53. src/web/core/__init__.py +5 -0
  54. src/web/core/responses.py +91 -0
  55. src/web/core/router.py +167 -0
  56. src/web/core/static_files.py +85 -0
  57. src/web/overrides/__init__.py +66 -0
  58. src/web/overrides/airports.py +261 -0
  59. src/web/overrides/break_time.py +83 -0
  60. src/web/overrides/config_yaml.py +21 -0
  61. src/web/overrides/filters.py +187 -0
  62. src/web/overrides/rows.py +110 -0
  63. src/web/readback/__init__.py +67 -0
  64. src/web/readback/common.py +68 -0
  65. src/web/readback/dashboard.py +83 -0
  66. src/web/readback/planning.py +335 -0
  67. src/web/readback/session.py +158 -0
  68. src/web/readback/tables.py +163 -0
  69. src/web/runner.py +168 -0
  70. src/web/server.py +185 -0
  71. 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