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,1639 @@
|
|
|
1
|
+
"""Step 4 — flight allocation orchestrator.
|
|
2
|
+
|
|
3
|
+
Reads from state:
|
|
4
|
+
cleaned flights (every ops class except GULF) — what there is to allocate
|
|
5
|
+
availability — who is on shift for D
|
|
6
|
+
overrides — per-day operator intent
|
|
7
|
+
the uploaded AM roster — AM/ZC rows (ZC shift recovery)
|
|
8
|
+
|
|
9
|
+
Builds:
|
|
10
|
+
StaffMember per assignable staff (STAFF + ZC; AM excluded)
|
|
11
|
+
EligibilityContext (P2F handler + flight times)
|
|
12
|
+
Sparse eligibility matrix
|
|
13
|
+
Pair list (deterministic from row order; secondary handover-only when
|
|
14
|
+
A surplus exists; split-1+2 at A→N; N→M cross-role for excess M-ZCs)
|
|
15
|
+
|
|
16
|
+
Solves with CP-SAT (hard constraints H1, H10, H16, H17;
|
|
17
|
+
S1+S3+S4+S5+S6+S7 enabled).
|
|
18
|
+
|
|
19
|
+
Writes back to state:
|
|
20
|
+
allocations (each row tagged with its DayOps / NightOps / P2F lane)
|
|
21
|
+
pairs, workload summary, unallocated list
|
|
22
|
+
warnings (W201/W210/W211 surfaced; appended to the Plan stage's)
|
|
23
|
+
recommendations for whatever could not be placed
|
|
24
|
+
the run-status stamp
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import time as _time
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from datetime import date as date_t
|
|
32
|
+
from datetime import time as time_t
|
|
33
|
+
from datetime import timedelta as _td
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from .allocator.caps import aggregate_capacity
|
|
37
|
+
from .allocator.eligibility import (
|
|
38
|
+
EligibilityContext,
|
|
39
|
+
build_matrix,
|
|
40
|
+
diagnose_unassignable,
|
|
41
|
+
tag_awkward_window,
|
|
42
|
+
)
|
|
43
|
+
from .allocator.pairings import PairGenerationError, generate_pairs
|
|
44
|
+
from .allocator.postsolve import (
|
|
45
|
+
assemble_allocation_rows,
|
|
46
|
+
build_workload_summary,
|
|
47
|
+
)
|
|
48
|
+
from .allocator.windows import std_to_ops_day_minutes
|
|
49
|
+
from .config import Config, load_config
|
|
50
|
+
from .io.readers import (
|
|
51
|
+
read_p2f_nominations,
|
|
52
|
+
read_per_staff_overrides,
|
|
53
|
+
read_raise_cap_overrides,
|
|
54
|
+
read_role_change_overrides,
|
|
55
|
+
read_sick_overrides,
|
|
56
|
+
read_skip_intl_removal_overrides,
|
|
57
|
+
read_skip_p2f_buffer_overrides,
|
|
58
|
+
read_waive_h10_pairs,
|
|
59
|
+
)
|
|
60
|
+
from .io.roster_library import read_library
|
|
61
|
+
from .state import AppState
|
|
62
|
+
from .schemas import (
|
|
63
|
+
AllocationResult,
|
|
64
|
+
AMRosterRow,
|
|
65
|
+
AvailabilityRow,
|
|
66
|
+
CleanFlightRow,
|
|
67
|
+
CrewStatus,
|
|
68
|
+
FlightInput,
|
|
69
|
+
OpsClass,
|
|
70
|
+
Role,
|
|
71
|
+
Severity,
|
|
72
|
+
ShiftCode,
|
|
73
|
+
StaffMember,
|
|
74
|
+
WarningRow,
|
|
75
|
+
)
|
|
76
|
+
from .solver.allocator_cpsat import AllocationSolverResult, solve_allocation
|
|
77
|
+
|
|
78
|
+
# ---------- ZC shift recovery helpers ----------
|
|
79
|
+
# 2026-05-27 (centralization): these three maps are DERIVED from the
|
|
80
|
+
# central taxonomy in schemas.py — one source of truth. Step3's
|
|
81
|
+
# branching logic depends on them being three DISJOINT subsets of
|
|
82
|
+
# CrewStatus, so they stay as separate local lookups:
|
|
83
|
+
# _ZC_STATUS_TO_SHIFT — ZC-only variants (M/ZC, A/ZC, …)
|
|
84
|
+
# _P2F_HANDLER_STATUS_TO_SHIFT — plain P2F handler variants (no ZC)
|
|
85
|
+
# _ZC_P2F_HANDLER_STATUS_TO_SHIFT — combined ZC + P2F variants
|
|
86
|
+
# Adding a new ZC variant to STATUS_IS_ZC (in schemas.py) auto-flows
|
|
87
|
+
# here. Adding a new P2F-handler status to STATUS_TO_P2F_HANDLER_SHIFT
|
|
88
|
+
# auto-flows here. No more "I forgot to update this map" bugs.
|
|
89
|
+
from .schemas import (
|
|
90
|
+
STATUS_IS_ZC as _CENTRAL_ZC,
|
|
91
|
+
STATUS_TO_P2F_HANDLER_SHIFT as _CENTRAL_P2F_SHIFT,
|
|
92
|
+
STATUS_TO_SHIFT as _CENTRAL_SHIFT,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
_ZC_STATUS_TO_SHIFT: dict[CrewStatus, ShiftCode] = {
|
|
96
|
+
s: _CENTRAL_SHIFT[s] # type: ignore[misc]
|
|
97
|
+
for s in _CENTRAL_ZC
|
|
98
|
+
if s not in _CENTRAL_P2F_SHIFT and _CENTRAL_SHIFT.get(s) is not None
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_P2F_HANDLER_STATUS_TO_SHIFT: dict[CrewStatus, ShiftCode] = {
|
|
102
|
+
s: sh for s, sh in _CENTRAL_P2F_SHIFT.items()
|
|
103
|
+
if s not in _CENTRAL_ZC
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_ZC_P2F_HANDLER_STATUS_TO_SHIFT: dict[CrewStatus, ShiftCode] = {
|
|
107
|
+
s: sh for s, sh in _CENTRAL_P2F_SHIFT.items()
|
|
108
|
+
if s in _CENTRAL_ZC
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _flight_key(flt: str, dep: str, arr: str, std_iso: str, date_iso: str) -> str:
|
|
113
|
+
"""The key ``FlightInput.unique_id`` uses, built from loose parts."""
|
|
114
|
+
return f"{flt}|{dep}|{arr}|{std_iso}|{date_iso}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _prior_allocations_as_hints(
|
|
118
|
+
state: AppState,
|
|
119
|
+
name_to_id: dict[str, str],
|
|
120
|
+
) -> dict[str, str]:
|
|
121
|
+
"""Build ``{unique_id: employee_id}`` from the PREVIOUS solve's rows,
|
|
122
|
+
which are still on the state until this run overwrites them.
|
|
123
|
+
|
|
124
|
+
CP-SAT consumes this as a warm start, which drops re-solve wall
|
|
125
|
+
clock 30-60 %. Without it every Allocate runs cold even when the
|
|
126
|
+
prior assignments are 99 % still feasible — which is why a sick /
|
|
127
|
+
recommender re-solve used to take as long as the first solve.
|
|
128
|
+
|
|
129
|
+
Staff who have since been removed from the roster are skipped; the
|
|
130
|
+
solver reassigns their flights cold.
|
|
131
|
+
"""
|
|
132
|
+
hints: dict[str, str] = {}
|
|
133
|
+
name_lookup = {k.strip().upper(): v for k, v in name_to_id.items()}
|
|
134
|
+
for r in state.allocations:
|
|
135
|
+
if not r.staff_name:
|
|
136
|
+
continue
|
|
137
|
+
eid = name_lookup.get(r.staff_name.strip().upper())
|
|
138
|
+
if not eid:
|
|
139
|
+
continue
|
|
140
|
+
hints[_flight_key(
|
|
141
|
+
r.flt, r.dep, r.arr,
|
|
142
|
+
r.std.isoformat(timespec="minutes"), r.date.isoformat(),
|
|
143
|
+
)] = eid
|
|
144
|
+
return hints
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _snapshot_prev_staff(state: AppState) -> dict[str, str]:
|
|
148
|
+
"""``{flight_key: staff_name}`` for the run that is about to be
|
|
149
|
+
replaced. The Allocations tab uses it to flag rows whose staff
|
|
150
|
+
changed, so the assigner can verify a re-solve only touched the
|
|
151
|
+
people it was supposed to."""
|
|
152
|
+
return {
|
|
153
|
+
_flight_key(
|
|
154
|
+
r.flt, r.dep, r.arr,
|
|
155
|
+
r.std.isoformat(timespec="minutes"), r.date.isoformat(),
|
|
156
|
+
): r.staff_name
|
|
157
|
+
for r in state.allocations if r.staff_name
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _read_auto_p2f_handlers_from_rosters(
|
|
162
|
+
am_roster_rows: list[AMRosterRow],
|
|
163
|
+
availability: list[AvailabilityRow],
|
|
164
|
+
d_day: date_t,
|
|
165
|
+
) -> dict[ShiftCode, str]:
|
|
166
|
+
"""Walk BOTH rosters for cells whose status is P2F/M, P2F/A, or
|
|
167
|
+
P2F/N on D. Returns {shift: employee_id}. First match per shift wins;
|
|
168
|
+
STAFF roster is scanned before AM roster so STAFF nominations take
|
|
169
|
+
precedence on conflict.
|
|
170
|
+
|
|
171
|
+
Per user direction 2026-05-10 (extended 2026-05-11): assigners
|
|
172
|
+
nominate P2F handlers via roster cells like `P2F/M` — works in
|
|
173
|
+
EITHER roster file. An override still wins on conflict
|
|
174
|
+
(manual nominations always trump auto-picks); previously this
|
|
175
|
+
function only scanned IN_AM_Roster, which is why STAFF roster
|
|
176
|
+
nominations were silently ignored.
|
|
177
|
+
"""
|
|
178
|
+
auto: dict[ShiftCode, str] = {}
|
|
179
|
+
# 2026-05-26 (followup): walk availability for BOTH STAFF and ZC.
|
|
180
|
+
# A regular staff in IN_Staff_Roster with `N/P2F/ZC` cell becomes
|
|
181
|
+
# role=ZC + license=P2F via Step 2; if we restrict to STAFF here he
|
|
182
|
+
# never gets picked as a P2F handler. Combined ZC+P2F statuses are
|
|
183
|
+
# also checked alongside the legacy P2F/<shift> statuses.
|
|
184
|
+
for av in availability:
|
|
185
|
+
if av.date != d_day:
|
|
186
|
+
continue
|
|
187
|
+
if av.role not in (Role.STAFF, Role.ZC):
|
|
188
|
+
continue
|
|
189
|
+
shift = (
|
|
190
|
+
_P2F_HANDLER_STATUS_TO_SHIFT.get(av.status)
|
|
191
|
+
or _ZC_P2F_HANDLER_STATUS_TO_SHIFT.get(av.status)
|
|
192
|
+
)
|
|
193
|
+
if shift and shift not in auto:
|
|
194
|
+
auto[shift] = av.employee_id
|
|
195
|
+
for am in am_roster_rows:
|
|
196
|
+
cell_status = am.status_by_date.get(d_day)
|
|
197
|
+
if cell_status is None:
|
|
198
|
+
continue
|
|
199
|
+
# 2026-05-26: M/P2F/ZC (combined) cells also nominate the staff
|
|
200
|
+
# as the shift's P2F handler. Checked alongside the legacy
|
|
201
|
+
# M/P2F-style cells.
|
|
202
|
+
shift = (
|
|
203
|
+
_P2F_HANDLER_STATUS_TO_SHIFT.get(cell_status)
|
|
204
|
+
or _ZC_P2F_HANDLER_STATUS_TO_SHIFT.get(cell_status)
|
|
205
|
+
)
|
|
206
|
+
if shift and shift not in auto:
|
|
207
|
+
auto[shift] = am.employee_id
|
|
208
|
+
return auto
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _build_staff_today(
|
|
212
|
+
availability: list[AvailabilityRow],
|
|
213
|
+
am_roster_rows: list[AMRosterRow],
|
|
214
|
+
d_day: date_t,
|
|
215
|
+
) -> list[StaffMember]:
|
|
216
|
+
"""Build StaffMember list for today's allocation.
|
|
217
|
+
|
|
218
|
+
STAFF rows: from the availability list (Step 2 output) — the roster as
|
|
219
|
+
submitted; no balance solver in between (per user direction 2026-05-11).
|
|
220
|
+
ZC rows: from IN_AM_Roster — shift recovered from that day's cell.
|
|
221
|
+
AM rows: dropped entirely (H13 — they don't fly).
|
|
222
|
+
"""
|
|
223
|
+
out: list[StaffMember] = []
|
|
224
|
+
seen_ids: set[str] = set()
|
|
225
|
+
# 2026-05-26 (followup): admit BOTH STAFF and ZC rows from
|
|
226
|
+
# availability. Step 2 sets `role=ZC` per-day when the cell is
|
|
227
|
+
# `M/ZC`, `M/P2F/ZC`, etc. — even on rows originating in
|
|
228
|
+
# IN_Staff_Roster. Previously this loop filtered `role != STAFF`,
|
|
229
|
+
# so a regular staff put on ZC duty for the day was silently
|
|
230
|
+
# dropped from staff_today. The AM-roster fallback below dedupes
|
|
231
|
+
# via ``seen_ids`` so workbooks that ALSO list the same person in
|
|
232
|
+
# IN_AM_Roster don't double-count.
|
|
233
|
+
for r in availability:
|
|
234
|
+
if r.date != d_day:
|
|
235
|
+
continue
|
|
236
|
+
if r.role not in (Role.STAFF, Role.ZC):
|
|
237
|
+
continue
|
|
238
|
+
if not r.assignable or r.current_shift is None:
|
|
239
|
+
continue
|
|
240
|
+
is_p2f = bool(r.license and "P2F" in r.license.upper())
|
|
241
|
+
out.append(StaffMember(
|
|
242
|
+
employee_id=r.employee_id,
|
|
243
|
+
name=r.name,
|
|
244
|
+
role=r.role,
|
|
245
|
+
date=d_day,
|
|
246
|
+
shift_today=r.current_shift,
|
|
247
|
+
license=r.license,
|
|
248
|
+
is_p2f_licensed=is_p2f,
|
|
249
|
+
))
|
|
250
|
+
seen_ids.add(r.employee_id)
|
|
251
|
+
# ZC + P2F-handler cells — classify PER-DAY (not whole-month).
|
|
252
|
+
# Cells matching a ZC variant (M/ZC, A/ZC...) put them on that
|
|
253
|
+
# shift as a ZC. Cells matching a P2F handler variant (P2F/M,
|
|
254
|
+
# P2F/A, P2F/N) put them on that shift, role=ZC, with P2F license
|
|
255
|
+
# auto-flagged. Per user direction 2026-05-10.
|
|
256
|
+
for am in am_roster_rows:
|
|
257
|
+
if am.employee_id in seen_ids:
|
|
258
|
+
# Already added via the availability loop above — skip to
|
|
259
|
+
# prevent double-counting when the same employee appears in
|
|
260
|
+
# both rosters (or when availability was rebuilt from
|
|
261
|
+
# IN_AM_Roster).
|
|
262
|
+
continue
|
|
263
|
+
cell_status = am.status_by_date.get(d_day)
|
|
264
|
+
if cell_status is None:
|
|
265
|
+
continue
|
|
266
|
+
recovered_shift = _ZC_STATUS_TO_SHIFT.get(cell_status)
|
|
267
|
+
license_val = am.license
|
|
268
|
+
is_p2f = bool(am.license and "P2F" in am.license.upper())
|
|
269
|
+
if recovered_shift is None:
|
|
270
|
+
# 2026-05-26: combined ZC+P2F cell? Same shift, role=ZC,
|
|
271
|
+
# is_p2f_licensed=True. The "handler" wiring is done in
|
|
272
|
+
# the auto-picker; here we only need to seat them as ZC
|
|
273
|
+
# with the P2F flag so eligibility passes.
|
|
274
|
+
recovered_shift = _ZC_P2F_HANDLER_STATUS_TO_SHIFT.get(cell_status)
|
|
275
|
+
if recovered_shift is not None:
|
|
276
|
+
is_p2f = True
|
|
277
|
+
license_val = (
|
|
278
|
+
license_val if license_val and "P2F" in license_val.upper()
|
|
279
|
+
else "P2F (auto from roster cell — combined ZC+P2F)"
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
# Maybe a P2F-handler cell — same shift, with auto-license
|
|
283
|
+
recovered_shift = _P2F_HANDLER_STATUS_TO_SHIFT.get(cell_status)
|
|
284
|
+
if recovered_shift is None:
|
|
285
|
+
# Today they're AM (M/IGT, A/IGT) or off — H13.
|
|
286
|
+
continue
|
|
287
|
+
# Mark them P2F-licensed for the day even if the master
|
|
288
|
+
# license list didn't say so — the roster cell is explicit.
|
|
289
|
+
is_p2f = True
|
|
290
|
+
license_val = (
|
|
291
|
+
license_val if license_val and "P2F" in license_val.upper()
|
|
292
|
+
else "P2F (auto from roster cell)"
|
|
293
|
+
)
|
|
294
|
+
out.append(StaffMember(
|
|
295
|
+
employee_id=am.employee_id,
|
|
296
|
+
name=am.name,
|
|
297
|
+
role=Role.ZC,
|
|
298
|
+
date=d_day,
|
|
299
|
+
shift_today=recovered_shift,
|
|
300
|
+
license=license_val,
|
|
301
|
+
is_p2f_licensed=is_p2f,
|
|
302
|
+
))
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _to_flight_inputs(
|
|
307
|
+
cleaned: list[CleanFlightRow], ops_day: date_t,
|
|
308
|
+
international_airport_codes: list[str] | None = None,
|
|
309
|
+
) -> list[FlightInput]:
|
|
310
|
+
"""Convert CleanFlightRow → FlightInput, tagging awkward windows and
|
|
311
|
+
international flights.
|
|
312
|
+
|
|
313
|
+
Phase 3 (2026-05-14, INTL overhaul):
|
|
314
|
+
- ``is_international`` is recomputed from config (DEP-side only —
|
|
315
|
+
Change 9). Pink ``routing_via_excluded_hub`` is gone.
|
|
316
|
+
- ``is_preplan_deferred`` is recomputed here from (date, std). The
|
|
317
|
+
cleaned-sheet schema does NOT store this flag (no column added,
|
|
318
|
+
per user direction), so when Step 3 reads back from the workbook
|
|
319
|
+
we lose ``c.is_preplan_deferred``. Derivation matches Step 1:
|
|
320
|
+
a flight is deferred iff date == ops_day+1 AND CUTOFF <= STD <=
|
|
321
|
+
DEFERRED_UPPER.
|
|
322
|
+
"""
|
|
323
|
+
from datetime import timedelta
|
|
324
|
+
from .step1_clean_flights import CUTOFF, DEFERRED_UPPER
|
|
325
|
+
d_plus_1 = ops_day + timedelta(days=1)
|
|
326
|
+
intl_set = {c.upper() for c in (international_airport_codes or [])}
|
|
327
|
+
out: list[FlightInput] = []
|
|
328
|
+
for c in cleaned:
|
|
329
|
+
dep_u = c.dep.upper()
|
|
330
|
+
# Change 9: DEP-side only.
|
|
331
|
+
is_intl = dep_u in intl_set
|
|
332
|
+
# Change 7 redux: derive deferred from (date, std).
|
|
333
|
+
is_deferred = (c.date == d_plus_1 and CUTOFF <= c.std <= DEFERRED_UPPER)
|
|
334
|
+
f = FlightInput(
|
|
335
|
+
date=c.date, flt=c.flt, type=c.type, ac=c.ac,
|
|
336
|
+
dep=c.dep, arr=c.arr, std=c.std, load=c.load,
|
|
337
|
+
ops_class=c.ops_class,
|
|
338
|
+
is_international=is_intl,
|
|
339
|
+
is_preplan_deferred=is_deferred,
|
|
340
|
+
)
|
|
341
|
+
out.append(tag_awkward_window(f, ops_day))
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _build_p2f_handler_anchors(
|
|
346
|
+
flights: list[FlightInput],
|
|
347
|
+
p2f_handler_by_shift: dict[ShiftCode, str],
|
|
348
|
+
ops_day: date_t,
|
|
349
|
+
) -> dict[str, list[int]]:
|
|
350
|
+
"""Compute the list of P2F flight STDs (in ops-day minutes) per
|
|
351
|
+
handler — drives the H4 buffer (F6 in eligibility: no normal
|
|
352
|
+
flights from STD-2hrs to STD+1hr)."""
|
|
353
|
+
out: dict[str, list[int]] = defaultdict(list)
|
|
354
|
+
# Group P2F flights by their shift's handler.
|
|
355
|
+
for f in flights:
|
|
356
|
+
if f.ops_class != OpsClass.P2F:
|
|
357
|
+
continue
|
|
358
|
+
# The handler is whichever shift owns this STD — but we don't
|
|
359
|
+
# know shift here without a staff member. Best heuristic: use
|
|
360
|
+
# the shift whose nominal window contains the STD.
|
|
361
|
+
from .allocator.windows import SHIFT_NOMINAL_MIN
|
|
362
|
+
std_min = std_to_ops_day_minutes(f.std, f.date, ops_day)
|
|
363
|
+
for shift, (start, end) in SHIFT_NOMINAL_MIN.items():
|
|
364
|
+
if start <= std_min <= end:
|
|
365
|
+
handler = p2f_handler_by_shift.get(shift)
|
|
366
|
+
if handler:
|
|
367
|
+
out[handler].append(std_min)
|
|
368
|
+
break
|
|
369
|
+
return dict(out)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def run(
|
|
373
|
+
state: AppState,
|
|
374
|
+
d_day: date_t,
|
|
375
|
+
config_path: Path | str = "configs/config.yml",
|
|
376
|
+
*,
|
|
377
|
+
append_warnings: bool = True,
|
|
378
|
+
mode_label: str = "Step 4",
|
|
379
|
+
solution_hints: dict[str, str] | None = None,
|
|
380
|
+
) -> dict[str, int]:
|
|
381
|
+
"""Step 4 orchestrator. Reads state, runs the solver, writes back.
|
|
382
|
+
|
|
383
|
+
Returns a count dict with FLIGHTS, ASSIGNED, UNALLOCATED, PAIRS,
|
|
384
|
+
WARNINGS, and FEASIBLE for the caller to display.
|
|
385
|
+
|
|
386
|
+
``solution_hints`` is the previous run's ``{unique_id: employee_id}``
|
|
387
|
+
map, used as a CP-SAT warm start. When None it is auto-built from
|
|
388
|
+
the allocations still on the state. Hints are silently ignored if no
|
|
389
|
+
longer feasible.
|
|
390
|
+
"""
|
|
391
|
+
config: Config = load_config(config_path)
|
|
392
|
+
started = _time.monotonic()
|
|
393
|
+
warnings: list[WarningRow] = []
|
|
394
|
+
counts: dict[str, int] = {
|
|
395
|
+
"FLIGHTS": 0, "ASSIGNED": 0, "UNALLOCATED": 0,
|
|
396
|
+
"PAIRS": 0, "WARNINGS": 0, "FEASIBLE": 0,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
# --- Read inputs ----------
|
|
400
|
+
cleaned = state.allocatable_cleaned()
|
|
401
|
+
availability = list(state.availability)
|
|
402
|
+
overrides = list(state.overrides)
|
|
403
|
+
prev_staff_by_key = _snapshot_prev_staff(state)
|
|
404
|
+
|
|
405
|
+
am_rows = read_library(state, "am_roster", config, [d_day])
|
|
406
|
+
|
|
407
|
+
# Build a name -> employee_id map for resolving override rows. Names
|
|
408
|
+
# come from BOTH availability (STAFF) AND the AM roster (AM/ZC).
|
|
409
|
+
name_to_id: dict[str, str] = {}
|
|
410
|
+
for av in availability:
|
|
411
|
+
if av.date == d_day and av.name:
|
|
412
|
+
name_to_id[av.name.strip().upper()] = av.employee_id
|
|
413
|
+
for amr in am_rows:
|
|
414
|
+
if amr.name:
|
|
415
|
+
name_to_id[amr.name.strip().upper()] = amr.employee_id
|
|
416
|
+
|
|
417
|
+
if solution_hints is None:
|
|
418
|
+
solution_hints = _prior_allocations_as_hints(state, name_to_id)
|
|
419
|
+
if solution_hints:
|
|
420
|
+
print(
|
|
421
|
+
f" solver: auto-loaded {len(solution_hints)} warm-start "
|
|
422
|
+
"hints from the previous allocation"
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Step-4-specific overrides (date is implicit from d_day).
|
|
426
|
+
p2f_nominations = read_p2f_nominations(overrides, d_day, name_to_id)
|
|
427
|
+
per_staff = read_per_staff_overrides(overrides, d_day, name_to_id)
|
|
428
|
+
# Per-day "sick" pulls a staff out of today's allocation. Applied
|
|
429
|
+
# below once staff_today is built — we zero their shift_today so
|
|
430
|
+
# eligibility treats them as off, and the solver redistributes
|
|
431
|
+
# their flights automatically. Logged so "I marked X sick but they
|
|
432
|
+
# still got flights" has a diagnostic trail.
|
|
433
|
+
sick_employee_ids = read_sick_overrides(overrides, d_day, name_to_id)
|
|
434
|
+
if sick_employee_ids:
|
|
435
|
+
print(
|
|
436
|
+
f" sick overrides: {len(sick_employee_ids)} row(s) — "
|
|
437
|
+
f"{sick_employee_ids[:5]}"
|
|
438
|
+
+ (f" ... (+{len(sick_employee_ids) - 5} more)"
|
|
439
|
+
if len(sick_employee_ids) > 5 else "")
|
|
440
|
+
)
|
|
441
|
+
# Per-day role change: {employee_id: "STAFF"|"ZC"|"AM"}. Applied
|
|
442
|
+
# right after the sick filter; STAFF -> ZC switches the H18 bucket
|
|
443
|
+
# so they sit at 14-15, AM makes them ineligible (everything they
|
|
444
|
+
# were carrying redistributes via the pin re-solve).
|
|
445
|
+
role_changes = read_role_change_overrides(overrides, d_day, name_to_id)
|
|
446
|
+
# Phase R per-flight overrides — converted into constraint-side sets
|
|
447
|
+
# below once FlightInput unique_ids are resolved.
|
|
448
|
+
waive_h10_rows = read_waive_h10_pairs(overrides, d_day, name_to_id)
|
|
449
|
+
raise_cap_rows = read_raise_cap_overrides(overrides, d_day, name_to_id)
|
|
450
|
+
skip_p2f_buffer_rows = read_skip_p2f_buffer_overrides(
|
|
451
|
+
overrides, d_day, name_to_id,
|
|
452
|
+
)
|
|
453
|
+
skip_intl_removal_rows = read_skip_intl_removal_overrides(overrides, d_day)
|
|
454
|
+
|
|
455
|
+
flights = _to_flight_inputs(
|
|
456
|
+
cleaned, d_day,
|
|
457
|
+
international_airport_codes=config.io.sv_portal.international_airport_codes,
|
|
458
|
+
)
|
|
459
|
+
counts["FLIGHTS"] = len(flights)
|
|
460
|
+
|
|
461
|
+
staff_today = _build_staff_today(availability, am_rows, d_day)
|
|
462
|
+
|
|
463
|
+
# 2026-05-27 diagnostic: surface staff_today shape early so a
|
|
464
|
+
# mis-applied override (sick / change_role / etc. flipping
|
|
465
|
+
# assignable=False for everyone) is visible in the console
|
|
466
|
+
# instead of producing "2263 unallocated" with no explanation.
|
|
467
|
+
from collections import Counter as _Counter
|
|
468
|
+
_shift_counter = _Counter(
|
|
469
|
+
(s.shift_today or "OFF", s.role.value) for s in staff_today
|
|
470
|
+
)
|
|
471
|
+
_assignable = sum(
|
|
472
|
+
1 for s in staff_today
|
|
473
|
+
if s.shift_today is not None
|
|
474
|
+
)
|
|
475
|
+
print(
|
|
476
|
+
f" staff_today: total={len(staff_today)} on-shift={_assignable} "
|
|
477
|
+
f" flights to allocate: {len(flights)}"
|
|
478
|
+
)
|
|
479
|
+
for (sh, role), n in sorted(_shift_counter.items()):
|
|
480
|
+
print(f" {sh:>4} / {role:<6}: {n}")
|
|
481
|
+
if _assignable == 0:
|
|
482
|
+
print(
|
|
483
|
+
" !! WARNING: ZERO staff on shift today. Every flight will "
|
|
484
|
+
"be UNALLOCATED. Likely cause: a `sick` / `remove_staff` row "
|
|
485
|
+
"in the override list matched everyone, or every roster row has "
|
|
486
|
+
"assignable=False. Check the Override drawer and the "
|
|
487
|
+
"uploaded rosters."
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# 2026-05-18 (user direction): apply "sick" override — flip
|
|
491
|
+
# shift_today to None for every employee in the sick list. The
|
|
492
|
+
# rest of the pipeline (eligibility, pair generation, solver)
|
|
493
|
+
# treats them as off today, so their flights redistribute to
|
|
494
|
+
# remaining staff on the same shift on the next solve. Done BEFORE
|
|
495
|
+
# the per-staff overrides loop so a sick staff with a max_flights
|
|
496
|
+
# override still ends up off.
|
|
497
|
+
if sick_employee_ids:
|
|
498
|
+
sick_set = set(sick_employee_ids)
|
|
499
|
+
# Resolve names that didn't match an employee_id by scanning
|
|
500
|
+
# roster names (case-insensitive, internal-whitespace-collapsed).
|
|
501
|
+
# 2026-05-28: switched to the centralized _normalize_name helper
|
|
502
|
+
# so 'AKSHAT CHATURVEDI' (double-space) and 'akshat chaturvedi'
|
|
503
|
+
# both resolve to the same key — previously a sick override with
|
|
504
|
+
# mismatched whitespace silently failed to remove the staff.
|
|
505
|
+
from .staged_overrides import _normalize_name
|
|
506
|
+
name_lookup = {_normalize_name(s.name): s.employee_id for s in staff_today}
|
|
507
|
+
resolved = set()
|
|
508
|
+
unresolved: list[str] = []
|
|
509
|
+
for sid in sick_set:
|
|
510
|
+
if any(s.employee_id == sid for s in staff_today):
|
|
511
|
+
resolved.add(sid)
|
|
512
|
+
elif _normalize_name(sid) in name_lookup:
|
|
513
|
+
resolved.add(name_lookup[_normalize_name(sid)])
|
|
514
|
+
else:
|
|
515
|
+
unresolved.append(sid)
|
|
516
|
+
if resolved:
|
|
517
|
+
sick_names = [s.name for s in staff_today if s.employee_id in resolved]
|
|
518
|
+
staff_today = [
|
|
519
|
+
s.model_copy(update={"shift_today": None})
|
|
520
|
+
if s.employee_id in resolved else s
|
|
521
|
+
for s in staff_today
|
|
522
|
+
]
|
|
523
|
+
print(
|
|
524
|
+
f" Sick override: removed {len(resolved)} staff from today's "
|
|
525
|
+
f"allocation: {', '.join(sick_names)}"
|
|
526
|
+
)
|
|
527
|
+
if unresolved:
|
|
528
|
+
# Loud failure so the user knows their sick override didn't
|
|
529
|
+
# take effect. Common causes: name typo, staff not on shift
|
|
530
|
+
# today (already off), or no matching id in roster.
|
|
531
|
+
print(
|
|
532
|
+
f" !! Sick override WARNING: {len(unresolved)} sick "
|
|
533
|
+
f"row(s) could NOT be resolved to staff_today rows: "
|
|
534
|
+
f"{unresolved}. These staff will still be allocated "
|
|
535
|
+
"flights — fix the name/id in the Override drawer and re-run."
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# 2026-05-18 (user direction): apply role-change override. Maps
|
|
539
|
+
# employee_id -> new Role enum. Switching to ZC reroutes them
|
|
540
|
+
# into the ZC bucket (H18 target 14-15 day, 11 N); switching to
|
|
541
|
+
# AM zeros shift_today (AMs don't fly — same effect as sick);
|
|
542
|
+
# promoting AM back to STAFF/ZC needs the AM roster row to have
|
|
543
|
+
# carried a shift, so they were already in staff_today (the
|
|
544
|
+
# _build_staff_today loop includes AMs as off-pool entries).
|
|
545
|
+
if role_changes:
|
|
546
|
+
name_lookup = {s.name.strip().upper(): s.employee_id for s in staff_today}
|
|
547
|
+
resolved_changes: dict[str, str] = {}
|
|
548
|
+
for raw_eid, new_role_str in role_changes.items():
|
|
549
|
+
if any(s.employee_id == raw_eid for s in staff_today):
|
|
550
|
+
resolved_changes[raw_eid] = new_role_str
|
|
551
|
+
elif raw_eid.upper() in name_lookup:
|
|
552
|
+
resolved_changes[name_lookup[raw_eid.upper()]] = new_role_str
|
|
553
|
+
if resolved_changes:
|
|
554
|
+
new_staff_today: list[StaffMember] = []
|
|
555
|
+
change_log: list[str] = []
|
|
556
|
+
for s in staff_today:
|
|
557
|
+
new_role_str = resolved_changes.get(s.employee_id)
|
|
558
|
+
if new_role_str is None:
|
|
559
|
+
new_staff_today.append(s)
|
|
560
|
+
continue
|
|
561
|
+
new_role = Role(new_role_str)
|
|
562
|
+
# Promoting to AM == off the live pool. STAFF<->ZC is
|
|
563
|
+
# just a role swap; shift stays the same so the H18
|
|
564
|
+
# bucket auto-routes them based on the new role.
|
|
565
|
+
update_dict: dict = {"role": new_role}
|
|
566
|
+
if new_role == Role.AM:
|
|
567
|
+
update_dict["shift_today"] = None
|
|
568
|
+
new_staff_today.append(s.model_copy(update=update_dict))
|
|
569
|
+
change_log.append(f"{s.name} ({s.role.value}->{new_role.value})")
|
|
570
|
+
staff_today = new_staff_today
|
|
571
|
+
print(
|
|
572
|
+
f" Role-change override: {len(resolved_changes)} staff "
|
|
573
|
+
f"re-roled: {', '.join(change_log)}"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# Phase 3 / Change 7 (2026-05-14, INTL overhaul): D+1 flights with
|
|
577
|
+
# STD ∈ [05:05, 05:30] are flagged ``is_preplan_deferred=True`` by
|
|
578
|
+
# Step 1. They enter the pool but are split out HERE before the
|
|
579
|
+
# solver runs — re-appended at the end as warning="PREPLAN_DEFERRED"
|
|
580
|
+
# rows with empty staff column. Solver never sees them.
|
|
581
|
+
preplan_only_flights: list[FlightInput] = [
|
|
582
|
+
f for f in flights if f.is_preplan_deferred
|
|
583
|
+
]
|
|
584
|
+
if preplan_only_flights:
|
|
585
|
+
pre_ids = {f.unique_id for f in preplan_only_flights}
|
|
586
|
+
flights = [f for f in flights if f.unique_id not in pre_ids]
|
|
587
|
+
counts["FLIGHTS"] -= len(preplan_only_flights)
|
|
588
|
+
print(
|
|
589
|
+
f" Pre-plan-deferred flights split out: "
|
|
590
|
+
f"{len(preplan_only_flights)} (D+1 STD 05:05-05:30; "
|
|
591
|
+
"re-appended with warning=PREPLAN_DEFERRED)"
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Apply per-staff override rows (newbie flag, max_flights,
|
|
595
|
+
# std_start, std_cutoff). The cutoff_time row carries BOTH bounds
|
|
596
|
+
# since 2026-05-27 — std_start is the lower bound (custom window
|
|
597
|
+
# start), std_cutoff is the upper bound (custom window end).
|
|
598
|
+
per_staff_today = {p.employee_id: p for p in per_staff if p.date == d_day}
|
|
599
|
+
if per_staff_today:
|
|
600
|
+
staff_today = [
|
|
601
|
+
s.model_copy(update={
|
|
602
|
+
"is_newbie": per_staff_today[s.employee_id].is_newbie or s.is_newbie,
|
|
603
|
+
"max_flights_cap": per_staff_today[s.employee_id].max_flights or s.max_flights_cap,
|
|
604
|
+
"std_cutoff": per_staff_today[s.employee_id].std_cutoff or s.std_cutoff,
|
|
605
|
+
"std_start": per_staff_today[s.employee_id].std_start or s.std_start,
|
|
606
|
+
}) if s.employee_id in per_staff_today else s
|
|
607
|
+
for s in staff_today
|
|
608
|
+
]
|
|
609
|
+
|
|
610
|
+
# --- Pre-solve capacity check (W210) ----------
|
|
611
|
+
# Soft behavior (post-2026-05-10): if aggregate capacity falls short
|
|
612
|
+
# of flight count, log W210 as ERROR so the assigner sees the gap,
|
|
613
|
+
# but DON'T abort. The solver places what it can; the rest land in
|
|
614
|
+
# UNALLOCATED. Previously this aborted entirely, which masked
|
|
615
|
+
# otherwise-good runs that just had a small capacity shortfall.
|
|
616
|
+
cap = aggregate_capacity(staff_today)
|
|
617
|
+
if cap < len(flights):
|
|
618
|
+
gap = len(flights) - cap
|
|
619
|
+
warnings.append(WarningRow(
|
|
620
|
+
severity=Severity.ERROR, code="W210",
|
|
621
|
+
message=(
|
|
622
|
+
f"aggregate capacity ({cap}) < flight count ({len(flights)}); "
|
|
623
|
+
f"~{gap} flights will be UNALLOCATED. Add staff, raise H16 caps, "
|
|
624
|
+
"or reduce flights."
|
|
625
|
+
),
|
|
626
|
+
))
|
|
627
|
+
|
|
628
|
+
# --- Build eligibility context ----------
|
|
629
|
+
# P2F handler resolution order (per user direction 2026-05-10):
|
|
630
|
+
# 1. AUTO from roster cells P2F/M, P2F/A, P2F/N — first match per
|
|
631
|
+
# shift wins.
|
|
632
|
+
# 2. override `p2f` rows — overwrite the auto-pick if the
|
|
633
|
+
# assigner explicitly nominates a different person.
|
|
634
|
+
staff_by_id = {s.employee_id: s for s in staff_today}
|
|
635
|
+
auto_p2f = _read_auto_p2f_handlers_from_rosters(am_rows, availability, d_day)
|
|
636
|
+
p2f_handler_by_shift: dict[ShiftCode, str] = dict(auto_p2f)
|
|
637
|
+
if auto_p2f:
|
|
638
|
+
print(
|
|
639
|
+
f" auto-picked P2F handlers from roster cells: "
|
|
640
|
+
f"{ {k: v for k, v in auto_p2f.items()} }"
|
|
641
|
+
)
|
|
642
|
+
# Compute which shifts actually have P2F flights in their window —
|
|
643
|
+
# those are the shifts where a missing nomination is a real problem.
|
|
644
|
+
from .allocator.windows import SHIFT_NOMINAL_MIN
|
|
645
|
+
# Per user direction 2026-05-10: P2F handlers are M/A/N only.
|
|
646
|
+
# M1/A1 windows are intentionally NOT counted — those P2F flights
|
|
647
|
+
# have no handler and will land in OUT_Unallocated if their STD
|
|
648
|
+
# doesn't also fall in an M/A/N coverage window.
|
|
649
|
+
p2f_shifts_needing_handler: set[ShiftCode] = set()
|
|
650
|
+
# Per-shift P2F flight count for W215 (8-flight ceiling per handler).
|
|
651
|
+
p2f_count_by_shift: dict[ShiftCode, int] = defaultdict(int)
|
|
652
|
+
# 2026-05-27 (centralization): pulled from schemas — single source
|
|
653
|
+
# of truth for which shifts nominate plain P2F handlers.
|
|
654
|
+
from .schemas import P2F_HANDLER_ELIGIBLE_SHIFTS
|
|
655
|
+
p2f_relevant_shifts = P2F_HANDLER_ELIGIBLE_SHIFTS
|
|
656
|
+
for f in flights:
|
|
657
|
+
if f.ops_class != OpsClass.P2F:
|
|
658
|
+
continue
|
|
659
|
+
std_min = std_to_ops_day_minutes(f.std, f.date, d_day)
|
|
660
|
+
for shift, (start, end) in SHIFT_NOMINAL_MIN.items():
|
|
661
|
+
if shift not in p2f_relevant_shifts:
|
|
662
|
+
continue
|
|
663
|
+
if start <= std_min <= end:
|
|
664
|
+
p2f_shifts_needing_handler.add(shift)
|
|
665
|
+
p2f_count_by_shift[shift] += 1
|
|
666
|
+
break
|
|
667
|
+
|
|
668
|
+
# W215: shifts with > 8 P2F flights need an additional nominee.
|
|
669
|
+
P2F_HANDLER_CAP = 8 # noqa: N806
|
|
670
|
+
for shift, count in sorted(p2f_count_by_shift.items()):
|
|
671
|
+
if count > P2F_HANDLER_CAP:
|
|
672
|
+
extra_needed = -(-count // P2F_HANDLER_CAP) - 1 # ceil-1
|
|
673
|
+
warnings.append(WarningRow(
|
|
674
|
+
severity=Severity.WARN, code="W215", date=d_day,
|
|
675
|
+
message=(
|
|
676
|
+
f"Shift {shift} has {count} P2F flights; one handler "
|
|
677
|
+
f"can do max {P2F_HANDLER_CAP}. Nominate "
|
|
678
|
+
f"{extra_needed} additional P2F handler(s) for {shift} "
|
|
679
|
+
f"in the Override drawer (type=p2f, shift={shift}, employee=<name>)."
|
|
680
|
+
),
|
|
681
|
+
))
|
|
682
|
+
for nom in p2f_nominations:
|
|
683
|
+
if nom.date != d_day:
|
|
684
|
+
continue
|
|
685
|
+
s = staff_by_id.get(nom.employee_id)
|
|
686
|
+
if s is None:
|
|
687
|
+
warnings.append(WarningRow(
|
|
688
|
+
severity=Severity.ERROR, code="W212", date=d_day,
|
|
689
|
+
message=(
|
|
690
|
+
f"P2F nomination for shift {nom.shift}: "
|
|
691
|
+
f"employee_id {nom.employee_id} not on the roster today. "
|
|
692
|
+
"Edit the overrides or check the staff roster."
|
|
693
|
+
),
|
|
694
|
+
))
|
|
695
|
+
continue
|
|
696
|
+
if s.shift_today != nom.shift:
|
|
697
|
+
warnings.append(WarningRow(
|
|
698
|
+
severity=Severity.ERROR, code="W212", date=d_day,
|
|
699
|
+
name=s.name,
|
|
700
|
+
message=(
|
|
701
|
+
f"P2F nomination says {nom.employee_id} ({s.name}) "
|
|
702
|
+
f"is on {nom.shift} today, but the roster has them on "
|
|
703
|
+
f"{s.shift_today or 'OFF'}. "
|
|
704
|
+
"Edit the overrides or fix the roster."
|
|
705
|
+
),
|
|
706
|
+
))
|
|
707
|
+
continue
|
|
708
|
+
if not s.is_p2f_licensed:
|
|
709
|
+
# 2026-05-27 (user direction): a manual P2F nomination is
|
|
710
|
+
# the assigner saying "this person handles P2F today" —
|
|
711
|
+
# don't reject for a missing license cell on the roster.
|
|
712
|
+
# Auto-flip is_p2f_licensed for the day and emit a WARN
|
|
713
|
+
# (not ERROR) so the assigner still sees the nomination
|
|
714
|
+
# was made for someone the roster doesn't tag as P2F.
|
|
715
|
+
print(
|
|
716
|
+
f" P2F nomination: {s.name} ({nom.shift}) — auto-granting "
|
|
717
|
+
"P2F license for today (roster license cell didn't say P2F)"
|
|
718
|
+
)
|
|
719
|
+
staff_today = [
|
|
720
|
+
_s.model_copy(update={
|
|
721
|
+
"is_p2f_licensed": True,
|
|
722
|
+
"license": (
|
|
723
|
+
_s.license if _s.license and "P2F" in _s.license.upper()
|
|
724
|
+
else "P2F (auto from override nomination)"
|
|
725
|
+
),
|
|
726
|
+
}) if _s.employee_id == nom.employee_id else _s
|
|
727
|
+
for _s in staff_today
|
|
728
|
+
]
|
|
729
|
+
# Refresh the local references so subsequent loop iterations
|
|
730
|
+
# see the patched staff.
|
|
731
|
+
staff_by_id = {_s.employee_id: _s for _s in staff_today}
|
|
732
|
+
warnings.append(WarningRow(
|
|
733
|
+
severity=Severity.WARN, code="W212", date=d_day,
|
|
734
|
+
name=s.name,
|
|
735
|
+
message=(
|
|
736
|
+
f"P2F nomination for {s.name}: roster license didn't "
|
|
737
|
+
"include 'P2F', auto-granted for today via override. "
|
|
738
|
+
"Update the roster's license cell to silence this warning."
|
|
739
|
+
),
|
|
740
|
+
))
|
|
741
|
+
p2f_handler_by_shift[nom.shift] = nom.employee_id
|
|
742
|
+
|
|
743
|
+
# Warn loudly for any shift that has P2F flights but no valid nomination.
|
|
744
|
+
for shift in sorted(p2f_shifts_needing_handler):
|
|
745
|
+
if shift not in p2f_handler_by_shift:
|
|
746
|
+
warnings.append(WarningRow(
|
|
747
|
+
severity=Severity.ERROR, code="W212", date=d_day,
|
|
748
|
+
message=(
|
|
749
|
+
f"P2F flights exist for shift {shift} but no valid "
|
|
750
|
+
"P2F handler nomination found in the overrides. "
|
|
751
|
+
"Add a row: type=p2f_handler, date={D}, shift={shift}, "
|
|
752
|
+
"employee_id=<P2F-licensed staff on shift>."
|
|
753
|
+
),
|
|
754
|
+
))
|
|
755
|
+
|
|
756
|
+
p2f_anchors = _build_p2f_handler_anchors(flights, p2f_handler_by_shift, d_day)
|
|
757
|
+
|
|
758
|
+
# ---- Phase R per-flight override sets ----
|
|
759
|
+
# Build a (flt, std_iso) → unique_id resolver so override rows
|
|
760
|
+
# (which key by flt+std) map onto the solver's unique_id keying.
|
|
761
|
+
uid_by_flt_std: dict[tuple[str, str], str] = {
|
|
762
|
+
(f.flt, f.std.isoformat(timespec="minutes")): f.unique_id
|
|
763
|
+
for f in flights
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
def _resolve_uid(flt: str, std_iso) -> str | None:
|
|
767
|
+
key = (flt, std_iso if isinstance(std_iso, str)
|
|
768
|
+
else std_iso.isoformat(timespec="minutes"))
|
|
769
|
+
return uid_by_flt_std.get(key)
|
|
770
|
+
|
|
771
|
+
# SkipP2FBuffer → set of (flight_uid, emp_id) tuples for elig F6
|
|
772
|
+
skip_p2f_buffer_pairs: set[tuple[str, str]] = set()
|
|
773
|
+
for r in skip_p2f_buffer_rows:
|
|
774
|
+
flight_key = f"{r.flight}|{r.std.isoformat(timespec='minutes')}"
|
|
775
|
+
skip_p2f_buffer_pairs.add((flight_key, r.employee_id))
|
|
776
|
+
|
|
777
|
+
# SkipINTLRemoval → set of flight_keys (no employee dimension)
|
|
778
|
+
skip_intl_removal_keys: set[str] = set()
|
|
779
|
+
for r in skip_intl_removal_rows:
|
|
780
|
+
skip_intl_removal_keys.add(
|
|
781
|
+
f"{r.flight}|{r.std.isoformat(timespec='minutes')}"
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
# WaiveH10Pair → set of (uid_a, uid_b, emp_id). We need to resolve
|
|
785
|
+
# both flights' unique_ids. ``other_std`` is on the SAME staff so
|
|
786
|
+
# we scan their existing flights to find it.
|
|
787
|
+
waive_h10_triples: set[tuple[str, str, str]] = set()
|
|
788
|
+
for r in waive_h10_rows:
|
|
789
|
+
uid_a = _resolve_uid(r.flight, r.std)
|
|
790
|
+
if uid_a is None:
|
|
791
|
+
continue
|
|
792
|
+
# other flight: same staff, std = other_std; resolve by scanning
|
|
793
|
+
# flights for matching std (any FLT acceptable — the OTHER side
|
|
794
|
+
# is identified by STD on the same staff at the constraint
|
|
795
|
+
# level, not by FLT)
|
|
796
|
+
other_iso = r.other_std.isoformat(timespec="minutes")
|
|
797
|
+
# Find ANY flight at that std — if the staff has multiple at
|
|
798
|
+
# the same std, all pairs get the waiver.
|
|
799
|
+
for f in flights:
|
|
800
|
+
if f.std.isoformat(timespec="minutes") != other_iso:
|
|
801
|
+
continue
|
|
802
|
+
waive_h10_triples.add(
|
|
803
|
+
(uid_a, f.unique_id, r.employee_id),
|
|
804
|
+
)
|
|
805
|
+
waive_h10_triples.add(
|
|
806
|
+
(f.unique_id, uid_a, r.employee_id), # symmetric
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# RaiseCapForFlight → set of (flight_uid, emp_id)
|
|
810
|
+
raise_cap_uids: set[tuple[str, str]] = set()
|
|
811
|
+
for r in raise_cap_rows:
|
|
812
|
+
uid = _resolve_uid(r.flight, r.std)
|
|
813
|
+
if uid is None:
|
|
814
|
+
continue
|
|
815
|
+
raise_cap_uids.add((uid, r.employee_id))
|
|
816
|
+
|
|
817
|
+
if any([
|
|
818
|
+
skip_p2f_buffer_pairs, skip_intl_removal_keys,
|
|
819
|
+
waive_h10_triples, raise_cap_uids,
|
|
820
|
+
]):
|
|
821
|
+
print(
|
|
822
|
+
" Phase R overrides loaded: "
|
|
823
|
+
f"waive_h10={len(waive_h10_rows)}, raise_cap={len(raise_cap_rows)}, "
|
|
824
|
+
f"skip_p2f_buffer={len(skip_p2f_buffer_rows)}, "
|
|
825
|
+
f"skip_intl_removal={len(skip_intl_removal_rows)}"
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
elig_ctx = EligibilityContext(
|
|
829
|
+
ops_day=d_day,
|
|
830
|
+
p2f_handler_by_shift=p2f_handler_by_shift,
|
|
831
|
+
p2f_flight_minutes_by_handler=p2f_anchors,
|
|
832
|
+
skip_p2f_buffer_pairs=frozenset(skip_p2f_buffer_pairs),
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
# --- Eligibility matrix + zero-eligibility check (W201) ----------
|
|
836
|
+
# Soft behavior (post-2026-05-10): flights with zero eligibility are
|
|
837
|
+
# logged as W201 and EXCLUDED from the solver's input. The remaining
|
|
838
|
+
# flights still proceed. Previously this aborted the entire run.
|
|
839
|
+
matrix = build_matrix(flights, staff_today, elig_ctx)
|
|
840
|
+
# Keyed by FlightInput.unique_id (matches eligibility matrix output).
|
|
841
|
+
zero_elig_flights = [f for f in flights if not matrix.get(f.unique_id)]
|
|
842
|
+
zero_elig_reasons: dict[str, str] = {}
|
|
843
|
+
for f in zero_elig_flights:
|
|
844
|
+
diags = diagnose_unassignable(f, staff_today, elig_ctx)
|
|
845
|
+
# If EVERY staff failed F5 (OUT_OF_SHIFT) the flight's STD lies
|
|
846
|
+
# outside every shift's distribution window — surface the exact
|
|
847
|
+
# message the user asked for in OUT_Unallocated (2026-05-12 §1).
|
|
848
|
+
if diags and all(r.value == "STD outside shift's distribution window (user 2026-05-12)"
|
|
849
|
+
for _eid, r in diags):
|
|
850
|
+
top_reason = "STD outside all shift windows"
|
|
851
|
+
else:
|
|
852
|
+
top_reason = diags[0][1].value if diags else "no eligible staff"
|
|
853
|
+
zero_elig_reasons[f.unique_id] = top_reason
|
|
854
|
+
# Phase 3 / Change 4 (2026-05-14, INTL overhaul): when an INTL DEP
|
|
855
|
+
# flight is unassignable specifically because no on-shift handler
|
|
856
|
+
# covers D-75 → STD, emit W216 INTL_UNASSIGNABLE (in addition to
|
|
857
|
+
# the generic W201). Gives the assigner a sharper signal.
|
|
858
|
+
is_intl_coverage_failure = (
|
|
859
|
+
f.is_international
|
|
860
|
+
and diags
|
|
861
|
+
and all(
|
|
862
|
+
r.name == "INTL_SHIFT_COVERAGE"
|
|
863
|
+
for _eid, r in diags
|
|
864
|
+
)
|
|
865
|
+
)
|
|
866
|
+
if is_intl_coverage_failure:
|
|
867
|
+
warnings.append(WarningRow(
|
|
868
|
+
severity=Severity.ERROR, code="W216", date=f.date,
|
|
869
|
+
message=(
|
|
870
|
+
f"INTL flight {f.flt} (STD "
|
|
871
|
+
f"{f.std.isoformat(timespec='minutes')}, DEP {f.dep}) "
|
|
872
|
+
"is unassignable — no on-shift handler covers D-75 "
|
|
873
|
+
"through STD. Add an override or reshape the roster."
|
|
874
|
+
),
|
|
875
|
+
))
|
|
876
|
+
warnings.append(WarningRow(
|
|
877
|
+
severity=Severity.ERROR, code="W201", date=f.date,
|
|
878
|
+
message=(
|
|
879
|
+
f"flight {f.flt} (STD {f.std.isoformat(timespec='minutes')}) "
|
|
880
|
+
f"has zero eligible staff — left UNALLOCATED. Top diagnoses: "
|
|
881
|
+
+ "; ".join(f"{eid}: {r.value}" for eid, r in diags[:3])
|
|
882
|
+
),
|
|
883
|
+
))
|
|
884
|
+
if zero_elig_flights:
|
|
885
|
+
zero_ids = {f.unique_id for f in zero_elig_flights}
|
|
886
|
+
flights = [f for f in flights if f.unique_id not in zero_ids]
|
|
887
|
+
# Drop their entries from the matrix too — solver only sees viable.
|
|
888
|
+
matrix = {fid: s for fid, s in matrix.items() if fid not in zero_ids}
|
|
889
|
+
|
|
890
|
+
# --- Pair generation (W211 on PairGenerationError) ----------
|
|
891
|
+
# 2026-05-28 (Phase 2 default): generate_pairs hoists today's P2F
|
|
892
|
+
# nominees to the front of M / A / N STAFF lists so they pair
|
|
893
|
+
# across boundaries (Change 2), and folds orphan ZCs into the
|
|
894
|
+
# STAFF pool when ZC counts mismatch (Change 3 — A↔A1 ZC pair +
|
|
895
|
+
# STAFF spillover). The behavior is unconditional now — empty
|
|
896
|
+
# p2f_handlers / balanced ZCs degrade gracefully.
|
|
897
|
+
try:
|
|
898
|
+
pairs = generate_pairs(
|
|
899
|
+
staff_today, overrides=None,
|
|
900
|
+
p2f_handlers=elig_ctx.p2f_handler_by_shift,
|
|
901
|
+
)
|
|
902
|
+
# Task 3 (2026-05-12): scan pairs for staff with multiple
|
|
903
|
+
# partners at the same boundary — flag everything except the
|
|
904
|
+
# sanctioned A→N split-1+2 pattern. W215 warnings flow into
|
|
905
|
+
# OUT_Warnings + the dashboard panel.
|
|
906
|
+
from .allocator.pair_validation import validate_pair_discrepancies
|
|
907
|
+
warnings.extend(validate_pair_discrepancies(pairs, d_day))
|
|
908
|
+
except PairGenerationError as e:
|
|
909
|
+
warnings.append(WarningRow(
|
|
910
|
+
severity=Severity.ERROR, code="W211", message=str(e),
|
|
911
|
+
))
|
|
912
|
+
_store_warnings(state, warnings, append=append_warnings)
|
|
913
|
+
state.run_date = d_day
|
|
914
|
+
state.stamp_run(
|
|
915
|
+
mode=mode_label, solver_status="INFEASIBLE",
|
|
916
|
+
duration_s=_time.monotonic() - started,
|
|
917
|
+
)
|
|
918
|
+
counts["WARNINGS"] = len(warnings)
|
|
919
|
+
return counts
|
|
920
|
+
counts["PAIRS"] = len(pairs)
|
|
921
|
+
|
|
922
|
+
# --- Day-level workload target (Finding 1, 2026-05-15) ------------
|
|
923
|
+
# The manual allocator picks ONE workload level per day and pins
|
|
924
|
+
# almost every non-handler staff to it (everyone-at-22 on light
|
|
925
|
+
# days, everyone-at-24 on heavy ones). Solver matches this by
|
|
926
|
+
# computing the level from the day's flight-to-staff ratio and
|
|
927
|
+
# using it as the target in S1. See MANUAL_PATTERNS_VS_ENGINE.md
|
|
928
|
+
# Finding 1 for evidence.
|
|
929
|
+
#
|
|
930
|
+
# Per (shift, role): level = round(total_flights / non_handler_staff_count),
|
|
931
|
+
# clipped to [band.min, band.max] from configs/shift_limits.json.
|
|
932
|
+
# P2F handlers are excluded from the bucket —
|
|
933
|
+
# they're already shape-shifted by their handler-specific rules.
|
|
934
|
+
#
|
|
935
|
+
# 2026-09-23 fix: ``total_flights`` above must be the flights that
|
|
936
|
+
# fall in THAT shift's own window, not the whole day's flight
|
|
937
|
+
# count. The previous version divided every bucket by the SAME
|
|
938
|
+
# global ratio (len(flights) / all-shifts' staff), so a shift with
|
|
939
|
+
# a much lighter night-time flight volume (e.g. N: ~169 flights /
|
|
940
|
+
# 18 staff =~ 9-10 each) still got handed the day-wide ratio
|
|
941
|
+
# (~21, driven by the much busier M/A shifts) before band-clipping
|
|
942
|
+
# ever saw it. Clipped up to band.min, that level was unreachable
|
|
943
|
+
# for anyone in the bucket, weakening S1's ability to pull the
|
|
944
|
+
# bucket together (2026-09-23: N/STAFF spread of 5-11 survived a
|
|
945
|
+
# 700s solve). Bucketing flights by their own shift window (same
|
|
946
|
+
# windowing the solver enforces via eligibility) gives each shift
|
|
947
|
+
# its own realistic ratio instead.
|
|
948
|
+
from .allocator.windows import SHIFT_NOMINAL_MIN as _SHIFT_NOMINAL_MIN
|
|
949
|
+
flights_per_shift_min: dict[ShiftCode, int] = defaultdict(int)
|
|
950
|
+
for f in flights:
|
|
951
|
+
f_std_min = std_to_ops_day_minutes(f.std, f.date, d_day)
|
|
952
|
+
for shift_code_iter, (s_start, s_end) in _SHIFT_NOMINAL_MIN.items():
|
|
953
|
+
if s_start <= f_std_min <= s_end:
|
|
954
|
+
flights_per_shift_min[shift_code_iter] += 1
|
|
955
|
+
break
|
|
956
|
+
handler_set = set(p2f_handler_by_shift.values())
|
|
957
|
+
bucket_sizes: dict[tuple[ShiftCode, Role], int] = defaultdict(int)
|
|
958
|
+
staff_per_shift: dict[ShiftCode, int] = defaultdict(int)
|
|
959
|
+
for s in staff_today:
|
|
960
|
+
if (s.shift_today is None or s.role == Role.AM
|
|
961
|
+
or s.employee_id in handler_set):
|
|
962
|
+
continue
|
|
963
|
+
bucket_sizes[(s.shift_today, s.role)] += 1
|
|
964
|
+
staff_per_shift[s.shift_today] += 1
|
|
965
|
+
from .allocator.caps import get_band as _get_band
|
|
966
|
+
day_level_by_bucket: dict[tuple[ShiftCode, Role], int] = {}
|
|
967
|
+
for (shift_code, role), size in bucket_sizes.items():
|
|
968
|
+
shift_staff_count = staff_per_shift.get(shift_code) or 1
|
|
969
|
+
raw_level = round(
|
|
970
|
+
flights_per_shift_min.get(shift_code, 0) / shift_staff_count
|
|
971
|
+
)
|
|
972
|
+
band = _get_band(shift_code, role)
|
|
973
|
+
if band is None:
|
|
974
|
+
day_level_by_bucket[(shift_code, role)] = raw_level
|
|
975
|
+
continue
|
|
976
|
+
lo, hi = band["min"], band["max"]
|
|
977
|
+
day_level_by_bucket[(shift_code, role)] = max(lo, min(raw_level, hi))
|
|
978
|
+
if day_level_by_bucket:
|
|
979
|
+
print(
|
|
980
|
+
" Day-level targeting (per-shift ratio, 2026-09-23 fix): "
|
|
981
|
+
+ ", ".join(
|
|
982
|
+
f"{k[0]}/{k[1].value}={v} "
|
|
983
|
+
f"(flights_in_shift={flights_per_shift_min.get(k[0], 0)}, "
|
|
984
|
+
f"staff_in_shift={staff_per_shift.get(k[0], 0)})"
|
|
985
|
+
for k, v in sorted(day_level_by_bucket.items())
|
|
986
|
+
)
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
# --- Sick-call iteration: pin prior allocation, redistribute only
|
|
990
|
+
# the sick person's flights (2026-05-18, user direction) ---------
|
|
991
|
+
# When `sick_employee_ids` is non-empty AND the console workbook
|
|
992
|
+
# already has allocation rows from a prior solve, build a
|
|
993
|
+
# PIN SET: every (flight_uid, employee_id) that was previously
|
|
994
|
+
# assigned to a NON-sick staff. Pass to the solver — those pairs
|
|
995
|
+
# are forced to x=1 (hard constraint), so only the sick person's
|
|
996
|
+
# flights have free decision vars. Solver redistributes ONLY them
|
|
997
|
+
# to remaining staff. Minimum disruption to the existing plan.
|
|
998
|
+
pinned_assignments: dict[str, str] = {}
|
|
999
|
+
# 2026-05-18: collect every employee whose prior assignments should
|
|
1000
|
+
# be freed for redistribution. Includes sick AND role-changed
|
|
1001
|
+
# staff (especially STAFF->ZC: their workload needs to drop from
|
|
1002
|
+
# 22 to 14-15, so extra flights MUST redistribute; and
|
|
1003
|
+
# STAFF/ZC->AM: all their flights become unallocated, then redist).
|
|
1004
|
+
free_for_redist: set[str] = set()
|
|
1005
|
+
if sick_employee_ids or role_changes:
|
|
1006
|
+
name_lookup = {s.name.strip().upper(): s.employee_id for s in staff_today}
|
|
1007
|
+
for raw in (sick_employee_ids or []):
|
|
1008
|
+
if any(s.employee_id == raw for s in staff_today):
|
|
1009
|
+
free_for_redist.add(raw)
|
|
1010
|
+
elif raw.upper() in name_lookup:
|
|
1011
|
+
free_for_redist.add(name_lookup[raw.upper()])
|
|
1012
|
+
for raw_eid in (role_changes or {}):
|
|
1013
|
+
if any(s.employee_id == raw_eid for s in staff_today):
|
|
1014
|
+
free_for_redist.add(raw_eid)
|
|
1015
|
+
elif raw_eid.upper() in name_lookup:
|
|
1016
|
+
free_for_redist.add(name_lookup[raw_eid.upper()])
|
|
1017
|
+
for uid, staff_name in prev_staff_by_key.items():
|
|
1018
|
+
eid = name_lookup.get(staff_name.strip().upper())
|
|
1019
|
+
if eid is None or eid in free_for_redist:
|
|
1020
|
+
continue
|
|
1021
|
+
# Only pin if this staff is still on shift — don't lock a
|
|
1022
|
+
# prior allocation to someone who's now off for some other
|
|
1023
|
+
# reason (eligibility would reject it anyway).
|
|
1024
|
+
if any(s_.employee_id == eid and s_.shift_today is not None
|
|
1025
|
+
for s_ in staff_today):
|
|
1026
|
+
pinned_assignments[uid] = eid
|
|
1027
|
+
if pinned_assignments:
|
|
1028
|
+
print(
|
|
1029
|
+
f" Sick re-solve: pinned {len(pinned_assignments)} prior "
|
|
1030
|
+
f"assignment(s); only sick staff's flights free to move"
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
# --- Solve ----------
|
|
1034
|
+
# 2026-09-23: try a HARD same-bucket spread ceiling of 2 first
|
|
1035
|
+
# (user direction — "give that person a break" once they're more
|
|
1036
|
+
# than 2 behind their least-loaded peer). If the day's real H10
|
|
1037
|
+
# spacing / eligibility shape makes that impossible somewhere,
|
|
1038
|
+
# CP-SAT reports the WHOLE day INFEASIBLE (one combined model —
|
|
1039
|
+
# see hard_bucket_spread_max docstring), so we retry without the
|
|
1040
|
+
# hard cap rather than falling all the way through to the greedy
|
|
1041
|
+
# fallback, which would drop every soft objective for the entire
|
|
1042
|
+
# day just because one bucket couldn't hit spread<=2.
|
|
1043
|
+
_solve_kwargs = dict(
|
|
1044
|
+
ops_day=d_day,
|
|
1045
|
+
max_seconds=config.solver.max_seconds,
|
|
1046
|
+
num_workers=config.solver.num_workers,
|
|
1047
|
+
elig_ctx=elig_ctx,
|
|
1048
|
+
enable_s1_count_balance=True,
|
|
1049
|
+
# S3 (heavy-flight spread) used pax to identify "heavy" flights;
|
|
1050
|
+
# disabled per user direction 2026-05-10 — pax is reference-only.
|
|
1051
|
+
enable_s3_heavy_spread=False,
|
|
1052
|
+
enable_s4_pair_stability=True,
|
|
1053
|
+
enable_s5_handover_preference=True,
|
|
1054
|
+
enable_s6_awkward_preference=True,
|
|
1055
|
+
enable_s7_zc_buffer_avoidance=True,
|
|
1056
|
+
# Phase 4 (2026-05-14, INTL overhaul, Changes 5 + 6): soft INTL
|
|
1057
|
+
# spacing penalty (15-30 min same-handler band) + fair INTL
|
|
1058
|
+
# distribution across handlers within each shift.
|
|
1059
|
+
enable_s_intl_spacing=True,
|
|
1060
|
+
enable_s_intl_fair=True,
|
|
1061
|
+
# Patch 2026-05-15: within the rush bands, prefer staff
|
|
1062
|
+
# whose shift isn't transitioning (±30 min of nominal start/end).
|
|
1063
|
+
enable_s_band_shift_stability=True,
|
|
1064
|
+
# Phase R per-flight overrides — empty by default, populated
|
|
1065
|
+
# by operator approvals via the agent chat layer.
|
|
1066
|
+
waive_h10_triples=frozenset(waive_h10_triples),
|
|
1067
|
+
raise_cap_uids=frozenset(raise_cap_uids),
|
|
1068
|
+
# Phase R re-solve: pass previous-run assignments as warm-start
|
|
1069
|
+
# hints when the caller supplies them.
|
|
1070
|
+
solution_hints=solution_hints,
|
|
1071
|
+
# Finding 1 (2026-05-15): day-level workload targeting.
|
|
1072
|
+
day_level_by_bucket=day_level_by_bucket,
|
|
1073
|
+
# 2026-05-18 sick-call iteration: pin prior allocation in place.
|
|
1074
|
+
pinned_assignments=pinned_assignments or None,
|
|
1075
|
+
)
|
|
1076
|
+
result = solve_allocation(
|
|
1077
|
+
flights, staff_today, matrix,
|
|
1078
|
+
hard_bucket_spread_max=2,
|
|
1079
|
+
**{
|
|
1080
|
+
**_solve_kwargs,
|
|
1081
|
+
# 2026-09-23: give the hard-capped attempt a SHORT probe
|
|
1082
|
+
# budget rather than the full config, so a bucket that
|
|
1083
|
+
# can't hit spread<=2 fails fast instead of burning the
|
|
1084
|
+
# whole time budget twice (once to fail here, again to
|
|
1085
|
+
# solve properly below). If it's achievable, CP-SAT
|
|
1086
|
+
# usually finds it well inside this window; if not, we
|
|
1087
|
+
# want to find that out quickly and move on.
|
|
1088
|
+
"max_seconds": max(60, min(config.solver.max_seconds // 2, 300)),
|
|
1089
|
+
},
|
|
1090
|
+
)
|
|
1091
|
+
# Any non-usable status here (INFEASIBLE, TIMEOUT/UNKNOWN,
|
|
1092
|
+
# MODEL_INVALID) means we do NOT have a real assignment set —
|
|
1093
|
+
# only OPTIMAL/FEASIBLE do. Retry without the hard cap, with the
|
|
1094
|
+
# FULL configured time budget, rather than falling through to the
|
|
1095
|
+
# greedy fallback (which would drop every soft objective for the
|
|
1096
|
+
# whole day just because one bucket couldn't hit spread<=2).
|
|
1097
|
+
if result.status not in ("OPTIMAL", "FEASIBLE"):
|
|
1098
|
+
print(
|
|
1099
|
+
f" Hard spread<=2 probe returned {result.status} — "
|
|
1100
|
+
"retrying without the hard cap (soft spread penalty only, "
|
|
1101
|
+
"full time budget)."
|
|
1102
|
+
)
|
|
1103
|
+
result = solve_allocation(
|
|
1104
|
+
flights, staff_today, matrix,
|
|
1105
|
+
hard_bucket_spread_max=None,
|
|
1106
|
+
**_solve_kwargs,
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
# --- Greedy fallback (2026-09-22) ----------
|
|
1110
|
+
# CP-SAT is an EXACT solver: if any combination of hard constraints
|
|
1111
|
+
# is mutually unsatisfiable, it reports INFEASIBLE and hands back
|
|
1112
|
+
# ZERO assignments for the WHOLE day — even for flights that had
|
|
1113
|
+
# nothing to do with the conflict (see the 2026-09-22 incident:
|
|
1114
|
+
# a static N/ZC workload floor with zero actual night-ops flights
|
|
1115
|
+
# took down all 2117 flights, not just the 3 N/ZC staff). The
|
|
1116
|
+
# solver-side fix (soft ZC floor) addresses THAT specific cause,
|
|
1117
|
+
# but as a safety net against any future/unknown infeasibility, if
|
|
1118
|
+
# CP-SAT comes back without a usable solution we fall back to a
|
|
1119
|
+
# greedy pass that only enforces the non-negotiable physical rules
|
|
1120
|
+
# (H1 one-staff-per-flight, H10 spacing, H16 caps) and ignores
|
|
1121
|
+
# every soft objective. Degraded (unbalanced) but never empty.
|
|
1122
|
+
if result.status not in ("OPTIMAL", "FEASIBLE") or not result.assignments:
|
|
1123
|
+
print(
|
|
1124
|
+
f" !! CP-SAT returned status={result.status} with "
|
|
1125
|
+
f"{len(result.assignments)} assignment(s) — falling back to "
|
|
1126
|
+
"the greedy allocator (H1/H10/H16 only, no workload "
|
|
1127
|
+
"balancing) so flights still get placed. Review the "
|
|
1128
|
+
"Workload tab manually after this run."
|
|
1129
|
+
)
|
|
1130
|
+
from .allocator.greedy_fallback import greedy_allocate
|
|
1131
|
+
fallback_assignments = greedy_allocate(
|
|
1132
|
+
flights, staff_today, matrix, ops_day=d_day,
|
|
1133
|
+
)
|
|
1134
|
+
warnings.append(WarningRow(
|
|
1135
|
+
severity=Severity.ERROR, code="W299", date=d_day,
|
|
1136
|
+
message=(
|
|
1137
|
+
f"CP-SAT could not produce a solution (status="
|
|
1138
|
+
f"{result.status}). Used the greedy fallback instead: "
|
|
1139
|
+
f"{len(fallback_assignments)}/{len(flights)} flights "
|
|
1140
|
+
"placed WITHOUT workload balancing, handover, INTL "
|
|
1141
|
+
"spacing, or P2F-cap logic. Review the Workload and "
|
|
1142
|
+
"Warnings tabs before sending this out, and report this "
|
|
1143
|
+
"run so the underlying CP-SAT infeasibility gets fixed."
|
|
1144
|
+
),
|
|
1145
|
+
))
|
|
1146
|
+
result = AllocationSolverResult(
|
|
1147
|
+
status=f"{result.status}_GREEDY_FALLBACK",
|
|
1148
|
+
assignments=fallback_assignments,
|
|
1149
|
+
wall_clock_seconds=result.wall_clock_seconds,
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
feasible = result.status in ("OPTIMAL", "FEASIBLE") or "GREEDY_FALLBACK" in result.status
|
|
1153
|
+
counts["FEASIBLE"] = int(feasible)
|
|
1154
|
+
counts["ASSIGNED"] = len(result.assignments)
|
|
1155
|
+
# UNALLOCATED includes both flights the solver couldn't place AND the
|
|
1156
|
+
# zero-eligibility flights that were dropped pre-solve (W201).
|
|
1157
|
+
counts["UNALLOCATED"] = (
|
|
1158
|
+
len(flights) - len(result.assignments) + len(zero_elig_flights)
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
# --- Post-solve assembly ----------
|
|
1162
|
+
rows = assemble_allocation_rows(
|
|
1163
|
+
flights, staff_today, result.assignments, pairs, d_day,
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
# 2026-05-28 (user direction): P2F post-pass ALWAYS runs BEFORE the
|
|
1167
|
+
# INTL post-pass — regardless of which P2F variant is selected via
|
|
1168
|
+
# ``p2f_adjustment.logic_v2``. Rationale: if INTL D-75 removal goes
|
|
1169
|
+
# first and removes a preceding domestic flight, then P2F removal
|
|
1170
|
+
# might also remove the INTL itself (or vice-versa), leading to
|
|
1171
|
+
# double-displacements. By doing P2F first, the slots it frees are
|
|
1172
|
+
# accounted for when INTL D-75 evaluates its own preceding-removal
|
|
1173
|
+
# need.
|
|
1174
|
+
_p2f_logic_v2 = bool(getattr(config.p2f_adjustment, "logic_v2", False))
|
|
1175
|
+
|
|
1176
|
+
def _run_intl_postpass(in_rows):
|
|
1177
|
+
from .allocator.postpass_intl import apply_intl_post_pass
|
|
1178
|
+
out_rows, summary = apply_intl_post_pass(
|
|
1179
|
+
in_rows, flights, staff_today, matrix,
|
|
1180
|
+
elig_ctx_p2f_handlers=elig_ctx.p2f_handler_by_shift,
|
|
1181
|
+
d_day=d_day,
|
|
1182
|
+
# Phase R: per-INTL-flight opt-out from the preceding-removal.
|
|
1183
|
+
skip_intl_removal_keys=frozenset(skip_intl_removal_keys),
|
|
1184
|
+
)
|
|
1185
|
+
if summary.n_intl_processed:
|
|
1186
|
+
print(
|
|
1187
|
+
f" INTL post-pass: processed={summary.n_intl_processed} "
|
|
1188
|
+
f"removed={summary.n_case_2b} "
|
|
1189
|
+
f"redistributed={summary.n_redistributed} "
|
|
1190
|
+
f"redist_failed={summary.n_redistribute_failed}"
|
|
1191
|
+
)
|
|
1192
|
+
for line in summary.log_lines[:20]:
|
|
1193
|
+
print(f" intl: {line}")
|
|
1194
|
+
if len(summary.log_lines) > 20:
|
|
1195
|
+
print(f" intl: ... + {len(summary.log_lines) - 20} more lines")
|
|
1196
|
+
return out_rows, summary
|
|
1197
|
+
|
|
1198
|
+
def _run_p2f_postpass(in_rows):
|
|
1199
|
+
if _p2f_logic_v2:
|
|
1200
|
+
from .allocator.postpass_p2f import apply_p2f_post_pass_v2
|
|
1201
|
+
out_rows, summary = apply_p2f_post_pass_v2(
|
|
1202
|
+
in_rows, flights, staff_today, matrix,
|
|
1203
|
+
p2f_handlers=elig_ctx.p2f_handler_by_shift,
|
|
1204
|
+
d_day=d_day,
|
|
1205
|
+
)
|
|
1206
|
+
tag = "P2F post-pass (v2 union window)"
|
|
1207
|
+
else:
|
|
1208
|
+
from .allocator.postpass_p2f import apply_p2f_post_pass
|
|
1209
|
+
out_rows, summary = apply_p2f_post_pass(
|
|
1210
|
+
in_rows, flights, staff_today, matrix,
|
|
1211
|
+
p2f_handlers=elig_ctx.p2f_handler_by_shift,
|
|
1212
|
+
d_day=d_day,
|
|
1213
|
+
tolerance_minutes=config.p2f_adjustment.tolerance_minutes,
|
|
1214
|
+
)
|
|
1215
|
+
tag = "P2F post-pass"
|
|
1216
|
+
if summary.n_p2f_processed:
|
|
1217
|
+
print(
|
|
1218
|
+
f" {tag}: processed={summary.n_p2f_processed} "
|
|
1219
|
+
f"removed={summary.n_removed} "
|
|
1220
|
+
f"redistributed={summary.n_redistributed} "
|
|
1221
|
+
f"redist_failed={summary.n_redistribute_failed} "
|
|
1222
|
+
f"anchor_shortages={summary.n_anchor_shortage}"
|
|
1223
|
+
)
|
|
1224
|
+
for line in summary.log_lines[:20]:
|
|
1225
|
+
print(f" p2f: {line}")
|
|
1226
|
+
if len(summary.log_lines) > 20:
|
|
1227
|
+
print(f" p2f: ... + {len(summary.log_lines) - 20} more lines")
|
|
1228
|
+
return out_rows, summary
|
|
1229
|
+
|
|
1230
|
+
# 2026-05-28 (user direction): always P2F first, then INTL —
|
|
1231
|
+
# regardless of which P2F variant is active. Avoids the
|
|
1232
|
+
# double-displacement edge case described above.
|
|
1233
|
+
print(
|
|
1234
|
+
" post-pass order: P2F"
|
|
1235
|
+
+ (" (v2 union window)" if _p2f_logic_v2 else "")
|
|
1236
|
+
+ " -> INTL",
|
|
1237
|
+
)
|
|
1238
|
+
rows, p2f_summary = _run_p2f_postpass(rows)
|
|
1239
|
+
rows, intl_summary = _run_intl_postpass(rows)
|
|
1240
|
+
|
|
1241
|
+
# 2026-09-23 fix: the P2F/INTL post-passes above remove flights
|
|
1242
|
+
# from whoever happens to sit next to a P2F/INTL flight, not from
|
|
1243
|
+
# whoever currently has the most flights — so they can re-open the
|
|
1244
|
+
# spread the solver's H18 constraint just closed (see
|
|
1245
|
+
# docs/REVIEW_NOTES.md finding #4). Run a final leveling pass:
|
|
1246
|
+
# while a (shift, role) bucket's most- and least-loaded staff
|
|
1247
|
+
# differ by more than 1, move one eligible flight from the former
|
|
1248
|
+
# to the latter. Every move still respects eligibility, H10
|
|
1249
|
+
# spacing, and hard caps — it only re-picks who among already-
|
|
1250
|
+
# eligible people does which flight.
|
|
1251
|
+
from .allocator.postpass_rebalance import apply_rebalance_pass
|
|
1252
|
+
rows, rebalance_summary = apply_rebalance_pass(
|
|
1253
|
+
rows, flights, staff_today, matrix,
|
|
1254
|
+
p2f_handlers=elig_ctx.p2f_handler_by_shift,
|
|
1255
|
+
d_day=d_day,
|
|
1256
|
+
)
|
|
1257
|
+
if rebalance_summary.n_transfers:
|
|
1258
|
+
print(
|
|
1259
|
+
f" Rebalance pass: buckets_checked="
|
|
1260
|
+
f"{rebalance_summary.n_buckets_checked} "
|
|
1261
|
+
f"buckets_improved={rebalance_summary.n_buckets_improved} "
|
|
1262
|
+
f"buckets_still_stuck={rebalance_summary.n_buckets_stuck} "
|
|
1263
|
+
f"transfers={rebalance_summary.n_transfers}"
|
|
1264
|
+
)
|
|
1265
|
+
for line in rebalance_summary.log_lines[:20]:
|
|
1266
|
+
print(f" rebalance: {line}")
|
|
1267
|
+
if len(rebalance_summary.log_lines) > 20:
|
|
1268
|
+
print(f" rebalance: ... + {len(rebalance_summary.log_lines) - 20} more lines")
|
|
1269
|
+
|
|
1270
|
+
# 2026-09-23 (user direction): floating mid-shift break per on-shift
|
|
1271
|
+
# staff (length from config.yml, set in the Setup sidebar),
|
|
1272
|
+
# soft/best-effort, via 1-for-1 swaps only — never changes
|
|
1273
|
+
# anyone's flight count, so it can't disturb the H18/rebalance
|
|
1274
|
+
# workload spread above. Runs LAST, against the final schedule.
|
|
1275
|
+
from .allocator.postpass_break import apply_break_pass
|
|
1276
|
+
rows, break_summary = apply_break_pass(
|
|
1277
|
+
rows, flights, staff_today, matrix, d_day,
|
|
1278
|
+
break_len=config.break_pass.length_minutes,
|
|
1279
|
+
)
|
|
1280
|
+
print(
|
|
1281
|
+
f" Break pass ({config.break_pass.length_minutes} min): "
|
|
1282
|
+
f"considered={break_summary.n_staff_considered} "
|
|
1283
|
+
f"already_clear={break_summary.n_clean} "
|
|
1284
|
+
f"cleared_via_swap={break_summary.n_created_via_swap} "
|
|
1285
|
+
f"partial={break_summary.n_partial} "
|
|
1286
|
+
f"not_found={break_summary.n_not_found} "
|
|
1287
|
+
f"swaps={break_summary.n_swaps}"
|
|
1288
|
+
)
|
|
1289
|
+
for line in break_summary.log_lines[:20]:
|
|
1290
|
+
print(f" break: {line}")
|
|
1291
|
+
if len(break_summary.log_lines) > 20:
|
|
1292
|
+
print(f" break: ... + {len(break_summary.log_lines) - 20} more lines")
|
|
1293
|
+
|
|
1294
|
+
# Merge all post-pass workload-note dicts (host -> list).
|
|
1295
|
+
merged_workload_notes: dict[str, list[str]] = defaultdict(list)
|
|
1296
|
+
for sid, msgs in intl_summary.workload_notes.items():
|
|
1297
|
+
merged_workload_notes[sid].extend(msgs)
|
|
1298
|
+
for sid, msgs in p2f_summary.workload_notes.items():
|
|
1299
|
+
merged_workload_notes[sid].extend(msgs)
|
|
1300
|
+
for sid, msgs in rebalance_summary.workload_notes.items():
|
|
1301
|
+
merged_workload_notes[sid].extend(msgs)
|
|
1302
|
+
for sid, msgs in break_summary.workload_notes.items():
|
|
1303
|
+
merged_workload_notes[sid].extend(msgs)
|
|
1304
|
+
|
|
1305
|
+
# 2026-05-26 fix: post-passes displace flights between staff via
|
|
1306
|
+
# ``_set_assignee``, which updates the row's staff fields but
|
|
1307
|
+
# never recomputes planned_by / relieved_by. So a flight moved
|
|
1308
|
+
# from Ravi (A→N partner = Kunal) to Priya (A→N partner = Suraj)
|
|
1309
|
+
# showed staff=Priya AND planned_by=Kunal — even though Kunal-
|
|
1310
|
+
# Priya isn't a pair. Re-derive the labels against each row's
|
|
1311
|
+
# CURRENT staff so the allocations match the pair map.
|
|
1312
|
+
from .allocator.postsolve import relabel_pair_columns
|
|
1313
|
+
rows = relabel_pair_columns(rows, flights, staff_today, pairs, d_day)
|
|
1314
|
+
|
|
1315
|
+
# W220 (2026-05-26 user direction): M-shift P2F handlers whose P2F
|
|
1316
|
+
# STD falls before 07:30 have their D-3hrs pre-brief window land
|
|
1317
|
+
# before M shift starts (04:00) — meaning no on-shift handler can
|
|
1318
|
+
# actually do the briefing. The previous-day N shift would
|
|
1319
|
+
# normally cover it via handover, but today's engine has no
|
|
1320
|
+
# visibility into yesterday's roster. We can't prevent the
|
|
1321
|
+
# assignment, but we surface it loudly so the assigner can
|
|
1322
|
+
# arrange a manual handover.
|
|
1323
|
+
#
|
|
1324
|
+
# Other shifts (A / N) are NOT warned: today's M can handover to
|
|
1325
|
+
# today's A (M→A pair), and today's A handovers to today's N
|
|
1326
|
+
# (A→N pair). Only M is "first of the day" with no precursor.
|
|
1327
|
+
from datetime import time as _time_t
|
|
1328
|
+
_M_P2F_WARN_LOWER = _time_t(4, 0)
|
|
1329
|
+
_M_P2F_WARN_UPPER = _time_t(7, 30)
|
|
1330
|
+
_flight_by_uid_warn = {f.unique_id: f for f in flights}
|
|
1331
|
+
_warned_uids: dict[str, str] = {}
|
|
1332
|
+
for r in rows:
|
|
1333
|
+
if not r.staff_employee_id:
|
|
1334
|
+
continue
|
|
1335
|
+
_uid = (
|
|
1336
|
+
f"{r.flt}|{r.dep}|{r.arr}|"
|
|
1337
|
+
f"{r.std.isoformat(timespec='minutes')}|"
|
|
1338
|
+
f"{r.date.isoformat()}"
|
|
1339
|
+
)
|
|
1340
|
+
_f = _flight_by_uid_warn.get(_uid)
|
|
1341
|
+
if _f is None or _f.ops_class != OpsClass.P2F:
|
|
1342
|
+
continue
|
|
1343
|
+
_handler = staff_by_id.get(r.staff_employee_id)
|
|
1344
|
+
if _handler is None or _handler.shift_today != "M":
|
|
1345
|
+
continue
|
|
1346
|
+
if not (_M_P2F_WARN_LOWER <= _f.std <= _M_P2F_WARN_UPPER):
|
|
1347
|
+
continue
|
|
1348
|
+
_short_msg = (
|
|
1349
|
+
f"M-shift P2F before 07:30 — D-3hrs pre-brief is before "
|
|
1350
|
+
"shift start (04:00); arrange manual handover from prev "
|
|
1351
|
+
"day N shift"
|
|
1352
|
+
)
|
|
1353
|
+
warnings.append(WarningRow(
|
|
1354
|
+
severity=Severity.WARN, code="W220", date=d_day,
|
|
1355
|
+
name=_handler.name,
|
|
1356
|
+
message=(
|
|
1357
|
+
f"P2F flight {_f.flt} STD "
|
|
1358
|
+
f"{_f.std.isoformat(timespec='minutes')} assigned to "
|
|
1359
|
+
f"M-shift handler {_handler.name}: D-3hrs pre-brief "
|
|
1360
|
+
f"window ({_f.std.replace(hour=max(0, _f.std.hour - 3)).isoformat(timespec='minutes')} "
|
|
1361
|
+
"approx) falls before M shift starts (04:00). The "
|
|
1362
|
+
"engine cannot place a pre-planner — manual handover "
|
|
1363
|
+
"from the previous day's N shift is required."
|
|
1364
|
+
),
|
|
1365
|
+
))
|
|
1366
|
+
_warned_uids[_uid] = _short_msg
|
|
1367
|
+
if _warned_uids:
|
|
1368
|
+
rows = [
|
|
1369
|
+
(
|
|
1370
|
+
r.model_copy(update={
|
|
1371
|
+
"warning": (
|
|
1372
|
+
f"{r.warning}; {_warned_uids[_k]}"
|
|
1373
|
+
if r.warning else _warned_uids[_k]
|
|
1374
|
+
),
|
|
1375
|
+
})
|
|
1376
|
+
if (_k := (
|
|
1377
|
+
f"{r.flt}|{r.dep}|{r.arr}|"
|
|
1378
|
+
f"{r.std.isoformat(timespec='minutes')}|"
|
|
1379
|
+
f"{r.date.isoformat()}"
|
|
1380
|
+
)) in _warned_uids else r
|
|
1381
|
+
)
|
|
1382
|
+
for r in rows
|
|
1383
|
+
]
|
|
1384
|
+
print(
|
|
1385
|
+
f" W220: flagged {len(_warned_uids)} early-morning M-shift "
|
|
1386
|
+
"P2F flight(s) — manual handover required"
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
# Reflect post-pass row mutations back into result.assignments so
|
|
1390
|
+
# the workload summary + OUT_Unallocated below see the new state.
|
|
1391
|
+
# Build a fresh assignments dict from the (possibly mutated) rows.
|
|
1392
|
+
post_assignments: dict[str, str] = {}
|
|
1393
|
+
for r in rows:
|
|
1394
|
+
if r.staff_employee_id:
|
|
1395
|
+
uid = (
|
|
1396
|
+
f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
|
|
1397
|
+
f"|{r.date.isoformat()}"
|
|
1398
|
+
)
|
|
1399
|
+
post_assignments[uid] = r.staff_employee_id
|
|
1400
|
+
result_assignments = post_assignments
|
|
1401
|
+
|
|
1402
|
+
# 2026-05-16 (user direction): pre-plan-deferred flights (D+1 STD
|
|
1403
|
+
# 05:05-05:30) are now ASSIGNED to an N-shift handler for pre-
|
|
1404
|
+
# planning. The N staff doesn't fly the leg — they just prepare
|
|
1405
|
+
# it during their night. Write their name in planned_by; staff
|
|
1406
|
+
# column stays empty (no one's flying it today). Drop the
|
|
1407
|
+
# PREPLAN_DEFERRED warning text now that the row carries a
|
|
1408
|
+
# responsible person.
|
|
1409
|
+
#
|
|
1410
|
+
# Selection: round-robin across N-shift STAFF (excludes ZC, AM,
|
|
1411
|
+
# P2F handler) — fair load on the prep work and
|
|
1412
|
+
# avoids picking the same person every night.
|
|
1413
|
+
if preplan_only_flights:
|
|
1414
|
+
from .schemas import AllocationRow, AllocationSheet
|
|
1415
|
+
handler_set = set(elig_ctx.p2f_handler_by_shift.values())
|
|
1416
|
+
n_pool = [
|
|
1417
|
+
s for s in staff_today
|
|
1418
|
+
if s.shift_today == "N"
|
|
1419
|
+
and s.role == Role.STAFF
|
|
1420
|
+
and s.employee_id not in handler_set
|
|
1421
|
+
]
|
|
1422
|
+
n_pool.sort(key=lambda s: s.employee_id) # deterministic order
|
|
1423
|
+
for i, f in enumerate(preplan_only_flights):
|
|
1424
|
+
preplanner = n_pool[i % len(n_pool)] if n_pool else None
|
|
1425
|
+
rows.append(AllocationRow(
|
|
1426
|
+
date=f.date, flt=f.flt, dep=f.dep, arr=f.arr,
|
|
1427
|
+
std=f.std, pax=f.load,
|
|
1428
|
+
staff_employee_id="", staff_name="",
|
|
1429
|
+
planned_by_employee_id=(preplanner.employee_id
|
|
1430
|
+
if preplanner else None),
|
|
1431
|
+
planned_by_name=(preplanner.name if preplanner else None),
|
|
1432
|
+
warning="",
|
|
1433
|
+
sheet_target=AllocationSheet.NIGHT_OPS,
|
|
1434
|
+
is_international=f.is_international,
|
|
1435
|
+
))
|
|
1436
|
+
# Pass the combined post-pass reason notes (INTL §2 + P2F §3) so
|
|
1437
|
+
# they appear in the violations column.
|
|
1438
|
+
summary = build_workload_summary(
|
|
1439
|
+
staff_today, result_assignments,
|
|
1440
|
+
extra_reasons=dict(merged_workload_notes),
|
|
1441
|
+
# Neetu fix (2026-05-12): pass rows so counts come from the
|
|
1442
|
+
# exact list the writer will emit, not the reconstructed dict.
|
|
1443
|
+
rows=rows,
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
# --- Build OUT_Unallocated rows (red-tinted "needs attention" sheet)
|
|
1447
|
+
# Combines:
|
|
1448
|
+
# (a) zero-eligibility flights dropped pre-solve (W201 — reason
|
|
1449
|
+
# comes from diagnose_unassignable: e.g., 'INTL_SHIFT_EDGE')
|
|
1450
|
+
# (b) flights the solver chose to leave unallocated post-solve
|
|
1451
|
+
# (capacity / spacing / cap-hit — reason 'capacity or spacing')
|
|
1452
|
+
unallocated_rows: list[dict[str, object]] = []
|
|
1453
|
+
# All flights from the original list — re-fetch since we dropped some
|
|
1454
|
+
all_flights_orig = (
|
|
1455
|
+
list(flights) + zero_elig_flights
|
|
1456
|
+
)
|
|
1457
|
+
# 2026-05-18: track redistribution leftovers (sick OR role-change)
|
|
1458
|
+
# separately so we can emit a W219 WARN naming the affected staff
|
|
1459
|
+
# + reason. A flight is a "leftover" iff it was previously assigned
|
|
1460
|
+
# to someone whose prior assignment is no longer pinned (sick,
|
|
1461
|
+
# demoted to AM, or downgraded STAFF->ZC and capped) AND the
|
|
1462
|
+
# solver couldn't re-place it.
|
|
1463
|
+
sick_leftover_flights: list[tuple[str, str, str]] = [] # (flt, name, why)
|
|
1464
|
+
name_lookup_for_leftover = {s.employee_id: s.name for s in staff_today}
|
|
1465
|
+
why_by_eid: dict[str, str] = {}
|
|
1466
|
+
for raw in (sick_employee_ids or []):
|
|
1467
|
+
# resolve to canonical eid
|
|
1468
|
+
eid = raw
|
|
1469
|
+
if not any(s.employee_id == raw for s in staff_today):
|
|
1470
|
+
up_match = {s.name.strip().upper(): s.employee_id for s in staff_today}
|
|
1471
|
+
eid = up_match.get(raw.upper(), raw)
|
|
1472
|
+
why_by_eid[eid] = "sick"
|
|
1473
|
+
for raw_eid, new_role in (role_changes or {}).items():
|
|
1474
|
+
eid = raw_eid
|
|
1475
|
+
if not any(s.employee_id == raw_eid for s in staff_today):
|
|
1476
|
+
up_match = {s.name.strip().upper(): s.employee_id for s in staff_today}
|
|
1477
|
+
eid = up_match.get(raw_eid.upper(), raw_eid)
|
|
1478
|
+
# Use new_role to phrase the reason (STAFF->ZC is a workload
|
|
1479
|
+
# cap, AM is a full removal; both surface as "role-change").
|
|
1480
|
+
why_by_eid.setdefault(eid, f"role-change to {new_role}")
|
|
1481
|
+
# Build a flt->prior-staff map from the original allocations
|
|
1482
|
+
# sheets so we can attribute leftovers.
|
|
1483
|
+
prior_flt_to_staff: dict[str, str] = dict(prev_staff_by_key) if why_by_eid else {}
|
|
1484
|
+
|
|
1485
|
+
for f in all_flights_orig:
|
|
1486
|
+
if f.unique_id in zero_elig_reasons:
|
|
1487
|
+
reason = zero_elig_reasons[f.unique_id]
|
|
1488
|
+
elif f.unique_id not in result_assignments:
|
|
1489
|
+
reason = "no slot — capacity or spacing constraint"
|
|
1490
|
+
# Was this flight previously held by a freed-for-redist
|
|
1491
|
+
# staff (sick or role-changed)? Re-label + queue W219.
|
|
1492
|
+
prev_staff = prior_flt_to_staff.get(f.unique_id, "")
|
|
1493
|
+
if prev_staff:
|
|
1494
|
+
prev_name_up = prev_staff.upper()
|
|
1495
|
+
matched_eid = next(
|
|
1496
|
+
(eid for eid in why_by_eid
|
|
1497
|
+
if name_lookup_for_leftover.get(eid, "").upper() == prev_name_up),
|
|
1498
|
+
None,
|
|
1499
|
+
)
|
|
1500
|
+
if matched_eid:
|
|
1501
|
+
why = why_by_eid[matched_eid]
|
|
1502
|
+
reason = (
|
|
1503
|
+
f"{why} redistribution failed — no eligible "
|
|
1504
|
+
f"candidate within shift (was {prev_staff})"
|
|
1505
|
+
)
|
|
1506
|
+
sick_leftover_flights.append((f.flt, prev_staff, why))
|
|
1507
|
+
else:
|
|
1508
|
+
continue # allocated, skip
|
|
1509
|
+
unallocated_rows.append({
|
|
1510
|
+
"date": f.date.isoformat(),
|
|
1511
|
+
"flt": f.flt,
|
|
1512
|
+
"dep": f.dep,
|
|
1513
|
+
"arr": f.arr,
|
|
1514
|
+
"std": f.std.isoformat(timespec="minutes"),
|
|
1515
|
+
"pax": f.load,
|
|
1516
|
+
"ops_class": f.ops_class.value,
|
|
1517
|
+
"is_international": f.is_international,
|
|
1518
|
+
"reason": reason,
|
|
1519
|
+
})
|
|
1520
|
+
# W219: surface the leftover count (sick OR role-change) so the
|
|
1521
|
+
# operator sees it on the Warnings tab without scanning
|
|
1522
|
+
# OUT_Unallocated. Keys = (name, why) so a single person split
|
|
1523
|
+
# across reasons (unlikely but possible) gets two distinct rows.
|
|
1524
|
+
if sick_leftover_flights:
|
|
1525
|
+
# defaultdict already imported at module top — re-importing
|
|
1526
|
+
# inside the function would shadow it as a local for the whole
|
|
1527
|
+
# scope (Python scoping quirk) and break earlier usages.
|
|
1528
|
+
by_who = defaultdict(list)
|
|
1529
|
+
for flt, who, why in sick_leftover_flights:
|
|
1530
|
+
by_who[(who, why)].append(flt)
|
|
1531
|
+
for (who_name, why), flts in by_who.items():
|
|
1532
|
+
warnings.append(WarningRow(
|
|
1533
|
+
severity=Severity.WARN, code="W219", date=d_day,
|
|
1534
|
+
name=who_name,
|
|
1535
|
+
message=(
|
|
1536
|
+
f"{why}: {len(flts)} flight(s) from {who_name} "
|
|
1537
|
+
f"could not be redistributed within their shift — "
|
|
1538
|
+
f"left in OUT_Unallocated for manual handling. "
|
|
1539
|
+
f"Flights: {', '.join(str(x) for x in flts[:10])}"
|
|
1540
|
+
+ (f" +{len(flts) - 10} more" if len(flts) > 10 else "")
|
|
1541
|
+
),
|
|
1542
|
+
))
|
|
1543
|
+
|
|
1544
|
+
bundle = AllocationResult(
|
|
1545
|
+
rows=tuple(rows),
|
|
1546
|
+
pairs=tuple(pairs),
|
|
1547
|
+
summary=tuple(summary),
|
|
1548
|
+
warnings=tuple(warnings),
|
|
1549
|
+
wall_clock_seconds=result.wall_clock_seconds,
|
|
1550
|
+
solver_status=result.status,
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
# --- Phase R: write recommender sidecar JSON ----------
|
|
1554
|
+
# For each unallocated flight, compute up to 3 ranked override
|
|
1555
|
+
# suggestions and stash them in a JSON sidecar next to the console
|
|
1556
|
+
# workbook. The web UI reads this via GET /api/recommendations and
|
|
1557
|
+
# surfaces inline Approve buttons under each Unallocated row.
|
|
1558
|
+
#
|
|
1559
|
+
# Include the zero-eligibility flights (stripped from `flights` /
|
|
1560
|
+
# `matrix` before solve) so the recommender's Path-B / Path-C
|
|
1561
|
+
# branches can suggest skip_p2f_buffer / skip_intl_removal for
|
|
1562
|
+
# them. Each gets an empty matrix entry so Path A is a no-op.
|
|
1563
|
+
from .allocator.recommender import recommend_for_unallocated
|
|
1564
|
+
try:
|
|
1565
|
+
reco_flights = list(flights) + list(zero_elig_flights)
|
|
1566
|
+
reco_matrix = dict(matrix)
|
|
1567
|
+
for f in zero_elig_flights:
|
|
1568
|
+
reco_matrix.setdefault(f.unique_id, set())
|
|
1569
|
+
recs = recommend_for_unallocated(
|
|
1570
|
+
flights=reco_flights,
|
|
1571
|
+
staff_today=staff_today,
|
|
1572
|
+
matrix=reco_matrix,
|
|
1573
|
+
assignments=result_assignments,
|
|
1574
|
+
elig_ctx=elig_ctx,
|
|
1575
|
+
ops_day=d_day,
|
|
1576
|
+
)
|
|
1577
|
+
rec_payload = {
|
|
1578
|
+
"ops_day": d_day.isoformat(),
|
|
1579
|
+
"n_unallocated": len(recs),
|
|
1580
|
+
"recommendations": [
|
|
1581
|
+
{
|
|
1582
|
+
"flight_id": r.flight_id,
|
|
1583
|
+
"flt": r.flt,
|
|
1584
|
+
"dep": r.dep,
|
|
1585
|
+
"arr": r.arr,
|
|
1586
|
+
"std": r.std_iso,
|
|
1587
|
+
"is_international": r.is_international,
|
|
1588
|
+
"suggestions": [
|
|
1589
|
+
{
|
|
1590
|
+
"intrusiveness": s.intrusiveness,
|
|
1591
|
+
"type": s.override_type,
|
|
1592
|
+
"rationale": s.rationale,
|
|
1593
|
+
"payload": s.payload,
|
|
1594
|
+
"target_staff": s.target_staff_name,
|
|
1595
|
+
}
|
|
1596
|
+
for s in r.suggestions
|
|
1597
|
+
],
|
|
1598
|
+
}
|
|
1599
|
+
for r in recs
|
|
1600
|
+
],
|
|
1601
|
+
}
|
|
1602
|
+
with state.lock:
|
|
1603
|
+
state.recommendations = rec_payload
|
|
1604
|
+
if recs:
|
|
1605
|
+
print(f" Recommender: {len(recs)} unallocated flight(s)")
|
|
1606
|
+
except Exception as e: # noqa: BLE001
|
|
1607
|
+
# Recommender is advisory — never block the main pipeline.
|
|
1608
|
+
print(f" Recommender skipped: {type(e).__name__}: {e}")
|
|
1609
|
+
|
|
1610
|
+
# --- Write outputs ----------
|
|
1611
|
+
with state.lock:
|
|
1612
|
+
state.run_date = d_day
|
|
1613
|
+
state.allocations = list(bundle.rows)
|
|
1614
|
+
state.pairs = list(bundle.pairs)
|
|
1615
|
+
state.workload = list(bundle.summary)
|
|
1616
|
+
state.unallocated = unallocated_rows
|
|
1617
|
+
state.prev_staff_by_key = prev_staff_by_key
|
|
1618
|
+
if warnings:
|
|
1619
|
+
_store_warnings(state, warnings, append=append_warnings)
|
|
1620
|
+
state.stamp_run(
|
|
1621
|
+
mode=mode_label, solver_status=result.status,
|
|
1622
|
+
duration_s=_time.monotonic() - started,
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
counts["WARNINGS"] = len(warnings)
|
|
1626
|
+
return counts
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _store_warnings(
|
|
1630
|
+
state: AppState, warnings: list[WarningRow], *, append: bool,
|
|
1631
|
+
) -> None:
|
|
1632
|
+
"""Put this stage's warnings on the state, either appending to what
|
|
1633
|
+
Plan left behind or replacing it."""
|
|
1634
|
+
with state.lock:
|
|
1635
|
+
state.warnings = (
|
|
1636
|
+
list(state.warnings) + warnings if append else list(warnings)
|
|
1637
|
+
)
|
|
1638
|
+
|
|
1639
|
+
|