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,244 @@
1
+ """Final workload rebalance pass (2026-09-23 fix).
2
+
3
+ The P2F and INTL post-passes (``postpass_p2f.py`` / ``postpass_intl.py``)
4
+ run AFTER the CP-SAT solver, and each one removes flights from whichever
5
+ staff happen to be adjacent to a P2F/INTL flight — not from whoever
6
+ currently has the most flights. The solver's own within-bucket balancing
7
+ (H18, ``solver/allocator_cpsat.py``) has already run by then, so those
8
+ post-pass removals can re-open a spread the solver had just closed: one
9
+ person loses two flights because they happened to work two INTL
10
+ departures back-to-back, another loses none, and the bucket ends up 3-4
11
+ flights apart instead of the intended <=1-2 (see docs/REVIEW_NOTES.md
12
+ finding #4).
13
+
14
+ This pass runs LAST, after both post-passes, and does the obvious thing
15
+ a human scheduler would do: for each (shift, role) bucket (same
16
+ exclusions as H18 — AM role and P2F handlers are left out, since
17
+ their workload mix isn't comparable), while the most-loaded and
18
+ least-loaded staff differ by more than ``target_spread``, move ONE
19
+ eligible plain-domestic flight from the most-loaded to the
20
+ least-loaded staff member. Repeat until the bucket is within target or
21
+ no further eligible transfer exists (matrix eligibility, H10 spacing,
22
+ and hard cap are all still enforced — this never creates a new
23
+ constraint violation, it only chooses who ends up doing which of the
24
+ flights that were already validly assignable to both people).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import contextlib
30
+ from collections import defaultdict
31
+ from dataclasses import dataclass, field
32
+ from datetime import date as date_t
33
+
34
+ from ..schemas import (
35
+ AllocationRow,
36
+ FlightInput,
37
+ OpsClass,
38
+ Role,
39
+ ShiftCode,
40
+ StaffMember,
41
+ )
42
+ from .caps import hard_cap_for
43
+ from .eligibility import sheet_target_for
44
+ from .windows import SpacingKey, spacing_clear, spacing_key, std_to_ops_day_minutes
45
+
46
+
47
+ @dataclass
48
+ class RebalanceSummary:
49
+ n_buckets_checked: int = 0
50
+ n_buckets_improved: int = 0
51
+ n_buckets_stuck: int = 0
52
+ n_transfers: int = 0
53
+ workload_notes: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list))
54
+ log_lines: list[str] = field(default_factory=list)
55
+
56
+ def note(self, staff_id: str, reason: str) -> None:
57
+ self.workload_notes[staff_id].append(reason)
58
+
59
+ def log(self, msg: str) -> None:
60
+ self.log_lines.append(msg)
61
+
62
+
63
+ def _transferable(flight: FlightInput | None) -> bool:
64
+ """Only move plain-domestic flights. P2F routing is handled
65
+ by dedicated logic elsewhere and shouldn't be touched here — this
66
+ mirrors ``postpass_intl._eligible_for_removal``."""
67
+ if flight is None:
68
+ return False
69
+ return flight.ops_class != OpsClass.P2F
70
+
71
+
72
+ def apply_rebalance_pass(
73
+ rows: list[AllocationRow],
74
+ flights: list[FlightInput],
75
+ staff_today: list[StaffMember],
76
+ matrix: dict[str, set[str]],
77
+ p2f_handlers: dict[ShiftCode, str],
78
+ d_day: date_t,
79
+ target_spread: int = 1,
80
+ max_transfers_per_bucket: int = 40,
81
+ ) -> tuple[list[AllocationRow], RebalanceSummary]:
82
+ """Level workload within each (shift, role) bucket by transferring
83
+ flights from the most- to the least-loaded staff. Returns updated
84
+ rows and a summary the orchestrator can fold into the workload
85
+ notes (same shape as the P2F/INTL post-pass summaries).
86
+ """
87
+ summary = RebalanceSummary()
88
+ flight_by_uid = {f.unique_id: f for f in flights}
89
+ staff_by_id = {s.employee_id: s for s in staff_today}
90
+
91
+ rstate: dict[str, dict] = {}
92
+ for r in rows:
93
+ uid = (
94
+ f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
95
+ f"|{r.date.isoformat()}"
96
+ )
97
+ rstate[uid] = {
98
+ "row": r,
99
+ "staff_id": r.staff_employee_id,
100
+ "staff_name": r.staff_name,
101
+ "planned_by_emp": r.planned_by_employee_id,
102
+ "planned_by_name": r.planned_by_name,
103
+ "relieved_by_emp": r.relieved_by_employee_id,
104
+ "relieved_by_name": r.relieved_by_name,
105
+ "warning": r.warning,
106
+ "sheet_target": r.sheet_target,
107
+ }
108
+
109
+ counts: dict[str, int] = defaultdict(int)
110
+ staff_stds: dict[str, list[SpacingKey]] = defaultdict(list)
111
+ for uid, st in rstate.items():
112
+ if not st["staff_id"]:
113
+ continue
114
+ counts[st["staff_id"]] += 1
115
+ f_ref = flight_by_uid.get(uid)
116
+ if f_ref is not None:
117
+ staff_stds[st["staff_id"]].append(spacing_key(f_ref, d_day))
118
+
119
+ excluded_handlers = set(p2f_handlers.values())
120
+ buckets: dict[tuple[ShiftCode, Role], list[StaffMember]] = defaultdict(list)
121
+ for s in staff_today:
122
+ if s.shift_today is None or s.role == Role.AM:
123
+ continue
124
+ if s.employee_id in excluded_handlers:
125
+ continue
126
+ buckets[(s.shift_today, s.role)].append(s)
127
+
128
+ for (shift_code, role), bucket in buckets.items():
129
+ if len(bucket) < 2:
130
+ continue
131
+ summary.n_buckets_checked += 1
132
+ bucket_ids = [s.employee_id for s in bucket]
133
+ n_transfers_this_bucket = 0
134
+ improved = False
135
+ while n_transfers_this_bucket < max_transfers_per_bucket:
136
+ bucket_counts = {eid: counts.get(eid, 0) for eid in bucket_ids}
137
+ max_c = max(bucket_counts.values())
138
+ min_c = min(bucket_counts.values())
139
+ if max_c - min_c <= target_spread:
140
+ break
141
+ donors = sorted(eid for eid, c in bucket_counts.items() if c == max_c)
142
+ recipients = sorted(eid for eid, c in bucket_counts.items() if c == min_c)
143
+
144
+ transferred = False
145
+ for donor_id in donors:
146
+ donor = staff_by_id[donor_id]
147
+ donor_flight_uids = sorted(
148
+ (
149
+ uid for uid, st in rstate.items()
150
+ if st["staff_id"] == donor_id
151
+ and _transferable(flight_by_uid.get(uid))
152
+ ),
153
+ key=lambda u: std_to_ops_day_minutes(
154
+ flight_by_uid[u].std, flight_by_uid[u].date, d_day,
155
+ ),
156
+ )
157
+ for uid in donor_flight_uids:
158
+ f = flight_by_uid[uid]
159
+ eligible_set = matrix.get(uid, set())
160
+ for recipient_id in recipients:
161
+ if recipient_id == donor_id:
162
+ continue
163
+ if recipient_id not in eligible_set:
164
+ continue
165
+ recipient = staff_by_id[recipient_id]
166
+ if counts.get(recipient_id, 0) >= hard_cap_for(recipient):
167
+ continue
168
+ f_entry = spacing_key(f, d_day)
169
+ if not spacing_clear(
170
+ f_entry, staff_stds.get(recipient_id, ()),
171
+ ):
172
+ continue
173
+
174
+ # -- transfer donor -> recipient --
175
+ st = rstate[uid]
176
+ counts[donor_id] -= 1
177
+ counts[recipient_id] = counts.get(recipient_id, 0) + 1
178
+ with contextlib.suppress(ValueError):
179
+ staff_stds[donor_id].remove(f_entry)
180
+ staff_stds.setdefault(recipient_id, []).append(f_entry)
181
+ st["staff_id"] = recipient.employee_id
182
+ st["staff_name"] = recipient.name
183
+ st["sheet_target"] = sheet_target_for(f, recipient)
184
+
185
+ summary.n_transfers += 1
186
+ n_transfers_this_bucket += 1
187
+ summary.note(
188
+ donor_id,
189
+ f"Rebalance: gave up FLT {f.flt} to "
190
+ f"{recipient.name} to level {shift_code}/"
191
+ f"{role.value} workload",
192
+ )
193
+ summary.note(
194
+ recipient_id,
195
+ f"Rebalance: received FLT {f.flt} from "
196
+ f"{donor.name} to level {shift_code}/"
197
+ f"{role.value} workload",
198
+ )
199
+ summary.log(
200
+ f"{shift_code}/{role.value}: moved flt {f.flt} "
201
+ f"{donor.name} ({bucket_counts[donor_id]}) -> "
202
+ f"{recipient.name} ({bucket_counts[recipient_id]})"
203
+ )
204
+ transferred = True
205
+ improved = True
206
+ break
207
+ if transferred:
208
+ break
209
+ if transferred:
210
+ break
211
+
212
+ if not transferred:
213
+ summary.n_buckets_stuck += 1
214
+ summary.log(
215
+ f"{shift_code}/{role.value}: spread still "
216
+ f"{max_c - min_c} after {n_transfers_this_bucket} "
217
+ "transfer(s) — no further eligible transfer found "
218
+ "(matrix / H10 spacing / hard cap all blocked it)"
219
+ )
220
+ break
221
+ if improved:
222
+ summary.n_buckets_improved += 1
223
+
224
+ # Rebuild the rows list from rstate (AllocationRow is frozen).
225
+ new_rows: list[AllocationRow] = []
226
+ for uid, st in rstate.items():
227
+ f = flight_by_uid.get(uid)
228
+ if f is None:
229
+ new_rows.append(st["row"])
230
+ continue
231
+ new_rows.append(AllocationRow(
232
+ date=f.date, flt=f.flt, dep=f.dep, arr=f.arr, std=f.std,
233
+ pax=f.load,
234
+ staff_employee_id=st["staff_id"] or "",
235
+ staff_name=st["staff_name"] or "",
236
+ planned_by_employee_id=st["planned_by_emp"],
237
+ planned_by_name=st["planned_by_name"],
238
+ relieved_by_employee_id=st["relieved_by_emp"],
239
+ relieved_by_name=st["relieved_by_name"],
240
+ warning=st["warning"],
241
+ sheet_target=st["sheet_target"],
242
+ is_international=f.is_international,
243
+ ))
244
+ return new_rows, summary