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,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
|