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,366 @@
1
+ """Floating-break post-pass (2026-09-23, soft / best-effort).
2
+
3
+ Gives each on-shift staff member a floating break somewhere mid-shift
4
+ (``break_pass.length_minutes`` in config.yml, 30 min by default),
5
+ WITHOUT changing anyone's flight count — it only performs 1-for-1
6
+ SWAPS between staff in the same (shift, role) bucket. Because
7
+ a swap is always "I give you one flight, you give me one flight",
8
+ both people's totals are unchanged, so the workload spread that H18 /
9
+ ``postpass_rebalance`` already achieved is left completely intact.
10
+
11
+ Deliberately NOT built into the CP-SAT model: that would need a new
12
+ break-window variable plus break/flight overlap bookkeeping for
13
+ essentially every (flight, staff) pair in the eligibility matrix —
14
+ tens of thousands of new variables and constraints on top of a solve
15
+ that already runs ~400-700s. The risk is the solve slows down enough
16
+ to threaten the very H18 spread this pass exists to protect. A
17
+ lightweight post-pass swap gets the same practical outcome (everyone
18
+ gets a real, contiguous, no-flight window) at a fraction of the cost,
19
+ with the trade-off that it's best-effort: if no eligible swap partner
20
+ exists for a flight sitting in someone's best available window, that
21
+ flight is left in place and the break is reported as partial/absent
22
+ rather than forced.
23
+
24
+ Order of operations in the pipeline matters: this runs LAST, after
25
+ P2F, INTL, and the workload rebalance pass, so it's swapping against
26
+ the final, already-balanced schedule.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import contextlib
32
+ from collections import defaultdict
33
+ from dataclasses import dataclass, field
34
+ from datetime import date as date_t
35
+
36
+ from ..config import BREAK_MINUTES_DEFAULT
37
+ from ..schemas import (
38
+ AllocationRow,
39
+ FlightInput,
40
+ OpsClass,
41
+ Role,
42
+ ShiftCode,
43
+ StaffMember,
44
+ )
45
+ from .eligibility import sheet_target_for
46
+ from .windows import (
47
+ SHIFT_NOMINAL_MIN,
48
+ SpacingKey,
49
+ spacing_clear,
50
+ spacing_key,
51
+ std_to_ops_day_minutes,
52
+ )
53
+
54
+ # Default only — the Allocate run passes the configured length.
55
+ BREAK_LEN_MIN = BREAK_MINUTES_DEFAULT
56
+ # Keep the break out of the first/last hour of the shift so it reads as
57
+ # a genuine mid-shift break, not a shift-boundary artifact.
58
+ EDGE_MARGIN_MIN = 60
59
+
60
+
61
+ def _fmt(ops_min: int) -> str:
62
+ day_offset, m = divmod(ops_min, 1440)
63
+ h, mm = divmod(m, 60)
64
+ suffix = " (+1d)" if day_offset else ""
65
+ return f"{h:02d}:{mm:02d}{suffix}"
66
+
67
+
68
+ @dataclass
69
+ class BreakSummary:
70
+ n_staff_considered: int = 0
71
+ n_clean: int = 0 # already had a natural break-length gap
72
+ n_created_via_swap: int = 0 # fully cleared via 1+ swaps
73
+ n_partial: int = 0 # some conflicts swapped, some left
74
+ n_not_found: int = 0 # no window could be cleared at all
75
+ n_swaps: int = 0
76
+ workload_notes: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list))
77
+ log_lines: list[str] = field(default_factory=list)
78
+
79
+ def note(self, staff_id: str, reason: str) -> None:
80
+ self.workload_notes[staff_id].append(reason)
81
+
82
+ def log(self, msg: str) -> None:
83
+ self.log_lines.append(msg)
84
+
85
+
86
+ def _transferable(flight: FlightInput | None) -> bool:
87
+ """Only plain-domestic flights are swap candidates — P2F routing
88
+ is dedicated to specific handlers and isn't touched here."""
89
+ if flight is None:
90
+ return False
91
+ return flight.ops_class != OpsClass.P2F
92
+
93
+
94
+ def _best_window(
95
+ stds: list[int], lo: int, hi: int, break_len: int,
96
+ ) -> tuple[int, list[int]] | None:
97
+ """Find the break_len-minute window inside [lo, hi] that overlaps
98
+ the fewest of ``stds`` (a staff member's currently-assigned STDs,
99
+ in ops-day minutes). Returns (window_start, conflicting_stds), or
100
+ None if [lo, hi] is too narrow to fit a window at all.
101
+
102
+ Candidate starts: the range floor, the range ceiling minus the
103
+ break length, and "right after each existing flight" — cheap and
104
+ sufficient since we only need the true minimum, not every window.
105
+ """
106
+ if hi - lo < break_len:
107
+ return None
108
+ candidates = {lo, hi - break_len}
109
+ for s in stds:
110
+ c = s + 1
111
+ if lo <= c <= hi - break_len:
112
+ candidates.add(c)
113
+ best: tuple[int, list[int]] | None = None
114
+ for c in sorted(candidates):
115
+ conflicts = [s for s in stds if c <= s < c + break_len]
116
+ if best is None or len(conflicts) < len(best[1]):
117
+ best = (c, conflicts)
118
+ if best[1] == []:
119
+ break
120
+ return best
121
+
122
+
123
+ def apply_break_pass(
124
+ rows: list[AllocationRow],
125
+ flights: list[FlightInput],
126
+ staff_today: list[StaffMember],
127
+ matrix: dict[str, set[str]],
128
+ d_day: date_t,
129
+ break_len: int = BREAK_LEN_MIN,
130
+ edge_margin: int = EDGE_MARGIN_MIN,
131
+ ) -> tuple[list[AllocationRow], BreakSummary]:
132
+ summary = BreakSummary()
133
+ flight_by_uid = {f.unique_id: f for f in flights}
134
+ staff_by_id = {s.employee_id: s for s in staff_today}
135
+
136
+ rstate: dict[str, dict] = {}
137
+ for r in rows:
138
+ uid = (
139
+ f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
140
+ f"|{r.date.isoformat()}"
141
+ )
142
+ rstate[uid] = {
143
+ "row": r,
144
+ "staff_id": r.staff_employee_id,
145
+ "staff_name": r.staff_name,
146
+ "planned_by_emp": r.planned_by_employee_id,
147
+ "planned_by_name": r.planned_by_name,
148
+ "relieved_by_emp": r.relieved_by_employee_id,
149
+ "relieved_by_name": r.relieved_by_name,
150
+ "warning": r.warning,
151
+ "sheet_target": r.sheet_target,
152
+ }
153
+
154
+ # Per-staff SpacingKey list + a quick uid lookup by (staff, std).
155
+ staff_stds: dict[str, list[SpacingKey]] = defaultdict(list)
156
+ uid_by_staff_std: dict[tuple[str, int], list[str]] = defaultdict(list)
157
+ for uid, st in rstate.items():
158
+ if not st["staff_id"]:
159
+ continue
160
+ f_ref = flight_by_uid.get(uid)
161
+ if f_ref is None:
162
+ continue
163
+ key = spacing_key(f_ref, d_day)
164
+ m = key[0]
165
+ staff_stds[st["staff_id"]].append(key)
166
+ uid_by_staff_std[(st["staff_id"], m)].append(uid)
167
+
168
+ buckets: dict[tuple[ShiftCode, Role], list[StaffMember]] = defaultdict(list)
169
+ for s in staff_today:
170
+ if s.shift_today is None or s.role == Role.AM:
171
+ continue
172
+ buckets[(s.shift_today, s.role)].append(s)
173
+
174
+ assigned_break: dict[str, tuple[int, int]] = {}
175
+
176
+ def _spacing_ok(
177
+ staff_id: str, new_key: SpacingKey, skip_std: int | None = None,
178
+ ) -> bool:
179
+ return spacing_clear(new_key, (
180
+ k for k in staff_stds.get(staff_id, ())
181
+ if skip_std is None or k[0] != skip_std
182
+ ))
183
+
184
+ def _during_break(staff_id: str, std: int) -> bool:
185
+ win = assigned_break.get(staff_id)
186
+ return win is not None and win[0] <= std < win[1]
187
+
188
+ def _do_swap(uid_a: str, staff_a: str, uid_b: str, staff_b: str) -> None:
189
+ """Give ``uid_a``'s flight to ``staff_b`` and ``uid_b``'s to
190
+ ``staff_a``. Keeps both counts, staff_stds, and rstate in sync."""
191
+ st_a, st_b = rstate[uid_a], rstate[uid_b]
192
+ f_a, f_b = flight_by_uid[uid_a], flight_by_uid[uid_b]
193
+ std_a = std_to_ops_day_minutes(f_a.std, f_a.date, d_day)
194
+ std_b = std_to_ops_day_minutes(f_b.std, f_b.date, d_day)
195
+ recip_a = staff_by_id[staff_b] # receives uid_a
196
+ recip_b = staff_by_id[staff_a] # receives uid_b
197
+
198
+ entry_a = spacing_key(f_a, d_day)
199
+ entry_b = spacing_key(f_b, d_day)
200
+ with contextlib.suppress(ValueError):
201
+ staff_stds[staff_a].remove(entry_a)
202
+ with contextlib.suppress(ValueError):
203
+ staff_stds[staff_b].remove(entry_b)
204
+ staff_stds[staff_a].append(entry_b)
205
+ staff_stds[staff_b].append(entry_a)
206
+
207
+ st_a["staff_id"], st_a["staff_name"] = recip_a.employee_id, recip_a.name
208
+ st_a["sheet_target"] = sheet_target_for(f_a, recip_a)
209
+ st_b["staff_id"], st_b["staff_name"] = recip_b.employee_id, recip_b.name
210
+ st_b["sheet_target"] = sheet_target_for(f_b, recip_b)
211
+
212
+ for (shift_code, role), bucket in sorted(
213
+ buckets.items(), key=lambda kv: (kv[0][0], kv[0][1].value),
214
+ ):
215
+ nominal = SHIFT_NOMINAL_MIN.get(shift_code)
216
+ if nominal is None:
217
+ continue
218
+ lo = nominal[0] + edge_margin
219
+ hi = nominal[1] - edge_margin
220
+ for s in sorted(bucket, key=lambda x: x.name):
221
+ summary.n_staff_considered += 1
222
+ window = _best_window(
223
+ [k[0] for k in staff_stds.get(s.employee_id, [])], lo, hi, break_len,
224
+ )
225
+ if window is None:
226
+ summary.n_not_found += 1
227
+ summary.log(
228
+ f"{s.name} ({shift_code}/{role.value}): shift too "
229
+ f"short for a {break_len}-min mid-shift window "
230
+ f"(margin={edge_margin})"
231
+ )
232
+ continue
233
+ win_start, conflicts = window
234
+ win_end = win_start + break_len
235
+
236
+ if not conflicts:
237
+ assigned_break[s.employee_id] = (win_start, win_end)
238
+ summary.n_clean += 1
239
+ summary.note(
240
+ s.employee_id,
241
+ f"Break: {_fmt(win_start)}-{_fmt(win_end)} (already clear)",
242
+ )
243
+ continue
244
+
245
+ resolved: list[int] = []
246
+ unresolved: list[int] = []
247
+ for conflict_std in conflicts:
248
+ donor_uids = uid_by_staff_std.get((s.employee_id, conflict_std), [])
249
+ donor_uid = next(
250
+ (u for u in donor_uids if _transferable(flight_by_uid.get(u))),
251
+ None,
252
+ )
253
+ if donor_uid is None:
254
+ unresolved.append(conflict_std)
255
+ continue
256
+ f = flight_by_uid[donor_uid]
257
+ eligible_for_f = matrix.get(donor_uid, set())
258
+
259
+ swapped = False
260
+ for peer in sorted(bucket, key=lambda x: x.name):
261
+ if peer.employee_id == s.employee_id:
262
+ continue
263
+ if peer.employee_id not in eligible_for_f:
264
+ continue
265
+ if _during_break(peer.employee_id, conflict_std):
266
+ continue
267
+ if not _spacing_ok(peer.employee_id, spacing_key(f, d_day)):
268
+ continue
269
+ # Find a plain-domestic flight of peer's, outside s's
270
+ # break window, that s is eligible for and that fits
271
+ # s's schedule once the conflict flight is removed.
272
+ peer_uids = sorted(
273
+ (
274
+ u for u, st in rstate.items()
275
+ if st["staff_id"] == peer.employee_id
276
+ and _transferable(flight_by_uid.get(u))
277
+ ),
278
+ key=lambda u: std_to_ops_day_minutes(
279
+ flight_by_uid[u].std, flight_by_uid[u].date, d_day,
280
+ ),
281
+ )
282
+ for g_uid in peer_uids:
283
+ g = flight_by_uid[g_uid]
284
+ g_std = std_to_ops_day_minutes(g.std, g.date, d_day)
285
+ if win_start <= g_std < win_end:
286
+ continue # would just create a new conflict
287
+ if s.employee_id not in matrix.get(g_uid, set()):
288
+ continue
289
+ if not _spacing_ok(
290
+ s.employee_id, spacing_key(g, d_day),
291
+ skip_std=conflict_std,
292
+ ):
293
+ continue
294
+ _do_swap(donor_uid, s.employee_id, g_uid, peer.employee_id)
295
+ summary.n_swaps += 1
296
+ summary.note(
297
+ s.employee_id,
298
+ f"Break: swapped FLT {f.flt} to {peer.name} "
299
+ f"for FLT {g.flt}, to clear "
300
+ f"{_fmt(win_start)}-{_fmt(win_end)} break",
301
+ )
302
+ summary.note(
303
+ peer.employee_id,
304
+ f"Break: swapped FLT {g.flt} to {s.name} "
305
+ f"for FLT {f.flt}, to help their "
306
+ f"{_fmt(win_start)}-{_fmt(win_end)} break",
307
+ )
308
+ summary.log(
309
+ f"{shift_code}/{role.value}: break-swap "
310
+ f"{s.name}<->{peer.name}: {f.flt}<->{g.flt}"
311
+ )
312
+ swapped = True
313
+ break
314
+ if swapped:
315
+ break
316
+ if swapped:
317
+ resolved.append(conflict_std)
318
+ else:
319
+ unresolved.append(conflict_std)
320
+
321
+ if not unresolved:
322
+ assigned_break[s.employee_id] = (win_start, win_end)
323
+ summary.n_created_via_swap += 1
324
+ summary.note(
325
+ s.employee_id,
326
+ f"Break: {_fmt(win_start)}-{_fmt(win_end)} "
327
+ f"(cleared via {len(resolved)} swap(s))",
328
+ )
329
+ elif resolved:
330
+ summary.n_partial += 1
331
+ summary.note(
332
+ s.employee_id,
333
+ f"Break: best window {_fmt(win_start)}-{_fmt(win_end)} "
334
+ f"still has {len(unresolved)} flight(s) — no eligible "
335
+ "swap partner found for the rest",
336
+ )
337
+ else:
338
+ summary.n_not_found += 1
339
+ summary.note(
340
+ s.employee_id,
341
+ f"Break: no eligible swap found — best window "
342
+ f"{_fmt(win_start)}-{_fmt(win_end)} still has "
343
+ f"{len(unresolved)} flight(s)",
344
+ )
345
+
346
+ # Rebuild the rows list from rstate (AllocationRow is frozen).
347
+ new_rows: list[AllocationRow] = []
348
+ for uid, st in rstate.items():
349
+ f = flight_by_uid.get(uid)
350
+ if f is None:
351
+ new_rows.append(st["row"])
352
+ continue
353
+ new_rows.append(AllocationRow(
354
+ date=f.date, flt=f.flt, dep=f.dep, arr=f.arr, std=f.std,
355
+ pax=f.load,
356
+ staff_employee_id=st["staff_id"] or "",
357
+ staff_name=st["staff_name"] or "",
358
+ planned_by_employee_id=st["planned_by_emp"],
359
+ planned_by_name=st["planned_by_name"],
360
+ relieved_by_employee_id=st["relieved_by_emp"],
361
+ relieved_by_name=st["relieved_by_name"],
362
+ warning=st["warning"],
363
+ sheet_target=st["sheet_target"],
364
+ is_international=f.is_international,
365
+ ))
366
+ return new_rows, summary