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,723 @@
|
|
|
1
|
+
"""P2F workload adjustment post-pass (rewrite per user direction 2026-05-26).
|
|
2
|
+
|
|
3
|
+
Runs AFTER the INTL post-pass. For each P2F flight, displaces the
|
|
4
|
+
closest normal-domestic flight in the (anchor, anchor + 30 min] window
|
|
5
|
+
from the **host** (whichever P2F handler's shift owns the anchor),
|
|
6
|
+
not from the **owner** (who flies the P2F leg).
|
|
7
|
+
|
|
8
|
+
Anchors per P2F flight:
|
|
9
|
+
* D-3hrs (P2F_STD - 180 min)
|
|
10
|
+
* D-1hr (P2F_STD - 60 min)
|
|
11
|
+
* D+20m (P2F_STD + 20 min)
|
|
12
|
+
|
|
13
|
+
Host selection (2026-05-26 redesign):
|
|
14
|
+
* Anchor → shift via SHIFT_STD_WINDOW_INNER (M / A / N only — those
|
|
15
|
+
are the shifts that nominate P2F handlers per the 2026-05-10
|
|
16
|
+
directive). Role-agnostic: 12:55 → M, 13:00 → A.
|
|
17
|
+
* Host = ``p2f_handlers[host_shift]``.
|
|
18
|
+
* If host is a ZC and the anchor falls inside their ZC start- or
|
|
19
|
+
end-of-shift report buffer (1.5 hr each side), SKIP that anchor —
|
|
20
|
+
the ZC isn't taking flights there anyway, so there's nothing to
|
|
21
|
+
displace and the briefing happens during their report time.
|
|
22
|
+
* If no host shift covers the anchor (e.g. early-morning M-shift
|
|
23
|
+
P2F at 06:00 → D-3hrs = 03:00, before any shift), skip with a
|
|
24
|
+
log line — the W220 warning fires from step3 in parallel.
|
|
25
|
+
|
|
26
|
+
Displacement (2026-05-26 revised — no ±30 cap):
|
|
27
|
+
* Closest displaceable flight strictly AFTER the anchor (any
|
|
28
|
+
distance). The handler needs the time FROM the anchor onward
|
|
29
|
+
to prep; flights before are already done.
|
|
30
|
+
* If the host has NO displaceable flight after the anchor (e.g.
|
|
31
|
+
P2F at 12:55 with D-1 at 11:55 and the host's day ends after a
|
|
32
|
+
last 11:50 leg), fall back to the closest displaceable flight
|
|
33
|
+
BEFORE the anchor. Removal is non-negotiable when any
|
|
34
|
+
displaceable flight exists.
|
|
35
|
+
|
|
36
|
+
Warning surface:
|
|
37
|
+
* Each P2F row's ``warning`` column lists the removed flights as
|
|
38
|
+
``FLT 7895@10:25, FLT 6118@12:10`` (just no + STD, per the
|
|
39
|
+
2026-05-26 ask). Pair-based PLANNED_BY / RELIEVED_BY are blanked
|
|
40
|
+
by relabel_pair_columns — operators read the warning column to
|
|
41
|
+
see what prep cost this P2F incurred.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import contextlib
|
|
47
|
+
from collections import defaultdict
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from datetime import date as date_t
|
|
50
|
+
|
|
51
|
+
from ..schemas import (
|
|
52
|
+
AllocationRow,
|
|
53
|
+
FlightInput,
|
|
54
|
+
OpsClass,
|
|
55
|
+
Role,
|
|
56
|
+
ShiftCode,
|
|
57
|
+
StaffMember,
|
|
58
|
+
)
|
|
59
|
+
from .eligibility import sheet_target_for
|
|
60
|
+
from .postpass_intl import _most_eligible
|
|
61
|
+
from .windows import (
|
|
62
|
+
SHIFT_STD_WINDOW_INNER,
|
|
63
|
+
ZC_BUFFER_END_MIN,
|
|
64
|
+
ZC_BUFFER_START_MIN,
|
|
65
|
+
SpacingKey,
|
|
66
|
+
spacing_key,
|
|
67
|
+
std_to_ops_day_minutes,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Anchor offsets relative to P2F_STD, paired with the number of
|
|
71
|
+
# flights to displace at each anchor. Each anchor displaces the K
|
|
72
|
+
# closest flights STRICTLY AFTER the anchor (no fallback to BEFORE,
|
|
73
|
+
# per the 2026-05-28 user direction).
|
|
74
|
+
#
|
|
75
|
+
# 2026-05-28 (user direction): counts changed from {1, 1, 1} to
|
|
76
|
+
# {2, 1, 1}, and the third anchor moved from D+20m to D (= P2F_STD).
|
|
77
|
+
# Removing 2 flights after D-3h gives the host the prep block they
|
|
78
|
+
# need; the +1 after D-1h covers final briefing; the +1 after D
|
|
79
|
+
# covers the immediate post-P2F slot (was D+20m before — now strictly
|
|
80
|
+
# the next flight after the P2F itself).
|
|
81
|
+
_ANCHORS: tuple[tuple[int, str, int], ...] = (
|
|
82
|
+
(-3 * 60, "D-3h", 2),
|
|
83
|
+
(-1 * 60, "D-1h", 1),
|
|
84
|
+
( 0, "D", 1),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# 2026-05-27 (centralization): pulled from schemas — single source
|
|
88
|
+
# of truth for which shifts nominate plain P2F handlers. M1 / A1 don't
|
|
89
|
+
# nominate per the 2026-05-10 directive — anchors in those windows
|
|
90
|
+
# fall through to whichever of M / A / N also covers them.
|
|
91
|
+
from ..schemas import P2F_HANDLER_ELIGIBLE_SHIFTS as _HOST_CANDIDATE_SHIFTS
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _anchor_host_shift(anchor_min: int) -> ShiftCode | None:
|
|
95
|
+
"""Map an anchor time (ops-day minutes) to a P2F-handler-bearing
|
|
96
|
+
shift via SHIFT_STD_WINDOW_INNER. Returns None when the anchor
|
|
97
|
+
falls in no candidate shift's window (W220 territory for M; for
|
|
98
|
+
other shifts simply means no displacement happens at that anchor).
|
|
99
|
+
|
|
100
|
+
Role-agnostic by design — 12:55 → M, 13:00 → A. Caller deals with
|
|
101
|
+
ZC report-buffer skip separately.
|
|
102
|
+
"""
|
|
103
|
+
for shift in _HOST_CANDIDATE_SHIFTS:
|
|
104
|
+
lo, hi = SHIFT_STD_WINDOW_INNER[shift]
|
|
105
|
+
if lo <= anchor_min <= hi:
|
|
106
|
+
return shift
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _anchor_in_zc_report_buffer(
|
|
111
|
+
host_shift: ShiftCode, anchor_min: int,
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""True when ``anchor_min`` lands in either of ``host_shift``'s
|
|
114
|
+
1.5 hr ZC report-buffer windows (start-of-shift or end-of-shift).
|
|
115
|
+
|
|
116
|
+
Used to skip displacement when a ZC hosts an anchor — they have
|
|
117
|
+
no flights to remove during their report time anyway, and the
|
|
118
|
+
briefing slots in there naturally.
|
|
119
|
+
"""
|
|
120
|
+
s1, e1 = ZC_BUFFER_START_MIN.get(host_shift, (0, 0))
|
|
121
|
+
s2, e2 = ZC_BUFFER_END_MIN.get(host_shift, (0, 0))
|
|
122
|
+
return (s1 <= anchor_min < e1) or (s2 <= anchor_min < e2)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class P2FPostPassSummary:
|
|
127
|
+
"""Diagnostic counters for the audit harness."""
|
|
128
|
+
|
|
129
|
+
n_p2f_processed: int = 0
|
|
130
|
+
n_removed: int = 0
|
|
131
|
+
n_redistributed: int = 0
|
|
132
|
+
n_redistribute_failed: int = 0
|
|
133
|
+
n_anchor_shortage: int = 0 # window had fewer candidates than required
|
|
134
|
+
workload_notes: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list))
|
|
135
|
+
log_lines: list[str] = field(default_factory=list)
|
|
136
|
+
|
|
137
|
+
def note(self, staff_id: str, reason: str) -> None:
|
|
138
|
+
self.workload_notes[staff_id].append(reason)
|
|
139
|
+
|
|
140
|
+
def log(self, msg: str) -> None:
|
|
141
|
+
self.log_lines.append(msg)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_displaceable(flight: FlightInput) -> bool:
|
|
145
|
+
"""A flight that the P2F post-pass is allowed to remove from the
|
|
146
|
+
handler's allocation. Excludes:
|
|
147
|
+
- P2F flights themselves (never displaced)
|
|
148
|
+
- INTL DEP flights (Phase 3 / Change 4: same handler start-to-
|
|
149
|
+
finish — INTL DEP allocations are sacred after the solver)
|
|
150
|
+
|
|
151
|
+
Per spec §3: the post-pass removes flights from the handler's
|
|
152
|
+
list AROUND the P2F flight — never the P2F flight itself.
|
|
153
|
+
"""
|
|
154
|
+
if flight.ops_class == OpsClass.P2F:
|
|
155
|
+
return False
|
|
156
|
+
# Phase 3 (2026-05-14, INTL overhaul / Change 4): an INTL DEP
|
|
157
|
+
# flight must stay with its assigned handler (self-pair). The P2F
|
|
158
|
+
# post-pass cannot move it without breaking that invariant.
|
|
159
|
+
return not flight.is_international
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def apply_p2f_post_pass_v2(
|
|
163
|
+
rows: list[AllocationRow],
|
|
164
|
+
flights: list[FlightInput],
|
|
165
|
+
staff_today: list[StaffMember],
|
|
166
|
+
matrix: dict[str, set[str]],
|
|
167
|
+
p2f_handlers: dict[ShiftCode, str],
|
|
168
|
+
d_day: date_t,
|
|
169
|
+
) -> tuple[list[AllocationRow], P2FPostPassSummary]:
|
|
170
|
+
"""Alternate P2F post-pass (user direction 2026-05-28).
|
|
171
|
+
|
|
172
|
+
Simple union-window rule: for each P2F flight at STD=T, the chain
|
|
173
|
+
``{owner shift's P2F handler, pre-planner shift's P2F handler}``
|
|
174
|
+
loses ALL flights with STD in ``[T-2h, T+1h]`` from their queues.
|
|
175
|
+
The P2F flight itself stays with the owner. Removed flights enter
|
|
176
|
+
§4 redistribution.
|
|
177
|
+
|
|
178
|
+
Differences from the anchored v1:
|
|
179
|
+
* No D-3h / D-1h / D+20m anchors. One contiguous window per P2F.
|
|
180
|
+
* No ZC report-buffer skip — if a chain member is a ZC, their
|
|
181
|
+
in-window flights still get removed (the buffer is a softer
|
|
182
|
+
concept that the simplified model intentionally ignores).
|
|
183
|
+
* Chain semantics handle the shift-boundary edge case the
|
|
184
|
+
anchored model misses (e.g. P2F at 13:30, owner = A shift
|
|
185
|
+
handler, has a 13:00 flight — that flight is in the chain and
|
|
186
|
+
in [11:30, 14:30] so it gets removed even though 13:00 is in
|
|
187
|
+
the pre-window).
|
|
188
|
+
* planned_by on the P2F row = pre-planner (when different from
|
|
189
|
+
owner). relieved_by stays blank — owner handles their own
|
|
190
|
+
post-window since they're freed by the same window logic.
|
|
191
|
+
|
|
192
|
+
Returns ``(new_rows, summary)`` — same shape as v1. Row count
|
|
193
|
+
preserved (flights are reassigned, not dropped).
|
|
194
|
+
"""
|
|
195
|
+
summary = P2FPostPassSummary()
|
|
196
|
+
flight_by_uid = {f.unique_id: f for f in flights}
|
|
197
|
+
|
|
198
|
+
rstate: dict[str, dict] = {}
|
|
199
|
+
rows_input_count = len(rows)
|
|
200
|
+
for r in rows:
|
|
201
|
+
uid = (
|
|
202
|
+
f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
|
|
203
|
+
f"|{r.date.isoformat()}"
|
|
204
|
+
)
|
|
205
|
+
rstate[uid] = {
|
|
206
|
+
"row": r,
|
|
207
|
+
"staff_id": r.staff_employee_id,
|
|
208
|
+
"staff_name": r.staff_name,
|
|
209
|
+
"planned_by_emp": r.planned_by_employee_id,
|
|
210
|
+
"planned_by_name": r.planned_by_name,
|
|
211
|
+
"relieved_by_emp": r.relieved_by_employee_id,
|
|
212
|
+
"relieved_by_name": r.relieved_by_name,
|
|
213
|
+
"warning": r.warning,
|
|
214
|
+
"sheet_target": r.sheet_target,
|
|
215
|
+
"uid": uid,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
counts: dict[str, int] = defaultdict(int)
|
|
219
|
+
staff_stds: dict[str, list[SpacingKey]] = defaultdict(list)
|
|
220
|
+
for uid, st in rstate.items():
|
|
221
|
+
if not st["staff_id"]:
|
|
222
|
+
continue
|
|
223
|
+
f_ref = flight_by_uid.get(uid)
|
|
224
|
+
if f_ref is None:
|
|
225
|
+
continue
|
|
226
|
+
counts[st["staff_id"]] += 1
|
|
227
|
+
staff_stds[st["staff_id"]].append(spacing_key(f_ref, d_day))
|
|
228
|
+
excluded: set[str] = set()
|
|
229
|
+
|
|
230
|
+
p2f_uids = sorted(
|
|
231
|
+
(uid for uid, st in rstate.items()
|
|
232
|
+
if flight_by_uid.get(uid)
|
|
233
|
+
and flight_by_uid[uid].ops_class == OpsClass.P2F
|
|
234
|
+
and st["staff_id"]),
|
|
235
|
+
key=lambda u: std_to_ops_day_minutes(
|
|
236
|
+
flight_by_uid[u].std, flight_by_uid[u].date, d_day,
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
staff_by_id: dict[str, StaffMember] = {
|
|
240
|
+
s.employee_id: s for s in staff_today if s.employee_id
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Resolve pre-planner shift from owner shift. Pre-shift mapping
|
|
244
|
+
# mirrors the ops-day flow: N is preceded by A, A by M, M by N of
|
|
245
|
+
# D-1 (which means no D-day chain partner — fall back to owner-only).
|
|
246
|
+
_PRE_SHIFT: dict[ShiftCode, ShiftCode | None] = {
|
|
247
|
+
"M": None, # M's pre-window is D-1's N — no D-day chain partner
|
|
248
|
+
"A": "M",
|
|
249
|
+
"N": "A",
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for p2f_uid in p2f_uids:
|
|
253
|
+
p2f_f = flight_by_uid[p2f_uid]
|
|
254
|
+
owner_id = rstate[p2f_uid]["staff_id"]
|
|
255
|
+
if not owner_id:
|
|
256
|
+
continue
|
|
257
|
+
summary.n_p2f_processed += 1
|
|
258
|
+
p2f_min = std_to_ops_day_minutes(p2f_f.std, p2f_f.date, d_day)
|
|
259
|
+
window_lo = p2f_min - 120
|
|
260
|
+
window_hi = p2f_min + 60
|
|
261
|
+
|
|
262
|
+
owner_shift = _anchor_host_shift(p2f_min)
|
|
263
|
+
if owner_shift is None:
|
|
264
|
+
summary.n_anchor_shortage += 1
|
|
265
|
+
summary.log(
|
|
266
|
+
f"P2F flt {p2f_f.flt} v2: STD "
|
|
267
|
+
f"{p2f_min // 60:02d}:{p2f_min % 60:02d} falls in no "
|
|
268
|
+
"P2F-handler-bearing shift — chain empty, no removal"
|
|
269
|
+
)
|
|
270
|
+
continue
|
|
271
|
+
pre_shift = _PRE_SHIFT.get(owner_shift)
|
|
272
|
+
pre_planner_id = (
|
|
273
|
+
p2f_handlers.get(pre_shift) if pre_shift else None
|
|
274
|
+
)
|
|
275
|
+
chain_ids = {owner_id}
|
|
276
|
+
if pre_planner_id and pre_planner_id != owner_id:
|
|
277
|
+
chain_ids.add(pre_planner_id)
|
|
278
|
+
|
|
279
|
+
# Sort displaceable candidates chronologically so the
|
|
280
|
+
# redistribution pool sees them in a stable order — matters
|
|
281
|
+
# because _displace_one mutates counts / staff_stds as it goes.
|
|
282
|
+
candidates: list[tuple[int, str]] = [] # (cand_min, uid)
|
|
283
|
+
for u, sx in rstate.items():
|
|
284
|
+
if u == p2f_uid:
|
|
285
|
+
continue
|
|
286
|
+
if sx["staff_id"] not in chain_ids:
|
|
287
|
+
continue
|
|
288
|
+
f_cand = flight_by_uid.get(u)
|
|
289
|
+
if f_cand is None or not _is_displaceable(f_cand):
|
|
290
|
+
continue
|
|
291
|
+
cand_min = std_to_ops_day_minutes(
|
|
292
|
+
f_cand.std, f_cand.date, d_day,
|
|
293
|
+
)
|
|
294
|
+
if window_lo <= cand_min <= window_hi:
|
|
295
|
+
candidates.append((cand_min, u))
|
|
296
|
+
candidates.sort()
|
|
297
|
+
|
|
298
|
+
removed_for_this_p2f: list[tuple[str, str]] = []
|
|
299
|
+
for _cand_min, cand_uid in candidates:
|
|
300
|
+
holder_id = rstate[cand_uid]["staff_id"]
|
|
301
|
+
if holder_id not in chain_ids:
|
|
302
|
+
# Could have been moved by a prior P2F's pass in this loop
|
|
303
|
+
continue
|
|
304
|
+
displaced_f = flight_by_uid[cand_uid]
|
|
305
|
+
displaced_std_iso = displaced_f.std.isoformat(timespec="minutes")
|
|
306
|
+
_displace_one(
|
|
307
|
+
cand_uid, p2f_f, holder_id, "v2-window", rstate,
|
|
308
|
+
flight_by_uid, staff_today, matrix, counts,
|
|
309
|
+
staff_stds, excluded, p2f_handlers,
|
|
310
|
+
d_day, summary,
|
|
311
|
+
)
|
|
312
|
+
if rstate[cand_uid]["staff_id"] != holder_id:
|
|
313
|
+
removed_for_this_p2f.append(
|
|
314
|
+
(str(displaced_f.flt), displaced_std_iso),
|
|
315
|
+
)
|
|
316
|
+
if holder_id == owner_id:
|
|
317
|
+
summary.note(
|
|
318
|
+
holder_id,
|
|
319
|
+
f"Below target: 1 flight removed for "
|
|
320
|
+
f"P2F FLT {p2f_f.flt} (v2 window)",
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
summary.note(
|
|
324
|
+
holder_id,
|
|
325
|
+
f"P2F prep host (v2): 1 flight removed for "
|
|
326
|
+
f"FLT {p2f_f.flt} (owner was "
|
|
327
|
+
f"{rstate[p2f_uid]['staff_name']})",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Warning column on P2F row: list removed flights.
|
|
331
|
+
if removed_for_this_p2f:
|
|
332
|
+
tag = ", ".join(
|
|
333
|
+
f"FLT {flt}@{std}" for flt, std in removed_for_this_p2f
|
|
334
|
+
)
|
|
335
|
+
existing = rstate[p2f_uid].get("warning") or ""
|
|
336
|
+
new_warn = (
|
|
337
|
+
f"{existing}; Removed: {tag}"
|
|
338
|
+
if existing else f"Removed: {tag}"
|
|
339
|
+
)
|
|
340
|
+
rstate[p2f_uid]["warning"] = new_warn
|
|
341
|
+
|
|
342
|
+
# planned_by = pre-planner when different from owner.
|
|
343
|
+
# relieved_by stays blank: owner is freed by the same window
|
|
344
|
+
# so no separate post-pair host exists in v2.
|
|
345
|
+
if pre_planner_id and pre_planner_id != owner_id:
|
|
346
|
+
pre_staff = staff_by_id.get(pre_planner_id)
|
|
347
|
+
if pre_staff is not None:
|
|
348
|
+
rstate[p2f_uid]["planned_by_emp"] = pre_planner_id
|
|
349
|
+
rstate[p2f_uid]["planned_by_name"] = pre_staff.name
|
|
350
|
+
|
|
351
|
+
new_rows: list[AllocationRow] = []
|
|
352
|
+
for uid, st in rstate.items():
|
|
353
|
+
f = flight_by_uid.get(uid)
|
|
354
|
+
if f is None:
|
|
355
|
+
new_rows.append(st["row"])
|
|
356
|
+
continue
|
|
357
|
+
new_rows.append(AllocationRow(
|
|
358
|
+
date=f.date, flt=f.flt, dep=f.dep, arr=f.arr, std=f.std,
|
|
359
|
+
pax=f.load,
|
|
360
|
+
staff_employee_id=st["staff_id"] or "",
|
|
361
|
+
staff_name=st["staff_name"] or "",
|
|
362
|
+
planned_by_employee_id=st["planned_by_emp"],
|
|
363
|
+
planned_by_name=st["planned_by_name"],
|
|
364
|
+
relieved_by_employee_id=st["relieved_by_emp"],
|
|
365
|
+
relieved_by_name=st["relieved_by_name"],
|
|
366
|
+
warning=st["warning"],
|
|
367
|
+
sheet_target=st["sheet_target"],
|
|
368
|
+
is_international=f.is_international,
|
|
369
|
+
))
|
|
370
|
+
if len(new_rows) != rows_input_count:
|
|
371
|
+
summary.log(
|
|
372
|
+
f"!! P2F v2 post-pass row count mismatch: "
|
|
373
|
+
f"input={rows_input_count} output={len(new_rows)}"
|
|
374
|
+
)
|
|
375
|
+
return new_rows, summary
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def apply_p2f_post_pass(
|
|
379
|
+
rows: list[AllocationRow],
|
|
380
|
+
flights: list[FlightInput],
|
|
381
|
+
staff_today: list[StaffMember],
|
|
382
|
+
matrix: dict[str, set[str]],
|
|
383
|
+
p2f_handlers: dict[ShiftCode, str],
|
|
384
|
+
d_day: date_t,
|
|
385
|
+
tolerance_minutes: int = 15,
|
|
386
|
+
) -> tuple[list[AllocationRow], P2FPostPassSummary]:
|
|
387
|
+
"""Apply §3 P2F workload adjustment to ``rows``.
|
|
388
|
+
|
|
389
|
+
Returns ``(new_rows, summary)``. Row count is preserved — flights
|
|
390
|
+
are reassigned (or kept on the handler if redistribution fails),
|
|
391
|
+
never dropped.
|
|
392
|
+
"""
|
|
393
|
+
summary = P2FPostPassSummary()
|
|
394
|
+
flight_by_uid = {f.unique_id: f for f in flights}
|
|
395
|
+
|
|
396
|
+
# Mutable working state — same shape as INTL post-pass.
|
|
397
|
+
rstate: dict[str, dict] = {}
|
|
398
|
+
rows_input_count = len(rows)
|
|
399
|
+
for r in rows:
|
|
400
|
+
uid = (
|
|
401
|
+
f"{r.flt}|{r.dep}|{r.arr}|{r.std.isoformat(timespec='minutes')}"
|
|
402
|
+
f"|{r.date.isoformat()}"
|
|
403
|
+
)
|
|
404
|
+
rstate[uid] = {
|
|
405
|
+
"row": r,
|
|
406
|
+
"staff_id": r.staff_employee_id,
|
|
407
|
+
"staff_name": r.staff_name,
|
|
408
|
+
"planned_by_emp": r.planned_by_employee_id,
|
|
409
|
+
"planned_by_name": r.planned_by_name,
|
|
410
|
+
"relieved_by_emp": r.relieved_by_employee_id,
|
|
411
|
+
"relieved_by_name": r.relieved_by_name,
|
|
412
|
+
"warning": r.warning,
|
|
413
|
+
"sheet_target": r.sheet_target,
|
|
414
|
+
"uid": uid,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# Per-staff state for §4: counts + STD lists (for H10 spacing).
|
|
418
|
+
counts: dict[str, int] = defaultdict(int)
|
|
419
|
+
staff_stds: dict[str, list[SpacingKey]] = defaultdict(list)
|
|
420
|
+
for uid, st in rstate.items():
|
|
421
|
+
if not st["staff_id"]:
|
|
422
|
+
continue
|
|
423
|
+
f_ref = flight_by_uid.get(uid)
|
|
424
|
+
if f_ref is None:
|
|
425
|
+
continue
|
|
426
|
+
counts[st["staff_id"]] += 1
|
|
427
|
+
staff_stds[st["staff_id"]].append(spacing_key(f_ref, d_day))
|
|
428
|
+
excluded: set[str] = set()
|
|
429
|
+
|
|
430
|
+
# Iterate P2F flights chronologically.
|
|
431
|
+
p2f_uids = sorted(
|
|
432
|
+
(uid for uid, st in rstate.items()
|
|
433
|
+
if flight_by_uid.get(uid)
|
|
434
|
+
and flight_by_uid[uid].ops_class == OpsClass.P2F
|
|
435
|
+
and st["staff_id"]),
|
|
436
|
+
key=lambda u: std_to_ops_day_minutes(
|
|
437
|
+
flight_by_uid[u].std, flight_by_uid[u].date, d_day,
|
|
438
|
+
),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# employee_id -> StaffMember for ZC-detection lookups
|
|
442
|
+
staff_by_id: dict[str, StaffMember] = {
|
|
443
|
+
s.employee_id: s for s in staff_today if s.employee_id
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for p2f_uid in p2f_uids:
|
|
447
|
+
p2f_f = flight_by_uid[p2f_uid]
|
|
448
|
+
owner_id = rstate[p2f_uid]["staff_id"]
|
|
449
|
+
if not owner_id:
|
|
450
|
+
continue
|
|
451
|
+
summary.n_p2f_processed += 1
|
|
452
|
+
p2f_min = std_to_ops_day_minutes(p2f_f.std, p2f_f.date, d_day)
|
|
453
|
+
# Track removed flights for this P2F's warning column.
|
|
454
|
+
removed_for_this_p2f: list[tuple[str, str]] = [] # (flt, std_iso)
|
|
455
|
+
# 2026-05-27 (user direction): record which host did the prep
|
|
456
|
+
# before vs after the P2F STD so we can populate planned_by /
|
|
457
|
+
# relieved_by on the P2F row. D-3 / D-1 → planned_by (host
|
|
458
|
+
# does prep BEFORE the flight). D+20 → relieved_by (host does
|
|
459
|
+
# paperwork AFTER). When the host is the owner themselves
|
|
460
|
+
# (same person flying the leg), skip the annotation — a self-
|
|
461
|
+
# pair label adds no information.
|
|
462
|
+
pre_host_id: str | None = None # D-3 preferred, D-1 fallback
|
|
463
|
+
pre_host_name: str | None = None
|
|
464
|
+
post_host_id: str | None = None # D+20
|
|
465
|
+
post_host_name: str | None = None
|
|
466
|
+
|
|
467
|
+
for offset, label, anchor_count in _ANCHORS:
|
|
468
|
+
anchor_min = p2f_min + offset
|
|
469
|
+
# 2026-05-26 redesign: host = whichever shift's STD window
|
|
470
|
+
# contains the anchor. Role-agnostic — staff or ZC, doesn't
|
|
471
|
+
# matter for the mapping step.
|
|
472
|
+
host_shift = _anchor_host_shift(anchor_min)
|
|
473
|
+
if host_shift is None:
|
|
474
|
+
summary.n_anchor_shortage += 1
|
|
475
|
+
summary.log(
|
|
476
|
+
f"P2F flt {p2f_f.flt} ({label}): anchor "
|
|
477
|
+
f"{anchor_min // 60:02d}:{anchor_min % 60:02d} falls "
|
|
478
|
+
"in no P2F-handler-bearing shift window — no "
|
|
479
|
+
"displacement (W220 may also fire from step3)"
|
|
480
|
+
)
|
|
481
|
+
continue
|
|
482
|
+
host_id = p2f_handlers.get(host_shift)
|
|
483
|
+
if not host_id:
|
|
484
|
+
summary.n_anchor_shortage += 1
|
|
485
|
+
summary.log(
|
|
486
|
+
f"P2F flt {p2f_f.flt} ({label}): no P2F handler "
|
|
487
|
+
f"nominated for host shift {host_shift} — "
|
|
488
|
+
"anchor unhosted"
|
|
489
|
+
)
|
|
490
|
+
continue
|
|
491
|
+
host_staff = staff_by_id.get(host_id)
|
|
492
|
+
# 2026-05-27: record host for the P2F row's planned_by /
|
|
493
|
+
# relieved_by columns. D-3 / D-1 → planned_by (work BEFORE
|
|
494
|
+
# the flight), D → relieved_by (the host taking the
|
|
495
|
+
# immediate post-P2F slot). Skip when host == owner (would
|
|
496
|
+
# be a self-pair label). D-3 takes precedence over D-1; if
|
|
497
|
+
# D-3 host is the owner, D-1's host fills the planned_by
|
|
498
|
+
# slot. 2026-05-28: third anchor renamed D+20m → D.
|
|
499
|
+
if host_id != owner_id:
|
|
500
|
+
host_label = (
|
|
501
|
+
host_staff.name if host_staff is not None else host_id
|
|
502
|
+
)
|
|
503
|
+
if label == "D-3h":
|
|
504
|
+
pre_host_id = host_id
|
|
505
|
+
pre_host_name = host_label
|
|
506
|
+
elif label == "D-1h" and pre_host_id is None:
|
|
507
|
+
pre_host_id = host_id
|
|
508
|
+
pre_host_name = host_label
|
|
509
|
+
elif label == "D":
|
|
510
|
+
post_host_id = host_id
|
|
511
|
+
post_host_name = host_label
|
|
512
|
+
# ZC report-buffer skip: if the host is a ZC and the anchor
|
|
513
|
+
# falls inside their start- or end-of-shift 1.5 hr report
|
|
514
|
+
# buffer, ZC isn't taking flights there anyway — skip
|
|
515
|
+
# displacement (briefing slots into report time). Host
|
|
516
|
+
# annotation is ALREADY recorded above, so the column will
|
|
517
|
+
# still show who did the briefing.
|
|
518
|
+
if (host_staff is not None
|
|
519
|
+
and host_staff.role == Role.ZC
|
|
520
|
+
and _anchor_in_zc_report_buffer(host_shift, anchor_min)):
|
|
521
|
+
summary.log(
|
|
522
|
+
f"P2F flt {p2f_f.flt} ({label}): host "
|
|
523
|
+
f"{host_staff.name} ({host_shift}/ZC) — anchor "
|
|
524
|
+
f"{anchor_min // 60:02d}:{anchor_min % 60:02d} in "
|
|
525
|
+
"ZC report-buffer; briefing absorbed, no flight removed"
|
|
526
|
+
)
|
|
527
|
+
continue
|
|
528
|
+
|
|
529
|
+
# 2026-05-28 (user direction): pick the K closest displaceable
|
|
530
|
+
# flights STRICTLY AFTER the anchor (delta >= 0 — flights at
|
|
531
|
+
# exact anchor time qualify as "after" since they ARE the
|
|
532
|
+
# conflict moment, except the P2F itself which is filtered
|
|
533
|
+
# by ``u != p2f_uid``). NO fallback to BEFORE — if the host
|
|
534
|
+
# has fewer than K candidates after, displace whatever
|
|
535
|
+
# exists and log the shortfall.
|
|
536
|
+
after_candidates: list[tuple[int, str]] = []
|
|
537
|
+
for u, sx in rstate.items():
|
|
538
|
+
if sx["staff_id"] != host_id or u == p2f_uid:
|
|
539
|
+
continue
|
|
540
|
+
f_cand = flight_by_uid.get(u)
|
|
541
|
+
if f_cand is None or not _is_displaceable(f_cand):
|
|
542
|
+
continue
|
|
543
|
+
cand_min = std_to_ops_day_minutes(
|
|
544
|
+
f_cand.std, f_cand.date, d_day,
|
|
545
|
+
)
|
|
546
|
+
delta = cand_min - anchor_min
|
|
547
|
+
if delta < 0:
|
|
548
|
+
continue
|
|
549
|
+
after_candidates.append((delta, u))
|
|
550
|
+
after_candidates.sort() # ascending — closest first
|
|
551
|
+
|
|
552
|
+
if not after_candidates:
|
|
553
|
+
summary.n_anchor_shortage += 1
|
|
554
|
+
summary.log(
|
|
555
|
+
f"P2F flt {p2f_f.flt} ({label}, host "
|
|
556
|
+
f"{host_staff.name if host_staff else host_id}): "
|
|
557
|
+
"host has no displaceable flight AFTER the anchor — "
|
|
558
|
+
"nothing to remove"
|
|
559
|
+
)
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
n_to_remove = min(anchor_count, len(after_candidates))
|
|
563
|
+
if n_to_remove < anchor_count:
|
|
564
|
+
summary.n_anchor_shortage += 1
|
|
565
|
+
summary.log(
|
|
566
|
+
f"P2F flt {p2f_f.flt} ({label}): asked for "
|
|
567
|
+
f"{anchor_count} removals, only {len(after_candidates)} "
|
|
568
|
+
f"candidate(s) after anchor — taking {n_to_remove}"
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
for _delta, best_uid in after_candidates[:n_to_remove]:
|
|
572
|
+
# State could have changed since we built the list
|
|
573
|
+
# (rare: a prior anchor in the same loop already moved
|
|
574
|
+
# this flight). Re-check the staff_id binding.
|
|
575
|
+
if rstate[best_uid]["staff_id"] != host_id:
|
|
576
|
+
continue
|
|
577
|
+
displaced_f = flight_by_uid[best_uid]
|
|
578
|
+
displaced_std_iso = displaced_f.std.isoformat(timespec="minutes")
|
|
579
|
+
_displace_one(
|
|
580
|
+
best_uid, p2f_f, host_id, label, rstate,
|
|
581
|
+
flight_by_uid, staff_today, matrix, counts,
|
|
582
|
+
staff_stds, excluded, p2f_handlers,
|
|
583
|
+
d_day, summary,
|
|
584
|
+
)
|
|
585
|
+
if rstate[best_uid]["staff_id"] != host_id:
|
|
586
|
+
removed_for_this_p2f.append(
|
|
587
|
+
(str(displaced_f.flt), displaced_std_iso),
|
|
588
|
+
)
|
|
589
|
+
if host_id != owner_id:
|
|
590
|
+
summary.note(
|
|
591
|
+
host_id,
|
|
592
|
+
f"P2F prep host: 1 flight removed for "
|
|
593
|
+
f"FLT {p2f_f.flt} {label} (owner was "
|
|
594
|
+
f"{rstate[p2f_uid]['staff_name']})",
|
|
595
|
+
)
|
|
596
|
+
else:
|
|
597
|
+
summary.note(
|
|
598
|
+
host_id,
|
|
599
|
+
f"Below target: 1 flight removed for "
|
|
600
|
+
f"P2F FLT {p2f_f.flt} {label}",
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
# 2026-05-26 user direction: populate the P2F row's warning
|
|
604
|
+
# column with the list of removed flights — just "FLT <no>@HH:MM".
|
|
605
|
+
# This is only for P2F rows (other rows keep their existing
|
|
606
|
+
# warning content). The warning column is where prep cost
|
|
607
|
+
# surfaces.
|
|
608
|
+
if removed_for_this_p2f:
|
|
609
|
+
tag = ", ".join(
|
|
610
|
+
f"FLT {flt}@{std}" for flt, std in removed_for_this_p2f
|
|
611
|
+
)
|
|
612
|
+
existing = rstate[p2f_uid].get("warning") or ""
|
|
613
|
+
new_warn = (
|
|
614
|
+
f"{existing}; Removed: {tag}"
|
|
615
|
+
if existing else f"Removed: {tag}"
|
|
616
|
+
)
|
|
617
|
+
rstate[p2f_uid]["warning"] = new_warn
|
|
618
|
+
|
|
619
|
+
# 2026-05-27 user direction: P2F rows DO carry pair-style
|
|
620
|
+
# annotations now — but driven by host (not by the pair table).
|
|
621
|
+
# planned_by = D-3 / D-1 host (prep BEFORE flight).
|
|
622
|
+
# relieved_by = D+20 host (paperwork AFTER flight).
|
|
623
|
+
# relabel_pair_columns no longer blanks P2F so these survive
|
|
624
|
+
# to the output sheet.
|
|
625
|
+
if pre_host_id is not None:
|
|
626
|
+
rstate[p2f_uid]["planned_by_emp"] = pre_host_id
|
|
627
|
+
rstate[p2f_uid]["planned_by_name"] = pre_host_name
|
|
628
|
+
if post_host_id is not None:
|
|
629
|
+
rstate[p2f_uid]["relieved_by_emp"] = post_host_id
|
|
630
|
+
rstate[p2f_uid]["relieved_by_name"] = post_host_name
|
|
631
|
+
|
|
632
|
+
# Rebuild rows from rstate — same loop as INTL post-pass.
|
|
633
|
+
new_rows: list[AllocationRow] = []
|
|
634
|
+
for uid, st in rstate.items():
|
|
635
|
+
f = flight_by_uid.get(uid)
|
|
636
|
+
if f is None:
|
|
637
|
+
new_rows.append(st["row"])
|
|
638
|
+
continue
|
|
639
|
+
new_rows.append(AllocationRow(
|
|
640
|
+
date=f.date, flt=f.flt, dep=f.dep, arr=f.arr, std=f.std,
|
|
641
|
+
pax=f.load,
|
|
642
|
+
staff_employee_id=st["staff_id"] or "",
|
|
643
|
+
staff_name=st["staff_name"] or "",
|
|
644
|
+
planned_by_employee_id=st["planned_by_emp"],
|
|
645
|
+
planned_by_name=st["planned_by_name"],
|
|
646
|
+
relieved_by_employee_id=st["relieved_by_emp"],
|
|
647
|
+
relieved_by_name=st["relieved_by_name"],
|
|
648
|
+
warning=st["warning"],
|
|
649
|
+
sheet_target=st["sheet_target"],
|
|
650
|
+
is_international=f.is_international,
|
|
651
|
+
))
|
|
652
|
+
if len(new_rows) != rows_input_count:
|
|
653
|
+
summary.log(
|
|
654
|
+
f"!! P2F post-pass row count mismatch: input={rows_input_count} "
|
|
655
|
+
f"output={len(new_rows)}"
|
|
656
|
+
)
|
|
657
|
+
return new_rows, summary
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _displace_one(
|
|
661
|
+
displaced_uid: str,
|
|
662
|
+
p2f_f: FlightInput,
|
|
663
|
+
handler_id: str,
|
|
664
|
+
anchor_label: str,
|
|
665
|
+
rstate: dict[str, dict],
|
|
666
|
+
flight_by_uid: dict[str, FlightInput],
|
|
667
|
+
staff_today: list[StaffMember],
|
|
668
|
+
matrix: dict[str, set[str]],
|
|
669
|
+
counts: dict[str, int],
|
|
670
|
+
staff_stds: dict[str, list[SpacingKey]],
|
|
671
|
+
excluded: set[str],
|
|
672
|
+
p2f_handlers: dict[ShiftCode, str],
|
|
673
|
+
d_day: date_t,
|
|
674
|
+
summary: P2FPostPassSummary,
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Move one displaceable flight from the handler to a §4
|
|
677
|
+
most-eligible recipient. If no recipient qualifies, leave the
|
|
678
|
+
flight on the handler (no-op) and log the failure."""
|
|
679
|
+
dst = rstate[displaced_uid]
|
|
680
|
+
displaced_f = flight_by_uid[displaced_uid]
|
|
681
|
+
displaced_entry = spacing_key(displaced_f, d_day)
|
|
682
|
+
|
|
683
|
+
# Temporarily decrement the handler's count + remove the STD so
|
|
684
|
+
# §4 sees an accurate post-removal state.
|
|
685
|
+
counts[handler_id] = max(0, counts.get(handler_id, 0) - 1)
|
|
686
|
+
if handler_id in staff_stds:
|
|
687
|
+
with contextlib.suppress(ValueError):
|
|
688
|
+
staff_stds[handler_id].remove(displaced_entry)
|
|
689
|
+
|
|
690
|
+
# Exclude the handler so the same person doesn't take their own
|
|
691
|
+
# flight back.
|
|
692
|
+
excluded_for_pick = excluded | {handler_id}
|
|
693
|
+
new_holder = _most_eligible(
|
|
694
|
+
displaced_f, staff_today, matrix, counts, excluded_for_pick,
|
|
695
|
+
preferred_shift=None, # any qualifying shift
|
|
696
|
+
p2f_handlers=p2f_handlers,
|
|
697
|
+
staff_stds=staff_stds, flight_key=displaced_entry,
|
|
698
|
+
)
|
|
699
|
+
if new_holder is None:
|
|
700
|
+
# Restore — keep the flight on the handler.
|
|
701
|
+
counts[handler_id] += 1
|
|
702
|
+
staff_stds.setdefault(handler_id, []).append(displaced_entry)
|
|
703
|
+
summary.n_redistribute_failed += 1
|
|
704
|
+
summary.log(
|
|
705
|
+
f"P2F flt {p2f_f.flt} ({anchor_label}): could not redistribute "
|
|
706
|
+
f"flt {displaced_f.flt} from {rstate[displaced_uid]['staff_name']}"
|
|
707
|
+
)
|
|
708
|
+
return
|
|
709
|
+
|
|
710
|
+
# Commit the reassignment.
|
|
711
|
+
counts[new_holder.employee_id] = counts.get(new_holder.employee_id, 0) + 1
|
|
712
|
+
staff_stds.setdefault(new_holder.employee_id, []).append(displaced_entry)
|
|
713
|
+
dst["staff_id"] = new_holder.employee_id
|
|
714
|
+
dst["staff_name"] = new_holder.name
|
|
715
|
+
dst["sheet_target"] = sheet_target_for(displaced_f, new_holder)
|
|
716
|
+
excluded.add(new_holder.employee_id)
|
|
717
|
+
summary.n_removed += 1
|
|
718
|
+
summary.n_redistributed += 1
|
|
719
|
+
summary.log(
|
|
720
|
+
f"P2F flt {p2f_f.flt} ({anchor_label}): removed flt "
|
|
721
|
+
f"{displaced_f.flt} from handler -> "
|
|
722
|
+
f"{new_holder.name} ({new_holder.shift_today})"
|
|
723
|
+
)
|