flight-alloc 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. flight_alloc-0.0.1.dist-info/METADATA +11 -0
  2. flight_alloc-0.0.1.dist-info/RECORD +71 -0
  3. flight_alloc-0.0.1.dist-info/WHEEL +5 -0
  4. flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
  5. flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/allocator/__init__.py +6 -0
  8. src/allocator/caps.py +513 -0
  9. src/allocator/eligibility.py +302 -0
  10. src/allocator/greedy_fallback.py +106 -0
  11. src/allocator/invariants.py +124 -0
  12. src/allocator/p2f_priority.py +142 -0
  13. src/allocator/pair_validation.py +108 -0
  14. src/allocator/pairings.py +554 -0
  15. src/allocator/postpass_break.py +366 -0
  16. src/allocator/postpass_intl.py +483 -0
  17. src/allocator/postpass_p2f.py +723 -0
  18. src/allocator/postpass_rebalance.py +244 -0
  19. src/allocator/postsolve.py +549 -0
  20. src/allocator/recommender.py +348 -0
  21. src/allocator/windows.py +377 -0
  22. src/cli.py +41 -0
  23. src/config.py +168 -0
  24. src/greedy_fallback.py +102 -0
  25. src/io/__init__.py +0 -0
  26. src/io/export.py +270 -0
  27. src/io/export_xml.py +66 -0
  28. src/io/readers.py +1048 -0
  29. src/io/roster_library.py +89 -0
  30. src/plan.py +192 -0
  31. src/recommender_staffing.py +329 -0
  32. src/roster_store.py +159 -0
  33. src/schemas.py +1244 -0
  34. src/solver/__init__.py +0 -0
  35. src/solver/allocator_cpsat.py +1412 -0
  36. src/staged_overrides.py +468 -0
  37. src/state.py +494 -0
  38. src/step1_clean_flights.py +286 -0
  39. src/step2_extract_roster.py +316 -0
  40. src/step3_allocate_flights.py +1639 -0
  41. src/web/__init__.py +47 -0
  42. src/web/__main__.py +9 -0
  43. src/web/api/__init__.py +56 -0
  44. src/web/api/export.py +37 -0
  45. src/web/api/inputs.py +122 -0
  46. src/web/api/override_rows.py +138 -0
  47. src/web/api/pages.py +30 -0
  48. src/web/api/readbacks.py +72 -0
  49. src/web/api/recommender.py +72 -0
  50. src/web/api/runs.py +102 -0
  51. src/web/api/settings.py +201 -0
  52. src/web/api/zc.py +117 -0
  53. src/web/core/__init__.py +5 -0
  54. src/web/core/responses.py +91 -0
  55. src/web/core/router.py +167 -0
  56. src/web/core/static_files.py +85 -0
  57. src/web/overrides/__init__.py +66 -0
  58. src/web/overrides/airports.py +261 -0
  59. src/web/overrides/break_time.py +83 -0
  60. src/web/overrides/config_yaml.py +21 -0
  61. src/web/overrides/filters.py +187 -0
  62. src/web/overrides/rows.py +110 -0
  63. src/web/readback/__init__.py +67 -0
  64. src/web/readback/common.py +68 -0
  65. src/web/readback/dashboard.py +83 -0
  66. src/web/readback/planning.py +335 -0
  67. src/web/readback/session.py +158 -0
  68. src/web/readback/tables.py +163 -0
  69. src/web/runner.py +168 -0
  70. src/web/server.py +185 -0
  71. src/zc_store.py +221 -0
@@ -0,0 +1,302 @@
1
+ """Eligibility matrix for Step 4 (round-3 update).
2
+
3
+ For every (flight, staff) pair, answer one question: is this staff eligible
4
+ to be assigned this flight, given the *hard* constraints H2, H3, H4, H11,
5
+ H12, H13? The matrix is a sparse mapping ``flight_id -> set[employee_id]``
6
+ that the CP-SAT solver consumes to define decision variables.
7
+
8
+ Each filter is a tiny pure function. Filter ordering does not change
9
+ correctness (all filters must pass — AND semantics) but it does change the
10
+ ``ExcludeReason`` returned for diagnostic logs.
11
+
12
+ Hard constraints enforced HERE:
13
+ H2 shift coverage (incl. handover overlap window — both prev and next
14
+ shift are eligible in the (nominal_end, nominal_end+30] window;
15
+ solver picks via S1+S5)
16
+ H3 P2F license match
17
+ H4 P2F dedication + buffer (no normal flights for the handler
18
+ from STD-2hrs to STD+1hr of each of their P2F flights)
19
+ H11 per-day overrides (STD cutoff, max_flights via H16 cap)
20
+ H12 international newbie + shift-edge exclusions
21
+ H13 AM exclusion
22
+ H20 nominated P2F handlers never take international flights (frees
23
+ up their day for more P2F work)
24
+
25
+ Hard constraints NOT enforced here (deferred to the solver):
26
+ H1 exactly one staff per flight — solver objective
27
+ H10 15-min spacing (30 domestic→INTL) — solver at-most-one pairs
28
+ H16 hard caps — solver constraint
29
+
30
+ Hard constraints removed in round-3 (now SOFT, handled in the solver):
31
+ H7 handover window (was tail-ext hard) — soft via S5
32
+ H9 ZC report buffers (was hard exclusion) — soft via S7
33
+ H15 awkward routing (was hard pre-filter) — soft via S6
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass, field
39
+ from datetime import date as date_t
40
+ from datetime import time as time_t
41
+ from enum import StrEnum
42
+
43
+ from ..schemas import (
44
+ AllocationSheet,
45
+ FlightInput,
46
+ OpsClass,
47
+ Role,
48
+ ShiftCode,
49
+ StaffMember,
50
+ )
51
+ from .windows import (
52
+ SHIFT_NOMINAL_MIN,
53
+ awkward_eligible_shifts,
54
+ in_p2f_block,
55
+ is_in_std_window,
56
+ std_to_ops_day_minutes,
57
+ )
58
+
59
+ # D-marker offset: planning happens 75 minutes before STD.
60
+ _D75_OFFSET_MIN: int = 75
61
+
62
+
63
+ def _intl_handler_covers_d75_through_std(
64
+ staff_shift: ShiftCode,
65
+ flight_std: time_t,
66
+ flight_date: date_t,
67
+ ops_day: date_t,
68
+ ) -> bool:
69
+ """For INTL same-handler rule (Change 4): does ``staff_shift``'s
70
+ nominal working window cover the full span from D-75 (the planning
71
+ marker) through STD?
72
+
73
+ Used by:
74
+ * Phase 2 (2026-05-14) — postsolve's INTL self-pair override
75
+ skips assigning PLANNED_BY / RELIEVED_BY when the handler can't
76
+ actually cover the planning window. Phase 3 makes this an
77
+ eligibility filter that rejects the assignment entirely.
78
+
79
+ Pure function — no side effects. Returns True iff:
80
+ * shift's nominal start ≤ (STD - 75 min), AND
81
+ * STD ≤ shift's nominal end.
82
+
83
+ Both bounds in ops-day minutes (handles N's midnight wrap via
84
+ ``std_to_ops_day_minutes``)."""
85
+ nominal = SHIFT_NOMINAL_MIN.get(staff_shift)
86
+ if nominal is None:
87
+ return False
88
+ start_min, end_min = nominal
89
+ std_min = std_to_ops_day_minutes(flight_std, flight_date, ops_day)
90
+ d75_min = std_min - _D75_OFFSET_MIN
91
+ return start_min <= d75_min and std_min <= end_min
92
+
93
+
94
+ class ExcludeReason(StrEnum):
95
+ """Diagnostic tag returned when a filter rejects a (flight, staff)
96
+ pair. Used by W201 diagnostic messages when a flight has zero
97
+ eligible staff."""
98
+
99
+ AM_ROLE = "AM (no flights, H13)"
100
+ NOT_ASSIGNABLE_TODAY = "staff not on shift today"
101
+ P2F_NOT_LICENSED = "P2F flight requires P2F license (H3)"
102
+ P2F_NOT_HANDLER = "P2F flight only goes to shift's nominated handler (H4)"
103
+ P2F_HANDLER_HARD_BLOCK = (
104
+ "normal flight within 2 hrs before / 1 hr after one of the "
105
+ "handler's P2F flights (H4)"
106
+ )
107
+ OUT_OF_SHIFT = "STD outside shift's distribution window (user 2026-05-12)"
108
+ STD_OUTSIDE_ALL_WINDOWS = "STD outside all shift windows"
109
+ OVERRIDE_CUTOFF = "STD past staff's per-day STD cutoff (H11)"
110
+ NEWBIE_INTERNATIONAL = "newbies excluded from international flights (H12a)"
111
+ INTL_SHIFT_EDGE = "international: staff within ±60 min of shift start/end (H12b — retired)"
112
+ INTL_SHIFT_COVERAGE = (
113
+ "international DEP: handler's shift does not cover D-75 through "
114
+ "STD (Change 4 — same handler start-to-finish)"
115
+ )
116
+ P2F_HANDLER_NO_INTL = (
117
+ "nominated P2F handlers do not take international flights (H20)"
118
+ )
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class EligibilityContext:
123
+ """Runtime context used by the filters. Computed once by the
124
+ orchestrator at preprocessing time."""
125
+
126
+ ops_day: date_t
127
+ p2f_handler_by_shift: dict[ShiftCode, str] = field(default_factory=dict)
128
+ """shift -> employee_id of the P2F handler nominated for that shift.
129
+ Per user direction 2026-05-10: only M / A / N have P2F handlers;
130
+ M1 and A1 P2F flights drop the handler restriction (any P2F-licensed
131
+ staff on shift can take them)."""
132
+
133
+ p2f_flight_minutes_by_handler: dict[str, list[int]] = field(default_factory=dict)
134
+ """employee_id -> list of P2F flight STDs (in ops-day minutes) the
135
+ handler is dedicated to. Drives H4's no-normal-flight window."""
136
+
137
+ # Phase R per-flight overrides — populated by step3 from the override list
138
+ # rows of type ``skip_p2f_buffer``. Each entry is (flight_key,
139
+ # employee_id) where flight_key = ``f"{flt}|{std.isoformat()}"``.
140
+ # When present, F6 skips the P2F buffer check for that exact pair.
141
+ skip_p2f_buffer_pairs: frozenset[tuple[str, str]] = frozenset()
142
+
143
+
144
+ def check(
145
+ flight: FlightInput, staff: StaffMember, ctx: EligibilityContext,
146
+ ) -> ExcludeReason | None:
147
+ """Return the first failing exclusion reason, or None if eligible."""
148
+ # F1: AM exclusion (H13). Cheapest; AMs never fly.
149
+ if staff.role == Role.AM:
150
+ return ExcludeReason.AM_ROLE
151
+ # F2: shift assignability. Off today => not eligible.
152
+ if staff.shift_today is None:
153
+ return ExcludeReason.NOT_ASSIGNABLE_TODAY
154
+ # F3: P2F license (H3). P2F flights require P2F-licensed staff.
155
+ if flight.ops_class == OpsClass.P2F and not staff.is_p2f_licensed:
156
+ return ExcludeReason.P2F_NOT_LICENSED
157
+ # F4: P2F dedication (H4). P2F is strictly M / A / N only — per
158
+ # user direction 2026-05-10, M1 and A1 staff are NEVER eligible for
159
+ # P2F flights regardless of license. Among M/A/N staff, only the
160
+ # shift's nominated handler is eligible.
161
+ if flight.ops_class == OpsClass.P2F:
162
+ if staff.shift_today not in ("M", "A", "N"):
163
+ return ExcludeReason.P2F_NOT_HANDLER # M1/A1 excluded
164
+ nominated = ctx.p2f_handler_by_shift.get(staff.shift_today)
165
+ if nominated != staff.employee_id:
166
+ return ExcludeReason.P2F_NOT_HANDLER
167
+ # F5: STD distribution window (user direction 2026-05-12, §1).
168
+ # A flight is eligible for a shift only if its STD falls in that
169
+ # shift's distribution window (M / M1 / N hard-bounded; A and A1
170
+ # have a soft outer band that the solver penalizes via S_outer).
171
+ if not is_in_std_window(
172
+ flight.std, flight.date, ctx.ops_day, staff.shift_today,
173
+ ):
174
+ return ExcludeReason.OUT_OF_SHIFT
175
+ # F6: P2F handler buffer (H4, user direction 2026-09-26). For each
176
+ # P2F flight the handler runs, no normal flight from 2 hrs before
177
+ # to 1 hr after its STD (prep and turnaround time). Applies to
178
+ # every handler, ZC+P2F included.
179
+ #
180
+ # Phase R override: if the (flight_key, employee_id) pair is in
181
+ # ``ctx.skip_p2f_buffer_pairs``, skip the buffer check entirely.
182
+ if flight.ops_class != OpsClass.P2F:
183
+ anchors = ctx.p2f_flight_minutes_by_handler.get(staff.employee_id, [])
184
+ if anchors:
185
+ flight_key = f"{flight.flt}|{flight.std.isoformat(timespec='minutes')}"
186
+ override_active = (
187
+ (flight_key, staff.employee_id) in ctx.skip_p2f_buffer_pairs
188
+ )
189
+ if not override_active:
190
+ target = std_to_ops_day_minutes(
191
+ flight.std, flight.date, ctx.ops_day,
192
+ )
193
+ if in_p2f_block(target, anchors):
194
+ return ExcludeReason.P2F_HANDLER_HARD_BLOCK
195
+ # F7: per-staff STD window (H11).
196
+ # 2026-05-27: extended to a windowed constraint — flights must fall
197
+ # in [std_start, std_cutoff] when those are set. Either bound may be
198
+ # None, in which case the staff's normal shift bound applies for
199
+ # that side (i.e., no extra constraint).
200
+ if staff.std_cutoff is not None and flight.std > staff.std_cutoff:
201
+ return ExcludeReason.OVERRIDE_CUTOFF
202
+ if staff.std_start is not None and flight.std < staff.std_start:
203
+ return ExcludeReason.OVERRIDE_CUTOFF
204
+ # F8: international newbie exclusion (H12a).
205
+ if flight.is_international and staff.is_newbie:
206
+ return ExcludeReason.NEWBIE_INTERNATIONAL
207
+ # F9 (Phase 3, Change 4 — 2026-05-14): INTL DEP same-handler
208
+ # start-to-finish. For an INTL flight, the handler's shift must
209
+ # nominally cover D-75 through STD (strict). If not, exclude.
210
+ # No buffer, no fallback (per user direction). Replaces the retired
211
+ # F9 INTL_SHIFT_EDGE rule.
212
+ if flight.is_international and not _intl_handler_covers_d75_through_std(
213
+ staff.shift_today, flight.std, flight.date, ctx.ops_day,
214
+ ):
215
+ return ExcludeReason.INTL_SHIFT_COVERAGE
216
+ # F10 (H20): the staff member nominated as *their own shift's* P2F
217
+ # handler never takes a *normal* international flight. Keeping
218
+ # ordinary INTL work off their plate frees up the time/slots
219
+ # they'd otherwise spend on it for more P2F flights instead. This
220
+ # must NOT touch the handler's own P2F flights — a P2F flight can
221
+ # itself be international (e.g. HAN-CCU), and F4 above is already
222
+ # the sole authority on who may take it; excluding it here would
223
+ # leave that flight with zero eligible staff. Mirrors F4's lookup
224
+ # — only the handler nominated for the shift they're working
225
+ # today is affected.
226
+ if (
227
+ flight.is_international
228
+ and flight.ops_class != OpsClass.P2F
229
+ and ctx.p2f_handler_by_shift.get(staff.shift_today) == staff.employee_id
230
+ ):
231
+ return ExcludeReason.P2F_HANDLER_NO_INTL
232
+ return None
233
+
234
+
235
+ def build_matrix(
236
+ flights: list[FlightInput],
237
+ staff_today: list[StaffMember],
238
+ ctx: EligibilityContext,
239
+ ) -> dict[str, set[str]]:
240
+ """Return ``{flight.unique_id: {employee_id, ...}}``. Sparse: only
241
+ includes eligible pairs.
242
+
243
+ Per user direction 2026-05-12: keyed by ``unique_id`` (flt + dep +
244
+ arr + STD) so multi-leg rotations with the same FLT number stay
245
+ independent. Earlier code keyed by ``flt`` alone, which caused
246
+ later legs to silently overwrite earlier ones in the dict.
247
+
248
+ Flight rows with empty sets are still emitted; the orchestrator
249
+ scans for these and aborts with W201 before invoking the solver.
250
+ """
251
+ matrix: dict[str, set[str]] = {}
252
+ for f in flights:
253
+ eligible: set[str] = set()
254
+ for s in staff_today:
255
+ if check(f, s, ctx) is None:
256
+ eligible.add(s.employee_id)
257
+ matrix[f.unique_id] = eligible
258
+ return matrix
259
+
260
+
261
+ def diagnose_unassignable(
262
+ flight: FlightInput, staff_today: list[StaffMember], ctx: EligibilityContext,
263
+ ) -> list[tuple[str, ExcludeReason]]:
264
+ """For a flight with zero eligible staff, return the list of
265
+ (employee_id, reason) tuples explaining why each candidate was excluded.
266
+ Drives W201 diagnostic messages.
267
+ """
268
+ out: list[tuple[str, ExcludeReason]] = []
269
+ for s in staff_today:
270
+ reason = check(flight, s, ctx)
271
+ if reason is not None:
272
+ out.append((s.employee_id, reason))
273
+ return out
274
+
275
+
276
+ # ---------- preprocessing helpers ----------
277
+
278
+ def tag_awkward_window(flight: FlightInput, ops_day: date_t) -> FlightInput:
279
+ """Tag a flight with is_awkward_window + awkward_eligible_shifts for
280
+ the solver's soft S6 routing preference. No longer drives eligibility
281
+ (awkward routing is soft now), but the solver consults these tags to
282
+ add a small penalty for non-preferred shifts."""
283
+ awk = awkward_eligible_shifts(flight.std, flight.date, ops_day)
284
+ if awk is None:
285
+ return flight
286
+ return flight.model_copy(update={
287
+ "is_awkward_window": True,
288
+ "awkward_eligible_shifts": awk,
289
+ })
290
+
291
+
292
+ def sheet_target_for(flight: FlightInput, staff: StaffMember) -> AllocationSheet:
293
+ """Resolve which allocation lane the row belongs to. Routing is
294
+ by ops_class (P2F) with the day/night split decided by the
295
+ assigned STAFF's shift (clarification 1)."""
296
+ if flight.ops_class == OpsClass.P2F:
297
+ return AllocationSheet.P2F
298
+ return (
299
+ AllocationSheet.NIGHT_OPS
300
+ if staff.shift_today == "N"
301
+ else AllocationSheet.DAY_OPS
302
+ )
@@ -0,0 +1,106 @@
1
+ """Greedy fallback allocator — Step 4 safety net.
2
+
3
+ CP-SAT (``solver/allocator_cpsat.py``) is the primary allocator: it
4
+ enforces every hard rule (H1/H10/H16/H4/H17/H18...) simultaneously and,
5
+ when it reaches OPTIMAL, provably gives the best weighted result. But
6
+ it is an EXACT solver — if any combination of hard constraints turns
7
+ out to be mutually unsatisfiable (a mis-tuned config value, a day with
8
+ an unusual shape, a bug not yet caught), it reports INFEASIBLE and
9
+ hands back ZERO assignments for the WHOLE day, even for the 99% of
10
+ flights that had nothing to do with the conflict. That is what
11
+ happened in production on 2026-09-22 (a static N/ZC workload floor of
12
+ 14 with zero actual night-ops flights).
13
+
14
+ This module is the fallback for exactly that situation. It is invoked
15
+ ONLY when CP-SAT returns a non-feasible status. It ignores every soft
16
+ objective (workload balance, handover preference, INTL spacing
17
+ fairness, day-level targeting...) — it exists purely so the operator
18
+ gets a usable, physically-valid allocation instead of a wall of
19
+ unallocated flights, with a loud warning that manual review is needed.
20
+
21
+ Hard rules this DOES still enforce (the ones that are non-negotiable
22
+ even in a degraded mode):
23
+ H1 one staff per flight — never double-books a flight
24
+ H10 same-staff spacing — never overlaps/underspaces
25
+ one person's flights
26
+ H16 per-staff hard cap — never exceeds a staff's max
27
+
28
+ Hard rules this does NOT enforce (left to the operator to review via
29
+ the Warnings / Unallocated tabs when a greedy-fallback run happens):
30
+ H17 P2F-per-handler cap of 8, H18
31
+ workload-spread bucketing, INTL D-75 coverage nuances.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from collections import defaultdict
37
+ from datetime import date as date_t
38
+
39
+ from ..schemas import FlightInput, OpsClass, StaffMember
40
+ from .caps import hard_cap_for
41
+ from .windows import SpacingKey, spacing_clear, spacing_key, std_to_ops_day_minutes
42
+
43
+
44
+ def greedy_allocate(
45
+ flights: list[FlightInput],
46
+ staff: list[StaffMember],
47
+ eligibility: dict[str, set[str]],
48
+ *,
49
+ ops_day: date_t,
50
+ ) -> dict[str, str]:
51
+ """Best-effort assignment respecting H1, H10, H16 only.
52
+
53
+ Places every P2F flight first (so the nominated handler is never
54
+ crowded out by normal flights), then all other flights, each group
55
+ in chronological (STD) order; for each, picks the least-loaded
56
+ eligible staff who doesn't violate that staff's
57
+ spacing or hard cap. No optimality guarantee and no workload
58
+ balancing beyond "prefer whoever has fewer flights so far" — this
59
+ is a safety net, not a replacement for the CP-SAT model.
60
+
61
+ Returns ``{flight.unique_id: employee_id}`` for whatever it
62
+ managed to place. A flight with no eligible-and-available staff is
63
+ simply absent from the result, exactly like a partial CP-SAT
64
+ solve — the caller's existing UNALLOCATED bookkeeping picks it up
65
+ the same way either way.
66
+ """
67
+ staff_by_id = {s.employee_id: s for s in staff}
68
+ caps = {s.employee_id: hard_cap_for(s) for s in staff}
69
+ counts: dict[str, int] = defaultdict(int)
70
+ # Per-staff SpacingKey list for H10 spacing checks.
71
+ assigned_by_staff: dict[str, list[SpacingKey]] = defaultdict(list)
72
+
73
+ # P2F FIRST (2026-09-22, user direction): the nominated handler's P2F
74
+ # flights are placed before any normal flight is considered.
75
+ # Walking purely by time let a handler fill up on earlier normal
76
+ # flights and then hit his cap / spacing when his P2F flights came
77
+ # up. Within each group the order is still chronological.
78
+ ordered = sorted(
79
+ flights,
80
+ key=lambda f: (
81
+ f.ops_class != OpsClass.P2F,
82
+ std_to_ops_day_minutes(f.std, f.date, ops_day),
83
+ ),
84
+ )
85
+
86
+ assignments: dict[str, str] = {}
87
+ for f in ordered:
88
+ key = spacing_key(f, ops_day)
89
+ candidates: list[str] = []
90
+ for eid in eligibility.get(f.unique_id, ()):
91
+ if staff_by_id.get(eid) is None:
92
+ continue
93
+ cap = caps.get(eid, 0)
94
+ if cap <= 0 or counts[eid] >= cap:
95
+ continue
96
+ if spacing_clear(key, assigned_by_staff[eid]):
97
+ candidates.append(eid)
98
+ if not candidates:
99
+ continue
100
+ candidates.sort(key=lambda eid: (counts[eid], eid))
101
+ pick = candidates[0]
102
+ assignments[f.unique_id] = pick
103
+ counts[pick] += 1
104
+ assigned_by_staff[pick].append(key)
105
+
106
+ return assignments
@@ -0,0 +1,124 @@
1
+ """Independent re-check of the hard rules on a finished allocation.
2
+
3
+ The CP-SAT model and the two post-passes (P2F, INTL) each enforce parts of
4
+ H1 / H10 / H16, and the post-passes move flights *after* the solver has
5
+ finished. This module re-derives the rules from the final rows alone, so a
6
+ regression in any stage shows up as a concrete list of violations instead
7
+ of a quietly worse roster.
8
+
9
+ It is deliberately decoupled from the solver: it only needs rows that carry
10
+ ``date``, ``std``, ``flt``, ``dep``, ``arr`` and ``staff_employee_id``
11
+ (``AllocationRow`` qualifies, so does a ``SimpleNamespace`` in a test),
12
+ plus ``is_international`` and ``sheet_target`` when a row has them.
13
+
14
+ Rules checked
15
+ -------------
16
+ H1 a flight appears against at most one staff member
17
+ H10 same-staff spacing (15 min, or 30 min for a domestic flight followed
18
+ by an international one and for two P2F flights — see
19
+ ``windows.required_spacing_min``)
20
+ H16 per-staff hard cap (only when ``caps`` is supplied)
21
+
22
+ Rows with an empty ``staff_employee_id`` (pre-plan-only / unallocated) are
23
+ ignored: they are reported elsewhere and have no owner to check.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections import Counter, defaultdict
29
+ from collections.abc import Collection, Iterable, Mapping
30
+ from dataclasses import dataclass
31
+ from datetime import date as date_t
32
+
33
+ from .windows import (
34
+ SPACING_MAX_MIN,
35
+ SpacingKey,
36
+ required_spacing_min,
37
+ std_to_ops_day_minutes,
38
+ )
39
+
40
+
41
+ def _spacing_key(row, ops_day: date_t) -> SpacingKey:
42
+ """A row's H10 identity. Rows carry no ops class, so P2F is read
43
+ off the lane they were routed to; missing fields read as a plain
44
+ domestic flight."""
45
+ return (
46
+ std_to_ops_day_minutes(row.std, row.date, ops_day),
47
+ bool(getattr(row, "is_international", False)),
48
+ str(getattr(row, "sheet_target", "")) == "P2F",
49
+ )
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Violation:
54
+ """One broken rule. ``employee_id`` is empty for flight-level rules."""
55
+
56
+ rule: str
57
+ employee_id: str
58
+ detail: str
59
+
60
+ def __str__(self) -> str: # pragma: no cover - cosmetic
61
+ who = f" [{self.employee_id}]" if self.employee_id else ""
62
+ return f"{self.rule}{who}: {self.detail}"
63
+
64
+
65
+ def flight_key(row) -> str:
66
+ """Stable identity for a flight row (mirrors ``FlightInput.unique_id``)."""
67
+ return f"{row.flt}|{row.dep}|{row.arr}|{row.std.isoformat()}|{row.date.isoformat()}"
68
+
69
+
70
+ def check_allocation(
71
+ rows: Iterable,
72
+ *,
73
+ ops_day: date_t,
74
+ caps: Mapping[str, int] | None = None,
75
+ waived_pairs: Collection[frozenset[str]] = (),
76
+ ) -> list[Violation]:
77
+ """Return every H1 / H10 / H16 violation found in ``rows``.
78
+
79
+ ``caps`` maps employee_id -> hard cap (H16). Staff missing from the
80
+ mapping are not cap-checked.
81
+ ``waived_pairs`` holds ``frozenset({key_a, key_b})`` entries for pairs
82
+ an operator explicitly waived (the ``waive_h10_pair`` override); those
83
+ pairs are not reported for H10.
84
+ """
85
+ assigned = [r for r in rows if getattr(r, "staff_employee_id", "")]
86
+ violations: list[Violation] = []
87
+
88
+ # ---- H1: no flight owned twice ----
89
+ seen = Counter(flight_key(r) for r in assigned)
90
+ for key, n in sorted(seen.items()):
91
+ if n > 1:
92
+ violations.append(Violation("H1", "", f"{key} assigned {n} times"))
93
+
94
+ by_staff: dict[str, list] = defaultdict(list)
95
+ for r in assigned:
96
+ by_staff[r.staff_employee_id].append(r)
97
+
98
+ for emp_id, flights in sorted(by_staff.items()):
99
+ # ---- H16: hard cap ----
100
+ if caps is not None and emp_id in caps and len(flights) > caps[emp_id]:
101
+ violations.append(
102
+ Violation("H16", emp_id, f"{len(flights)} flights > cap {caps[emp_id]}")
103
+ )
104
+
105
+ # ---- H10: spacing (all pairs inside the max window, not just
106
+ # neighbours: a domestic flight 20 min before an INTL one breaks
107
+ # the 30-min rule even with a flight in between) ----
108
+ timed = sorted((_spacing_key(r, ops_day), flight_key(r)) for r in flights)
109
+ for i, (a, a_key) in enumerate(timed):
110
+ for b, b_key in timed[i + 1:]:
111
+ gap = b[0] - a[0]
112
+ if gap >= SPACING_MAX_MIN:
113
+ break
114
+ if gap < required_spacing_min(a, b):
115
+ if frozenset({a_key, b_key}) in waived_pairs:
116
+ continue
117
+ violations.append(
118
+ Violation(
119
+ "H10",
120
+ emp_id,
121
+ f"{a_key} and {b_key} only {gap} min apart",
122
+ )
123
+ )
124
+ return violations
@@ -0,0 +1,142 @@
1
+ """P2F-first priority: reserve each handler's P2F flights before anything else.
2
+
3
+ Why this exists
4
+ ---------------
5
+ A P2F flight can only go to the shift's nominated handler (eligibility F4).
6
+ That same handler is also eligible for ordinary flights, and every one of
7
+ those competes with his P2F flights for the same cap (H16) and the same
8
+ 15-minute spacing (H10). With every flight costing the same when left
9
+ unallocated, nothing told the allocator that the handler's P2F work comes
10
+ first, so he could end up filled with normal flights and a P2F left over.
11
+
12
+ ``select_p2f_priority`` decides, per handler, which P2F flights are
13
+ *reserved* for him. The CP-SAT model then fixes those assignments and the
14
+ normal flights are solved around them; the greedy fallback places them
15
+ before it looks at any normal flight.
16
+
17
+ Only flights that can be reserved *safely* are returned, so fixing them
18
+ can never make the model infeasible:
19
+
20
+ * the flight must have exactly one nominated handler eligible for it
21
+ (if two handlers could take it, the choice is left to the solver);
22
+ * at most ``per_handler_limit`` (H17: 8) per handler, and never more than
23
+ the handler's own hard cap (H16);
24
+ * two reserved P2F flights for one handler must be at least 30 min
25
+ apart (H10), unless the operator waived that pair;
26
+ * a flight is skipped when a normal flight already pinned to the handler
27
+ (re-solve mode) is too close to it, or when it is pinned elsewhere.
28
+
29
+ Anything skipped is returned with a reason so the caller can log it. Those
30
+ flights are still allocated by the solver, just without the reservation.
31
+
32
+ The module only needs flights with ``unique_id``, ``date``, ``std``,
33
+ ``ops_class`` and ``is_international`` — no solver dependency — so it is
34
+ cheap to unit test.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from collections import defaultdict
40
+ from collections.abc import Collection, Iterable, Mapping
41
+ from datetime import date as date_t
42
+
43
+ from ..schemas import OpsClass
44
+ from .windows import SpacingKey, spacing_clear, spacing_key
45
+
46
+ # H17: no handler is asked to do more than this many P2F flights a day.
47
+ P2F_PER_HANDLER_LIMIT = 8
48
+
49
+
50
+ def select_p2f_priority(
51
+ flights: Iterable,
52
+ eligibility: Mapping[str, Collection[str]],
53
+ handler_ids: Collection[str],
54
+ *,
55
+ ops_day: date_t,
56
+ cap_by_staff: Mapping[str, int],
57
+ per_handler_limit: int = P2F_PER_HANDLER_LIMIT,
58
+ pinned_assignments: Mapping[str, str] | None = None,
59
+ waive_h10_triples: Collection[tuple[str, str, str]] = frozenset(),
60
+ ) -> tuple[dict[str, str], list[tuple[str, str]]]:
61
+ """Pick the P2F flights to reserve for their handlers.
62
+
63
+ Returns ``(reserved, skipped)``:
64
+
65
+ * ``reserved`` — ``{flight_unique_id: handler_employee_id}``
66
+ * ``skipped`` — ``[(flight_unique_id, reason), ...]`` for every P2F
67
+ flight that was *not* reserved.
68
+ """
69
+ pinned = dict(pinned_assignments or {})
70
+ handler_set = set(handler_ids)
71
+ flight_list = list(flights)
72
+ key_of = {f.unique_id: spacing_key(f, ops_day) for f in flight_list}
73
+
74
+ reserved: dict[str, str] = {}
75
+ skipped: list[tuple[str, str]] = []
76
+
77
+ # Group each P2F flight under the single handler eligible for it.
78
+ by_handler: dict[str, list] = defaultdict(list)
79
+ for f in flight_list:
80
+ if f.ops_class != OpsClass.P2F:
81
+ continue
82
+ candidates = set(eligibility.get(f.unique_id, ())) & handler_set
83
+ if not candidates:
84
+ skipped.append((f.unique_id, "no nominated handler is eligible"))
85
+ elif len(candidates) > 1:
86
+ skipped.append((f.unique_id, "more than one handler is eligible"))
87
+ else:
88
+ by_handler[next(iter(candidates))].append(f)
89
+
90
+ # Times of ordinary flights already pinned to each handler (re-solve
91
+ # mode). A reserved P2F flight must keep H10 spacing from these.
92
+ p2f_uids = {f.unique_id for fl in by_handler.values() for f in fl}
93
+ pinned_normal: dict[str, list[SpacingKey]] = defaultdict(list)
94
+ for uid, eid in pinned.items():
95
+ if eid in by_handler and uid in key_of and uid not in p2f_uids:
96
+ pinned_normal[eid].append(key_of[uid])
97
+
98
+ def _waived(uid_a: str, uid_b: str, emp: str) -> bool:
99
+ return (
100
+ (uid_a, uid_b, emp) in waive_h10_triples
101
+ or (uid_b, uid_a, emp) in waive_h10_triples
102
+ )
103
+
104
+ for handler, p2f_flights in by_handler.items():
105
+ limit = min(per_handler_limit, cap_by_staff.get(handler, 0))
106
+ # Flights already pinned to this handler go first (they are fixed
107
+ # anyway), then earliest STD.
108
+ ordered = sorted(
109
+ p2f_flights,
110
+ key=lambda f: (pinned.get(f.unique_id) != handler, key_of[f.unique_id][0]),
111
+ )
112
+ taken: list = []
113
+ for f in ordered:
114
+ uid = f.unique_id
115
+ pinned_to = pinned.get(uid)
116
+ if pinned_to is not None and pinned_to != handler:
117
+ skipped.append((uid, f"pinned to {pinned_to}, not the handler"))
118
+ continue
119
+ if len(taken) >= limit:
120
+ skipped.append((uid, f"handler already has {limit} reserved (H17/H16)"))
121
+ continue
122
+ here = key_of[uid]
123
+ clash = next(
124
+ (
125
+ o for o in taken
126
+ if not _waived(uid, o.unique_id, handler)
127
+ and not spacing_clear(here, [key_of[o.unique_id]])
128
+ ),
129
+ None,
130
+ )
131
+ if clash is not None:
132
+ skipped.append(
133
+ (uid, f"too close to reserved P2F {clash.unique_id} (H10)")
134
+ )
135
+ continue
136
+ if not spacing_clear(here, pinned_normal.get(handler, ())):
137
+ skipped.append((uid, "too close to a flight already pinned to the handler"))
138
+ continue
139
+ taken.append(f)
140
+ reserved[uid] = handler
141
+
142
+ return reserved, skipped