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,377 @@
1
+ """Shift-window tables and time helpers for Step 4.
2
+
3
+ The numbers below are the single source of truth for H7 (tail extensions),
4
+ H9 (ZC report buffers), H10 (15-min spacing, 30 min domestic→INTL and
5
+ P2F↔P2F),
6
+ H12 (international ±60min),
7
+ and H15 (awkward-window routing). They mirror REF_Constraints in the
8
+ unified workbook — keep them in lockstep when either side changes.
9
+
10
+ Time arithmetic uses "ops-day minutes": minutes from 00:00 of the ops day
11
+ (D). A flight at STD 02:00 on calendar D+1 (the N-shift continuation) has
12
+ ops_day_minutes = 120 + 1440 = 1560, which is inside N's window
13
+ [1230, 1740] = [20:30 D, 05:00 D+1]. This convention sidesteps midnight-
14
+ crossing edge cases everywhere downstream.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Iterable
20
+ from datetime import date as date_t
21
+ from datetime import time as time_t
22
+
23
+ from ..schemas import OpsClass, ShiftCode
24
+
25
+ # ---------- shift hours (nominal) ----------
26
+ # (start_offset, end_offset) in minutes from 00:00 of the ops day.
27
+ # N's end is 24*60 + 5*60 = 1740 because it crosses midnight into D+1.
28
+
29
+ SHIFT_NOMINAL_MIN: dict[ShiftCode, tuple[int, int]] = {
30
+ "M": (4 * 60, 12 * 60 + 30), # 04:00 - 12:30
31
+ "M1": (6 * 60, 14 * 60 + 30), # 06:00 - 14:30
32
+ "A": (12 * 60 + 30, 21 * 60), # 12:30 - 21:00
33
+ "A1": (14 * 60 + 30, 23 * 60), # 14:30 - 23:00
34
+ "N": (20 * 60 + 30, 24 * 60 + 5 * 60), # 20:30 - 05:00 D+1
35
+ }
36
+
37
+ # Handover overlap (round-3 update): for STDs in (nominal_end, nominal_end +
38
+ # HANDOVER_WINDOW_MIN], BOTH the prev shift AND the next shift are eligible
39
+ # (soft, not hard). The solver picks via S1 count balance + S5 handover
40
+ # preference. Postsolve uses is_in_tail_ext (same range) to decide whether
41
+ # RELIEVED_BY annotation applies.
42
+ HANDOVER_WINDOW_MIN = 30
43
+
44
+ # ---------- STD distribution windows (user direction 2026-05-12) ----------
45
+ # These windows constrain which shifts a flight's STD can be ALLOCATED to.
46
+ # Distinct from SHIFT_NOMINAL_MIN (which is staff working hours): a shift
47
+ # only takes flights whose STD lies inside this distribution band.
48
+ #
49
+ # M / M1 / N — hard bounds [inner_lo, inner_hi].
50
+ # A / A1 — hard inner band; soft outer extension (extra ~5 min beyond
51
+ # inner_hi). Outer-band assignments are allowed but the solver
52
+ # pays a penalty to minimize their use (S_outer term).
53
+ #
54
+ # All values are ops-day minutes. N wraps midnight: end=29:00 = 05:00 D+1.
55
+ SHIFT_STD_WINDOW_INNER: dict[ShiftCode, tuple[int, int]] = {
56
+ "M": (5 * 60 + 5, 12 * 60 + 55), # 05:05 - 12:55
57
+ "M1": (7 * 60 + 5, 14 * 60 + 55), # 07:05 - 14:55
58
+ # Patch 2026-05-14 (post-Phase-4 unallocated fix): A inner_lo was
59
+ # 13:10, leaving a 15-min coverage gap [12:56, 13:09] where only M1
60
+ # (12 staff) could absorb the rush. 9 unallocated 13:00 flights on
61
+ # the 05-03 dataset traced to M1 saturation. Pulling A's left edge
62
+ # to 13:00 lets the much larger A pool (32 STAFF) absorb the rush.
63
+ "A": (13 * 60, 21 * 60 + 5), # 13:00 - 21:05
64
+ "A1": (15 * 60 + 10, 22 * 60 + 55), # 15:10 - 22:55
65
+ # Same patch on the A->N side: N inner_lo was 21:20, leaving a gap
66
+ # [21:06, 21:19] where only A1 (12 staff) could absorb. Pulling N's
67
+ # left edge to 21:05 lets N share the load.
68
+ "N": (21 * 60 + 5, 24 * 60 + 5 * 60), # 21:05 - 05:00 D+1
69
+ }
70
+ # Soft outer end-extension for A and A1 only. The window grows from
71
+ # inner_hi to outer_hi; entries here override that growth for the listed
72
+ # shifts. Other shifts have outer = inner (no extension).
73
+ SHIFT_STD_WINDOW_OUTER_END: dict[ShiftCode, int] = {
74
+ "A": 21 * 60 + 10, # +5 min to 21:10
75
+ "A1": 23 * 60, # +5 min to 23:00
76
+ }
77
+
78
+ # ---------- shift hours with handover window (H7, round-3 update) ----------
79
+ # Computed from SHIFT_NOMINAL_MIN + HANDOVER_WINDOW_MIN. Uniform 30 min
80
+ # across all shifts — replaces the per-shift D6/r1/r2-1 values.
81
+
82
+ SHIFT_TAIL_END_MIN: dict[ShiftCode, int] = {
83
+ shift: nominal_end + HANDOVER_WINDOW_MIN
84
+ for shift, (_, nominal_end) in SHIFT_NOMINAL_MIN.items()
85
+ }
86
+
87
+ # ---------- ZC report buffers (H9) ----------
88
+ # Half-open intervals [start, end) in ops-day minutes. STDs in either buffer
89
+ # are excluded for ZCs of that shift.
90
+
91
+ ZC_BUFFER_START_MIN: dict[ShiftCode, tuple[int, int]] = {
92
+ "M": (4 * 60, 5 * 60 + 30), # 04:00 - 05:30
93
+ "A": (12 * 60 + 30, 14 * 60), # 12:30 - 14:00
94
+ "M1": (6 * 60, 7 * 60 + 30), # 06:00 - 07:30
95
+ "A1": (14 * 60 + 30, 16 * 60), # 14:30 - 16:00
96
+ "N": (20 * 60 + 30, 22 * 60), # 20:30 - 22:00
97
+ }
98
+
99
+ ZC_BUFFER_END_MIN: dict[ShiftCode, tuple[int, int]] = {
100
+ "M": (11 * 60, 12 * 60 + 30), # 11:00 - 12:30
101
+ "A": (19 * 60 + 30, 21 * 60), # 19:30 - 21:00
102
+ "M1": (13 * 60, 14 * 60 + 30), # 13:00 - 14:30
103
+ "A1": (21 * 60 + 30, 23 * 60), # 21:30 - 23:00
104
+ "N": (24 * 60 + 3 * 60 + 30, 24 * 60 + 5 * 60), # 03:30 - 05:00 D+1
105
+ }
106
+
107
+ # ---------- H15 awkward-window routing ----------
108
+ # Keyed by ops-day minutes; value = tuple of shifts eligible at that STD.
109
+ # Only applies to flights on the ops day itself (not D+1 N-tail flights —
110
+ # those belong to tomorrow's M shift).
111
+
112
+ AWKWARD_ROUTING_MIN: dict[int, tuple[ShiftCode, ...]] = {
113
+ # M -> A boundary
114
+ 12 * 60 + 55: ("M",),
115
+ 13 * 60: ("M", "M1"),
116
+ 13 * 60 + 5: ("M1",),
117
+ 13 * 60 + 10: ("M1", "A"),
118
+ 13 * 60 + 15: ("A",),
119
+ # M1 -> A1 boundary (per D7 sample evidence: A absorbs entirely)
120
+ 14 * 60 + 55: ("A",),
121
+ 15 * 60: ("A",),
122
+ 15 * 60 + 5: ("A",),
123
+ 15 * 60 + 10: ("A",),
124
+ 15 * 60 + 15: ("A",),
125
+ }
126
+
127
+ # ---------- buffer constants ----------
128
+ # H10 (post-round-3 update per user direction 2026-05-10): hard floor
129
+ # raised from 10 → 15 min (no two flights for one handler within 15 min);
130
+ # soft warning band raised from 15 → 20 min so the assigner sees a flag
131
+ # whenever spacing is between 15 and 20 min.
132
+ SPACING_HARD_MIN = 15
133
+ SPACING_SOFT_WARN_MIN = 20
134
+
135
+ # H10 (user direction 2026-09-26): the 15-min floor is strict — the
136
+ # 10-min rush-band relaxation of 2026-05-15 is gone — and a domestic
137
+ # flight followed by an international one needs 30 min, so the handler
138
+ # has time to prepare for the INTL departure. INTL→domestic and
139
+ # INTL→INTL keep the plain 15-min floor.
140
+ SPACING_DOM_TO_INTL_MIN = 30
141
+ # H10 (user direction 2026-09-26): one handler can run two P2F flights
142
+ # only when they are at least 30 min apart.
143
+ SPACING_P2F_PAIR_MIN = 30
144
+ #: Widest gap any pair of flights can need — callers scanning a sorted
145
+ #: schedule can stop once two flights are this far apart.
146
+ SPACING_MAX_MIN = max(SPACING_HARD_MIN, SPACING_DOM_TO_INTL_MIN, SPACING_P2F_PAIR_MIN)
147
+
148
+ #: A flight as the H10 rules see it:
149
+ #: ``(STD in ops-day minutes, is_international, is_p2f)``.
150
+ SpacingKey = tuple[int, bool, bool]
151
+
152
+ # Operationally-dense rush windows (user direction 2026-05-15). They
153
+ # used to relax H10 to 10 min; now they only drive the solver's
154
+ # "prefer a handler whose shift isn't starting or ending" tiebreaker.
155
+ # Each entry is (start_min, end_min) in ops-day minutes, inclusive.
156
+ # Derived from the manual 06-May-2026 allocation's 89 H10 violations
157
+ # (88 of them fall in these six windows).
158
+ RUSH_BANDS_MIN: tuple[tuple[int, int], ...] = (
159
+ (5 * 60, 5 * 60 + 30), # 05:00 - 05:30
160
+ (6 * 60 + 55, 7 * 60 + 30), # 06:55 - 07:30
161
+ (12 * 60 + 55, 13 * 60 + 15), # 12:55 - 13:15
162
+ (17 * 60, 17 * 60 + 15), # 17:00 - 17:15
163
+ (20 * 60, 22 * 60), # 20:00 - 22:00
164
+ (23 * 60 + 15, 23 * 60 + 35), # 23:15 - 23:35
165
+ )
166
+
167
+
168
+ def is_in_rush_band(
169
+ std: time_t, flight_date: date_t, ops_day: date_t,
170
+ ) -> bool:
171
+ """True if a flight's STD lies in one of the rush bands (inclusive
172
+ on both ends)."""
173
+ minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
174
+ return any(lo <= minutes <= hi for lo, hi in RUSH_BANDS_MIN)
175
+
176
+
177
+ def spacing_key(flight, ops_day: date_t) -> SpacingKey:
178
+ """``flight``'s :data:`SpacingKey` — anything with ``std``,
179
+ ``date``, ``is_international`` and ``ops_class`` will do."""
180
+ return (
181
+ std_to_ops_day_minutes(flight.std, flight.date, ops_day),
182
+ bool(flight.is_international),
183
+ flight.ops_class == OpsClass.P2F,
184
+ )
185
+
186
+
187
+ def required_spacing_min(a: SpacingKey, b: SpacingKey) -> int:
188
+ """Required min gap between two same-staff flights, in either
189
+ order: 30 when the earlier one is domestic and the later one
190
+ international, 30 when both are P2F, else 15."""
191
+ earlier, later = (a, b) if a[0] <= b[0] else (b, a)
192
+ gap = SPACING_HARD_MIN
193
+ if later[1] and not earlier[1]:
194
+ gap = max(gap, SPACING_DOM_TO_INTL_MIN)
195
+ if earlier[2] and later[2]:
196
+ gap = max(gap, SPACING_P2F_PAIR_MIN)
197
+ return gap
198
+
199
+
200
+ def spacing_clear(key: SpacingKey, others: Iterable[SpacingKey]) -> bool:
201
+ """True when a flight keeps the H10 gap to every one of ``others``
202
+ — the same staff member's other flights."""
203
+ return all(
204
+ abs(key[0] - other[0]) >= required_spacing_min(key, other)
205
+ for other in others
206
+ )
207
+
208
+
209
+ def shift_boundary_within_30min(
210
+ shift: ShiftCode, std: time_t, flight_date: date_t, ops_day: date_t,
211
+ ) -> bool:
212
+ """True iff ``shift``'s nominal START or END lies within ±30 min of
213
+ the flight's STD.
214
+
215
+ Used by the band-targeted "prefer stable shift" soft preference
216
+ (user direction 2026-05-15): within the rush bands, the
217
+ solver should prefer to assign a flight to a staff whose shift is
218
+ NOT in transition. A shift is "in transition" at STD T when its
219
+ nominal start or end is within 30 minutes of T.
220
+
221
+ Handles N's midnight wrap: N's nominal end is at ops-day-min 1740
222
+ (= 05:00 D+1). A D-day flight at 04:50 (ops-min 290) is far from
223
+ that 1740 in the linear sense, but std_to_ops_day_minutes adds
224
+ 1440 when ``flight_date == ops_day + 1``, so the comparison stays
225
+ correct on either side of midnight.
226
+ """
227
+ nominal = SHIFT_NOMINAL_MIN.get(shift)
228
+ if nominal is None:
229
+ return False
230
+ start_min, end_min = nominal
231
+ minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
232
+ return abs(minutes - start_min) <= 30 or abs(minutes - end_min) <= 30
233
+
234
+ # H4 P2F buffer (user direction 2026-09-26): a P2F handler takes no
235
+ # normal flights from 2 hrs before to 1 hr after each of their P2F
236
+ # flights' STD, both ends inclusive. Replaces the round-3 D-3hrs ±15
237
+ # hard block and the D-1hr / D+20min ±15 max-1 windows. Enforced as an
238
+ # eligibility filter (eligibility.py F6), so the solver and every
239
+ # post-pass that checks eligibility respect it.
240
+ P2F_BLOCK_BEFORE_MIN = 120
241
+ P2F_BLOCK_AFTER_MIN = 60
242
+
243
+
244
+ def in_p2f_block(std_min: int, p2f_std_mins: Iterable[int]) -> bool:
245
+ """True when a normal flight at ``std_min`` falls inside the
246
+ no-normal-flight window of any of a handler's P2F flights
247
+ (``p2f_std_mins``; all in ops-day minutes)."""
248
+ return any(
249
+ p - P2F_BLOCK_BEFORE_MIN <= std_min <= p + P2F_BLOCK_AFTER_MIN
250
+ for p in p2f_std_mins
251
+ )
252
+
253
+ # International ±60 min from shift start/end (H12, correction r2-3).
254
+ INTL_SHIFT_EDGE_MIN = 60
255
+
256
+
257
+ # ---------- time-arithmetic helpers ----------
258
+
259
+ def std_to_ops_day_minutes(std: time_t, flight_date: date_t, ops_day: date_t) -> int:
260
+ """Convert (calendar date, STD) to minutes-from-00:00-of-ops-day.
261
+
262
+ A 02:00 STD on calendar (ops_day + 1) returns 1560, which N's window
263
+ [1230, 1740] correctly contains.
264
+ """
265
+ base = std.hour * 60 + std.minute
266
+ delta_days = (flight_date - ops_day).days
267
+ return base + delta_days * 1440
268
+
269
+
270
+ def is_in_shift(
271
+ std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
272
+ ) -> bool:
273
+ """True if (flight_date, std) falls within ``shift``'s window+tail-ext on
274
+ ``ops_day``. Inclusive of both endpoints — a 13:00 STD is still M's
275
+ (M's tail ends at 13:00 sharp, so STD=13:00 is the last allowed)."""
276
+ minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
277
+ start = SHIFT_NOMINAL_MIN[shift][0]
278
+ end = SHIFT_TAIL_END_MIN[shift]
279
+ return start <= minutes <= end
280
+
281
+
282
+ def is_in_zc_buffer(
283
+ std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
284
+ ) -> bool:
285
+ """True if STD is in the ZC's start-of-shift OR end-of-shift report
286
+ buffer for ``shift``. Half-open [start, end), so the buffer's upper
287
+ edge is the first eligible STD."""
288
+ minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
289
+ s1, e1 = ZC_BUFFER_START_MIN[shift]
290
+ s2, e2 = ZC_BUFFER_END_MIN[shift]
291
+ return (s1 <= minutes < e1) or (s2 <= minutes < e2)
292
+
293
+
294
+ def awkward_eligible_shifts(
295
+ std: time_t, flight_date: date_t, ops_day: date_t,
296
+ ) -> tuple[ShiftCode, ...] | None:
297
+ """Return the tuple of shifts allowed at this STD per H15, OR None if
298
+ the STD is not in any awkward window. D+1 flights never trigger H15
299
+ (next-day's M owns them, not today's solver)."""
300
+ if flight_date != ops_day:
301
+ return None
302
+ minutes = std.hour * 60 + std.minute
303
+ return AWKWARD_ROUTING_MIN.get(minutes)
304
+
305
+
306
+ def is_in_tail_ext(
307
+ std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
308
+ ) -> bool:
309
+ """True if STD is in the tail-extension portion of ``shift`` —
310
+ i.e., past the nominal end but within the configured tail end.
311
+
312
+ Tail-ext flights are still owned by ``shift`` (STAFF column points
313
+ to the prev-shift handler), but the next shift's paired person does
314
+ the post-airborne (D+20) work and is recorded as RELIEVED_BY.
315
+ Flights at or before nominal end are NOT relieved (the prev staff
316
+ handles their own D+20 within shift).
317
+
318
+ Note: the uniform 30-min handover window is applied to N as well, so
319
+ 05:01-05:30 on D+1 return True for N. Earlier comments claimed N has
320
+ no tail extension; postsolve is unaffected because it has no relief
321
+ shift for N. Behaviour is pinned in tests/test_windows.py.
322
+ """
323
+ minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
324
+ nominal_end = SHIFT_NOMINAL_MIN[shift][1]
325
+ tail_end = SHIFT_TAIL_END_MIN[shift]
326
+ return nominal_end < minutes <= tail_end
327
+
328
+
329
+ def is_within_intl_shift_edge(
330
+ std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
331
+ ) -> bool:
332
+ """H12 (correction r2-3): for international flights, exclude staff
333
+ whose STD is within 60 min of their shift's nominal start or end.
334
+
335
+ Strict-less-than / strict-greater-than per the correction's literal
336
+ wording: STD < shift_start + 60min OR STD > shift_end - 60min.
337
+
338
+ Retired 2026-05-12 (F9 dropped in eligibility.py). Kept here for
339
+ history / tests; not called by the live pipeline.
340
+ """
341
+ minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
342
+ start, end = SHIFT_NOMINAL_MIN[shift]
343
+ return minutes < start + INTL_SHIFT_EDGE_MIN or minutes > end - INTL_SHIFT_EDGE_MIN
344
+
345
+
346
+ def is_in_std_window(
347
+ std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
348
+ ) -> bool:
349
+ """True if the flight's STD lies in ``shift``'s STD distribution
350
+ window (inner OR outer band; outer applies to A and A1 only).
351
+
352
+ Per user direction 2026-05-12 §1: a flight is eligible for a shift
353
+ only when its STD falls in that shift's STD window. This is a
354
+ NARROWER check than ``is_in_shift`` (which spans the full shift
355
+ hours + handover overlap). Replaces F5 in eligibility.py.
356
+ """
357
+ minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
358
+ lo, hi = SHIFT_STD_WINDOW_INNER[shift]
359
+ outer_hi = SHIFT_STD_WINDOW_OUTER_END.get(shift, hi)
360
+ return lo <= minutes <= outer_hi
361
+
362
+
363
+ def is_in_std_outer_band(
364
+ std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
365
+ ) -> bool:
366
+ """True if STD lies in the soft outer band (inner_hi, outer_hi].
367
+
368
+ Used by the solver to penalize outer-band assignments via a soft
369
+ objective term — A and A1 only have non-empty bands. Other shifts
370
+ return False (no outer band defined).
371
+ """
372
+ if shift not in SHIFT_STD_WINDOW_OUTER_END:
373
+ return False
374
+ minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
375
+ _, inner_hi = SHIFT_STD_WINDOW_INNER[shift]
376
+ outer_hi = SHIFT_STD_WINDOW_OUTER_END[shift]
377
+ return inner_hi < minutes <= outer_hi
src/cli.py ADDED
@@ -0,0 +1,41 @@
1
+ """Launcher for the Flight Allocation console.
2
+
3
+ State lives in the server process for the length of a session, so there
4
+ is nothing for a per-stage CLI command to read or write — the stages are
5
+ driven from the dashboard. This module exists to start the UI.
6
+
7
+ python -m src.cli # launch the console
8
+ python -m src.cli --port 9000 --no-browser
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+
16
+ from .web.server import DEFAULT_HOST, DEFAULT_PORT, serve
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ p = argparse.ArgumentParser(
21
+ prog="flight-alloc",
22
+ description="Launch the Flight Allocation console (local web UI).",
23
+ )
24
+ p.add_argument("--host", default=DEFAULT_HOST,
25
+ help=f"bind address (default {DEFAULT_HOST})")
26
+ p.add_argument("--port", type=int, default=DEFAULT_PORT,
27
+ help=f"bind port (default {DEFAULT_PORT})")
28
+ p.add_argument("--no-browser", action="store_true",
29
+ help="do not auto-open the browser")
30
+ # Accepted and ignored so an old shortcut like `flight-alloc web`
31
+ # still launches instead of erroring out.
32
+ p.add_argument("command", nargs="?", default="web",
33
+ help=argparse.SUPPRESS)
34
+ args = p.parse_args(argv)
35
+
36
+ serve(host=args.host, port=args.port, open_browser=not args.no_browser)
37
+ return 0
38
+
39
+
40
+ if __name__ == "__main__":
41
+ sys.exit(main())
src/config.py ADDED
@@ -0,0 +1,168 @@
1
+ """Load and validate ``configs/config.yml`` (plan §1.5.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ #: Floating mid-shift break length, in minutes. The bounds are shared by
13
+ #: the config model below and the Setup sidebar's edit form: under 15 is
14
+ #: shorter than the H10 flight spacing, and every shift is 8.5 h with the
15
+ #: first and last hour kept break-free.
16
+ BREAK_MINUTES_DEFAULT = 30
17
+ BREAK_MINUTES_LOWEST = 15
18
+ BREAK_MINUTES_HIGHEST = 120
19
+
20
+
21
+ class _Frozen(BaseModel):
22
+ model_config = ConfigDict(frozen=True, extra="forbid")
23
+
24
+
25
+ class AllocationConfig(_Frozen):
26
+ cycle_year: int
27
+ default_date: date
28
+
29
+
30
+ class SolverConfig(_Frozen):
31
+ max_seconds: int
32
+ deficit_penalty_lambda: int
33
+ # CPU cores handed to CP-SAT for parallel portfolio search. 0 (default)
34
+ # means "auto": use every available core, capped at 8 (see
35
+ # solver/allocator_cpsat.py — OR-Tools' own guidance is that portfolio
36
+ # search gets most of its benefit by ~8 diverse workers). Set explicitly
37
+ # only if you know this machine should use more/fewer.
38
+ num_workers: int = 0
39
+
40
+
41
+ class StateConfig(_Frozen):
42
+ staleness_max_days: int
43
+
44
+
45
+ class SVPortalConfig(_Frozen):
46
+ sheet: str
47
+ columns: dict[str, str]
48
+ date_format: str
49
+ pax_regex: str
50
+ # Phase 3 / Change 1 (2026-05-14, INTL overhaul): flights with
51
+ # ``DEP ∈ routing_drop_dep_codes`` are dropped at Step 1 extraction.
52
+ # ARR-side highlighting was removed in the same amendment — Gulf-3
53
+ # ARR flights are plain domestic now.
54
+ routing_drop_dep_codes: list[str] = []
55
+ # Step 1 tags a flight is_international=True if **DEP** is in this
56
+ # list (Change 9, 2026-05-14: DEP-side only — was DEP OR ARR).
57
+ international_airport_codes: list[str] = []
58
+
59
+
60
+ class StaffRosterConfig(_Frozen):
61
+ sheet: str
62
+ name_column: str
63
+ date_header_format: str
64
+ skip_blank_columns: bool
65
+
66
+
67
+ class AMRosterConfig(_Frozen):
68
+ sheet_glob: str
69
+ name_column: str
70
+ date_header_format: str
71
+ subheader_rows_to_skip: int
72
+
73
+
74
+ class IOConfig(_Frozen):
75
+ sv_portal: SVPortalConfig
76
+ staff_roster: StaffRosterConfig
77
+ am_roster: AMRosterConfig
78
+
79
+
80
+ class StatusConfig(_Frozen):
81
+ aliases: dict[str, str]
82
+ shifts: list[str]
83
+ non_shift_recognized: list[str]
84
+ am_shifts: list[str]
85
+ zc_shifts: list[str]
86
+ unrecognized_treatment: str
87
+
88
+
89
+ class P2FAdjustmentConfig(_Frozen):
90
+ """User direction 2026-05-12 §3 — P2F post-pass tolerance window.
91
+
92
+ For each P2F flight assigned to a handler, the post-pass removes:
93
+ * 2 flights with STD within ±tolerance_minutes of (P2F_STD - 3h)
94
+ * 1 flight with STD within ±tolerance_minutes of (P2F_STD - 1h)
95
+ * 1 flight with STD within ±tolerance_minutes of (P2F_STD + 20min)
96
+ Removed flights go to the §4 redistribution pool. Partial removal
97
+ allowed (e.g., only 1 of 2 candidates found within tolerance).
98
+
99
+ 2026-05-28 (user direction): ``logic_v2`` switches the P2F post-pass
100
+ from the anchored 3-point model (D-3h / D-1h / D+20m, host-vs-owner
101
+ split) to a single union window per P2F flight. For each P2F at
102
+ STD=T, the entire P2F handler chain (owner shift's handler +
103
+ pre-planner shift's handler) loses ALL their flights in [T-2h, T+1h]
104
+ — the P2F flight itself stays with the owner. Removed flights enter
105
+ the redistribution pool. When ``logic_v2: true`` the P2F post-pass
106
+ also runs BEFORE the INTL post-pass (default order is INTL first)
107
+ so INTL D-75 removal doesn't waste a slot the P2F window would
108
+ already free.
109
+ """
110
+
111
+ tolerance_minutes: int = 15
112
+ logic_v2: bool = False
113
+
114
+
115
+ class BreakPassConfig(_Frozen):
116
+ """Floating mid-shift break (``allocator/postpass_break.py``). Every
117
+ on-shift staff member gets one flight-free window of
118
+ ``length_minutes``. Editable from the Setup sidebar."""
119
+
120
+ length_minutes: int = Field(
121
+ default=BREAK_MINUTES_DEFAULT,
122
+ ge=BREAK_MINUTES_LOWEST,
123
+ le=BREAK_MINUTES_HIGHEST,
124
+ )
125
+
126
+
127
+ class Config(_Frozen):
128
+ allocation: AllocationConfig
129
+ # Task 2a (2026-05-12): required_staffing removed. The per-shift
130
+ # "people needed" baseline is now derived at display time from the
131
+ # current preferred targets and the day's flight count
132
+ # (ceil(flights / preferred_per_staff)). Editable values for
133
+ # per-(shift, role) preferred / acceptable / cap live in
134
+ # configs/shift_limits.json — see allocator/caps.py.
135
+ solver: SolverConfig
136
+ state: StateConfig
137
+ io: IOConfig
138
+ status: StatusConfig
139
+ ops_class_by_aircraft_type: dict[str, str] = {}
140
+ # GULF classification (user direction 2026-05-19): a flight is GULF
141
+ # when EITHER its DEP airport (3-letter) is in
142
+ # ``ops_class_gulf_dep_codes`` (default AUH / DOH / DXB), OR its
143
+ # Aircraft Owner / TYPE letter equals one of
144
+ # ``ops_class_gulf_owner_codes`` (default QR). GULF lands on the
145
+ # dedicated GULF class for reference but is NOT read
146
+ # by Step 3 — extract-only, never allocated. Replaces the legacy
147
+ # ``routing_drop_dep_codes`` outright drop.
148
+ ops_class_gulf_dep_codes: list[str] = []
149
+ ops_class_gulf_owner_codes: list[str] = []
150
+ # User-defined extraction filters (per user direction 2026-05-25).
151
+ # Each filter is a dict with any subset of keys: ``ac_type``,
152
+ # ``ac_owner``, ``ac``, ``dep``, ``arr``, plus an optional
153
+ # ``custom_header`` + ``custom_value`` for matching arbitrary
154
+ # SV-portal columns. Empty / missing field means "don't match on
155
+ # that field". A flight matches the filter iff ALL specified fields
156
+ # match (AND semantics, case-insensitive exact match). When a
157
+ # flight matches any filter it routes to the GULF class — the
158
+ # existing extract-only sheet — same as the static Gulf codes
159
+ # above. Edits via the Override drawer's Extraction Filters form
160
+ # or directly in config.yml.
161
+ extraction_filters: list[dict[str, str]] = []
162
+ p2f_adjustment: P2FAdjustmentConfig = P2FAdjustmentConfig()
163
+ break_pass: BreakPassConfig = BreakPassConfig()
164
+
165
+
166
+ def load_config(path: Path | str) -> Config:
167
+ raw: Any = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
168
+ return Config.model_validate(raw)
src/greedy_fallback.py ADDED
@@ -0,0 +1,102 @@
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
+ H4 P2F partial-block windows, 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, StaffMember
40
+ from .caps import hard_cap_for
41
+ from .windows import required_spacing_min, 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
+ Walks flights in chronological (STD) order; for each, picks the
54
+ least-loaded eligible staff who doesn't violate that staff's
55
+ spacing or hard cap. No optimality guarantee and no workload
56
+ balancing beyond "prefer whoever has fewer flights so far" — this
57
+ is a safety net, not a replacement for the CP-SAT model.
58
+
59
+ Returns ``{flight.unique_id: employee_id}`` for whatever it
60
+ managed to place. A flight with no eligible-and-available staff is
61
+ simply absent from the result, exactly like a partial CP-SAT
62
+ solve — the caller's existing UNALLOCATED bookkeeping picks it up
63
+ the same way either way.
64
+ """
65
+ staff_by_id = {s.employee_id: s for s in staff}
66
+ caps = {s.employee_id: hard_cap_for(s) for s in staff}
67
+ counts: dict[str, int] = defaultdict(int)
68
+ # Per-staff list of assigned STD minutes for H10 spacing checks.
69
+ assigned_by_staff: dict[str, list[int]] = defaultdict(list)
70
+
71
+ ordered = sorted(
72
+ flights,
73
+ key=lambda f: std_to_ops_day_minutes(f.std, f.date, ops_day),
74
+ )
75
+
76
+ assignments: dict[str, str] = {}
77
+ for f in ordered:
78
+ std_min = std_to_ops_day_minutes(f.std, f.date, ops_day)
79
+ candidates: list[str] = []
80
+ for eid in eligibility.get(f.unique_id, ()):
81
+ if staff_by_id.get(eid) is None:
82
+ continue
83
+ cap = caps.get(eid, 0)
84
+ if cap <= 0 or counts[eid] >= cap:
85
+ continue
86
+ ok = True
87
+ for other_std in assigned_by_staff[eid]:
88
+ lo, hi = min(std_min, other_std), max(std_min, other_std)
89
+ if hi - lo < required_spacing_min(lo, hi):
90
+ ok = False
91
+ break
92
+ if ok:
93
+ candidates.append(eid)
94
+ if not candidates:
95
+ continue
96
+ candidates.sort(key=lambda eid: (counts[eid], eid))
97
+ pick = candidates[0]
98
+ assignments[f.unique_id] = pick
99
+ counts[pick] += 1
100
+ assigned_by_staff[pick].append(std_min)
101
+
102
+ return assignments
src/io/__init__.py ADDED
File without changes