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,468 @@
1
+ """Apply add_flight / remove_flight / add_staff / remove_staff /
2
+ change_role / sick overrides directly to the in-memory data, so the
3
+ dashboard reflects them BEFORE Allocate is clicked.
4
+
5
+ "Save (no re-run)" lets the assigner stage a change — add a person, add
6
+ a flight, pull someone out — and see the projected state on the
7
+ dashboard, confirming intent before triggering the solver. This module
8
+ is the bridge.
9
+
10
+ Apply rules:
11
+ add_flight → append a CleanFlightRow under the named ops_class.
12
+ remove_flight → drop the matching (flt, std) row from every ops_class.
13
+ add_staff → append a synthetic AvailabilityRow with id ``ADD_NNN``.
14
+ remove_staff → flip the matching staff's ``assignable`` to False
15
+ (same semantics as type=sick).
16
+ change_role → rewrite the staff's role, and their status when the
17
+ new role is ZC.
18
+ change_shift → rewrite an EXISTING staff's shift/status for the day
19
+ (M/A/N/M1/A1, or an off/leave code) — the Roster
20
+ Change dashboard pane. Unlike add_staff/remove_staff
21
+ this doesn't touch role or add a synthetic row.
22
+ ZC list → everyone on the saved Zone Controller list for D
23
+ (``zc_store``, picked on the dashboard) is promoted to
24
+ ZC, and anyone removed there is taken off ZC — even if
25
+ the roster cell still says ``/ZC``.
26
+
27
+ Every call re-applies ALL staged rows from scratch, which is why each
28
+ applier is written to be a no-op when its effect is already present.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Mapping
34
+ from datetime import date as date_t
35
+ from datetime import time as time_t
36
+ from typing import Any
37
+
38
+ from . import zc_store
39
+ from .schemas import (
40
+ NON_ASSIGNABLE,
41
+ STATUS_IS_ZC,
42
+ STATUS_TO_SHIFT,
43
+ AvailabilityRow,
44
+ CleanFlightRow,
45
+ CrewStatus,
46
+ OpsClass,
47
+ OverrideType,
48
+ Role,
49
+ )
50
+ from .state import AppState
51
+
52
+
53
+ def _cell(row: Mapping[str, Any], *names: str) -> str:
54
+ """First non-blank value among ``names``, stripped. '' when none."""
55
+ for n in names:
56
+ v = row.get(n)
57
+ if v is None:
58
+ continue
59
+ s = str(v).strip()
60
+ if s:
61
+ return s
62
+ return ""
63
+
64
+
65
+ def _parse_time(s: str) -> time_t | None:
66
+ """Best-effort HH:MM parser. Accepts ``8:30`` / ``08:30`` /
67
+ ``08:30:00``. None on failure (caller skips the row)."""
68
+ if not s:
69
+ return None
70
+ parts = s.split(":")
71
+ try:
72
+ h = int(parts[0])
73
+ m = int(parts[1]) if len(parts) > 1 else 0
74
+ return time_t(h, m)
75
+ except (ValueError, IndexError):
76
+ return None
77
+
78
+
79
+ def _parse_date(s: str, fallback: date_t) -> date_t:
80
+ """Accept ISO (2026-05-28) or MM/DD (05/28, year from fallback)."""
81
+ if not s:
82
+ return fallback
83
+ try:
84
+ return date_t.fromisoformat(s)
85
+ except ValueError:
86
+ pass
87
+ try:
88
+ mm, dd = s.split("/")[:2]
89
+ return date_t(fallback.year, int(mm), int(dd))
90
+ except (ValueError, IndexError):
91
+ return fallback
92
+
93
+
94
+ def _normalize_name(s: Any) -> str:
95
+ """Collapse internal whitespace + uppercase, so 'AKSHAT CHATURVEDI'
96
+ and 'akshat chaturvedi' resolve to the same key."""
97
+ import re as _re
98
+ return _re.sub(r"\s+", " ", str(s or "")).strip().upper()
99
+
100
+
101
+ def apply_staged_overrides(state: AppState, d_day: date_t) -> dict[str, int]:
102
+ """Apply every staged override type to the cleaned flights and the
103
+ availability list. Returns {override_type: rows_applied}."""
104
+ counts: dict[str, int] = {
105
+ OverrideType.ADD_FLIGHT.value: 0,
106
+ OverrideType.REMOVE_FLIGHT.value: 0,
107
+ OverrideType.ADD_STAFF.value: 0,
108
+ OverrideType.REMOVE_STAFF.value: 0,
109
+ OverrideType.CHANGE_ROLE.value: 0,
110
+ OverrideType.CHANGE_SHIFT.value: 0,
111
+ "zc_list": 0,
112
+ }
113
+ with state.lock:
114
+ for row in list(state.overrides):
115
+ tp = _cell(row, "type").lower()
116
+ if tp == OverrideType.ADD_FLIGHT.value:
117
+ if _apply_add_flight(state, row, d_day):
118
+ counts[tp] += 1
119
+ elif tp == OverrideType.REMOVE_FLIGHT.value:
120
+ if _apply_remove_flight(state, row, d_day):
121
+ counts[tp] += 1
122
+ elif tp == OverrideType.ADD_STAFF.value:
123
+ if _apply_add_staff(state, row, d_day):
124
+ counts[tp] += 1
125
+ elif tp in (OverrideType.REMOVE_STAFF.value, OverrideType.SICK.value):
126
+ # sick is handled again by step3, but doing it here too
127
+ # lets the dashboard reflect it pre-Allocate.
128
+ if _apply_remove_staff(state, row, d_day):
129
+ counts[OverrideType.REMOVE_STAFF.value] += 1
130
+ elif tp == OverrideType.CHANGE_ROLE.value:
131
+ if _apply_change_role(state, row, d_day):
132
+ counts[tp] += 1
133
+ elif tp == OverrideType.CHANGE_SHIFT.value:
134
+ if _apply_change_shift(state, row, d_day):
135
+ counts[tp] += 1
136
+ # The dashboard's ZC list goes last, so an explicit change_role
137
+ # row (e.g. ZC -> AM for someone who is unwell) is applied first
138
+ # and the list only promotes people still in the live pool.
139
+ counts["zc_list"] = _apply_zc_list(state, d_day)
140
+ return counts
141
+
142
+
143
+ # ---------------- flights ----------------
144
+
145
+
146
+ def _apply_add_flight(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
147
+ flt = _cell(row, "flight", "flt", "employee")
148
+ dep = _cell(row, "dep").upper()[:3]
149
+ arr = _cell(row, "arr").upper()[:3]
150
+ std = _parse_time(_cell(row, "std", "shift"))
151
+ if not (flt and dep and arr and std):
152
+ return False
153
+ try:
154
+ ops_class = OpsClass(_cell(row, "ops_class").lower() or "day")
155
+ except ValueError:
156
+ return False
157
+ flight_date = _parse_date(_cell(row, "date"), d_day)
158
+ try:
159
+ pax = int(float(_cell(row, "pax", "limit") or 0))
160
+ except (TypeError, ValueError):
161
+ pax = 0
162
+
163
+ bucket = state.cleaned.setdefault(ops_class, [])
164
+ # Idempotent: same date + flt + std on the same class is a no-op.
165
+ for existing in bucket:
166
+ if (existing.date == flight_date and existing.flt == flt
167
+ and existing.std == std):
168
+ return False
169
+ try:
170
+ new_row = CleanFlightRow(
171
+ date=flight_date,
172
+ flt=flt,
173
+ type=_cell(row, "ac_type"),
174
+ ac=_cell(row, "ac"),
175
+ dep=dep,
176
+ arr=arr,
177
+ std=std,
178
+ load=max(0, min(600, pax)),
179
+ ops_class=ops_class,
180
+ )
181
+ except ValueError as exc:
182
+ print(f" [add_flight] skipped {flt!r}: {exc}")
183
+ return False
184
+ bucket.append(new_row)
185
+ return True
186
+
187
+
188
+ def _apply_remove_flight(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
189
+ flt = _cell(row, "flight", "flt", "employee")
190
+ std = _parse_time(_cell(row, "std", "shift"))
191
+ if not (flt and std):
192
+ return False
193
+ # Optional matchers — narrow the match when the form supplied them.
194
+ want_dep = _cell(row, "dep").upper()[:3]
195
+ want_arr = _cell(row, "arr").upper()[:3]
196
+ raw_date = _cell(row, "date")
197
+ want_date = _parse_date(raw_date, d_day) if raw_date else None
198
+ # An MM/DD date has no year, so match on month+day only.
199
+ day_only = bool(raw_date and "/" in raw_date)
200
+
201
+ removed = False
202
+ for ops, rows in state.cleaned.items():
203
+ keep: list[CleanFlightRow] = []
204
+ for r in rows:
205
+ match = (
206
+ r.flt == flt
207
+ and r.std == std
208
+ and (not want_dep or r.dep == want_dep)
209
+ and (not want_arr or r.arr == want_arr)
210
+ )
211
+ if match and want_date is not None:
212
+ if day_only:
213
+ match = (r.date.month, r.date.day) == (want_date.month, want_date.day)
214
+ else:
215
+ match = r.date == want_date
216
+ if match:
217
+ removed = True
218
+ else:
219
+ keep.append(r)
220
+ state.cleaned[ops] = keep
221
+ return removed
222
+
223
+
224
+ # ---------------- staff ----------------
225
+
226
+
227
+ def _apply_add_staff(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
228
+ name = _cell(row, "employee")
229
+ shift = _cell(row, "shift").upper()
230
+ role_raw = _cell(row, "role").upper() or "STAFF"
231
+ if not (name and shift):
232
+ print(f" [add_staff] skipped: missing field — name={name!r}, shift={shift!r}")
233
+ return False
234
+ if shift not in ("M", "A", "N", "M1", "A1"):
235
+ print(
236
+ f" [add_staff] skipped: shift {shift!r} not in "
237
+ "(M, A, N, M1, A1) — use a plain shift code"
238
+ )
239
+ return False
240
+ try:
241
+ role = Role(role_raw)
242
+ except ValueError:
243
+ print(f" [add_staff] skipped: role {role_raw!r} not in (STAFF, ZC, AM)")
244
+ return False
245
+
246
+ target = _normalize_name(name)
247
+ d_iso = d_day.isoformat()
248
+ for av in state.availability:
249
+ if _normalize_name(av.name) == target and av.date.isoformat() == d_iso:
250
+ return False # already present — nothing to add
251
+
252
+ n_added = sum(1 for av in state.availability if av.employee_id.startswith("ADD_"))
253
+ state.availability.append(AvailabilityRow(
254
+ employee_id=f"ADD_{n_added + 1:03d}",
255
+ name=name,
256
+ role=role,
257
+ date=d_day,
258
+ status=CrewStatus(shift),
259
+ raw_status=shift,
260
+ assignable=True,
261
+ current_shift=shift,
262
+ license=None,
263
+ ))
264
+ return True
265
+
266
+
267
+ def _apply_remove_staff(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
268
+ name = _cell(row, "employee")
269
+ if not name:
270
+ print(" [remove_staff] skipped: blank employee field")
271
+ return False
272
+ target = _normalize_name(name)
273
+ d_iso = d_day.isoformat()
274
+ found = False
275
+ candidates: list[str] = []
276
+ for i, av in enumerate(state.availability):
277
+ if av.date.isoformat() != d_iso:
278
+ continue
279
+ nm = _normalize_name(av.name)
280
+ candidates.append(nm)
281
+ if nm == target:
282
+ state.availability[i] = av.model_copy(update={"assignable": False})
283
+ found = True
284
+ if not found:
285
+ # Loud diagnostic — common causes are a name typo, the staff not
286
+ # being on shift today, or the wrong date.
287
+ nearby = (
288
+ [n for n in candidates if target.split()[0] in n]
289
+ if " " in target else []
290
+ )
291
+ hint = f"; similar names on {d_iso}: {nearby[:3]}" if nearby else ""
292
+ print(
293
+ f" [remove_staff] WARNING: no roster row matches "
294
+ f"employee={name!r} on {d_iso} ({len(candidates)} staff rows "
295
+ f"for that date){hint}"
296
+ )
297
+ return found
298
+
299
+
300
+ def _apply_change_role(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
301
+ """Flip a staff's role so the dashboard roster reflects the change
302
+ without a re-solve.
303
+
304
+ The ``shift`` column carries the NEW role (STAFF / ZC / AM). For ZC
305
+ we also rewrite status / raw_status to the ``<shift>/ZC`` variant so
306
+ downstream readers see a ZC-flagged row; AM drops them out of the
307
+ live pool, matching the step3 semantics.
308
+ """
309
+ name = _cell(row, "employee")
310
+ new_role_raw = _cell(row, "shift", "role").upper()
311
+ if not (name and new_role_raw):
312
+ return False
313
+ try:
314
+ new_role = Role(new_role_raw)
315
+ except ValueError:
316
+ return False
317
+
318
+ target = _normalize_name(name)
319
+ d_iso = d_day.isoformat()
320
+ found = False
321
+ for i, av in enumerate(state.availability):
322
+ if _normalize_name(av.name) != target or av.date.isoformat() != d_iso:
323
+ continue
324
+ update: dict[str, Any] = {"role": new_role}
325
+ if new_role is Role.ZC:
326
+ update = _zc_update(av)
327
+ elif new_role is Role.AM:
328
+ update["assignable"] = False
329
+ state.availability[i] = av.model_copy(update=update)
330
+ found = True
331
+ return found
332
+
333
+
334
+ def _apply_change_shift(state: AppState, row: Mapping[str, Any], d_day: date_t) -> bool:
335
+ """Move an EXISTING staff member to a new shift, or off/on leave, for
336
+ the day — the Roster Change dashboard pane (search a name, pick M /
337
+ A / N / M1 / A1, or Off / On Leave).
338
+
339
+ The ``shift`` column carries the new status as the roster file's own
340
+ literal: a plain shift code, or an off/leave code (``F`` day-off,
341
+ ``P/L`` paid leave, ``C/L`` casual leave, ``C/OFF`` custom off).
342
+ Unlike ``remove_staff``/``sick`` this doesn't just flip a flag — it
343
+ rewrites status/current_shift so the person reappears correctly if
344
+ later moved back onto a shift. Unlike ``add_staff`` it never creates
345
+ a row: an unrecognized employee is a no-op, same as remove_staff.
346
+ """
347
+ name = _cell(row, "employee")
348
+ raw = _cell(row, "shift").upper()
349
+ if not (name and raw):
350
+ return False
351
+ try:
352
+ new_status = CrewStatus(raw)
353
+ except ValueError:
354
+ print(f" [change_shift] skipped: unrecognized shift/status {raw!r}")
355
+ return False
356
+
357
+ target = _normalize_name(name)
358
+ d_iso = d_day.isoformat()
359
+ found = False
360
+ for i, av in enumerate(state.availability):
361
+ if _normalize_name(av.name) != target or av.date.isoformat() != d_iso:
362
+ continue
363
+ state.availability[i] = av.model_copy(update={
364
+ "status": new_status,
365
+ "raw_status": new_status.value,
366
+ "current_shift": STATUS_TO_SHIFT.get(new_status),
367
+ "assignable": new_status not in NON_ASSIGNABLE,
368
+ })
369
+ found = True
370
+ if not found:
371
+ print(
372
+ f" [change_shift] WARNING: no roster row matches "
373
+ f"employee={name!r} on {d_iso}"
374
+ )
375
+ return found
376
+
377
+
378
+ # ---------------- Zone Controller list ----------------
379
+
380
+ # A P2F handler who is made ZC keeps the handler duty: M/P2F -> M/P2F/ZC.
381
+ # (Rewriting it to plain M/ZC would drop them from the P2F auto-pick.)
382
+ _P2F_TO_ZC_P2F: dict[CrewStatus, CrewStatus] = {
383
+ CrewStatus.P2F_M: CrewStatus.ZC_P2F_M,
384
+ CrewStatus.P2F_A: CrewStatus.ZC_P2F_A,
385
+ CrewStatus.P2F_N: CrewStatus.ZC_P2F_N,
386
+ }
387
+
388
+
389
+ def _zc_update(av: AvailabilityRow) -> dict[str, Any]:
390
+ """The field changes that make ``av`` a Zone Controller for the day:
391
+ role ZC plus the matching ``<shift>/ZC`` status, so every downstream
392
+ reader (dashboard, step3, auto-P2F picker) sees a ZC-flagged row."""
393
+ update: dict[str, Any] = {"role": Role.ZC}
394
+ if av.status in STATUS_IS_ZC:
395
+ return update
396
+ target = _P2F_TO_ZC_P2F.get(av.status)
397
+ if target is None and av.current_shift in ("M", "A", "N", "M1", "A1"):
398
+ try:
399
+ target = CrewStatus(f"{av.current_shift}/ZC")
400
+ except ValueError:
401
+ target = None
402
+ if target is not None:
403
+ update["status"] = target
404
+ update["raw_status"] = target.value
405
+ return update
406
+
407
+
408
+ def _demote_update(av: AvailabilityRow) -> dict[str, Any]:
409
+ """Undo a ZC promotion: back to the roster the person came from
410
+ (STAFF / AM) and to the plain shift or P2F-handler status without
411
+ the ``/ZC`` (M/ZC -> M, M/P2F/ZC -> M/P2F)."""
412
+ update: dict[str, Any] = {"role": av.origin_role or Role.STAFF}
413
+ lit = av.status.value
414
+ if lit.endswith("/ZC"):
415
+ lit = lit[: -len("/ZC")]
416
+ target: CrewStatus | None = None
417
+ try:
418
+ target = CrewStatus(lit)
419
+ except ValueError:
420
+ # e.g. M1/P2F has no status of its own: keep just the shift.
421
+ if lit.endswith("/P2F"):
422
+ try:
423
+ target = CrewStatus(lit[: -len("/P2F")])
424
+ except ValueError:
425
+ target = None
426
+ if target is not None:
427
+ update["status"] = target
428
+ update["raw_status"] = target.value
429
+ return update
430
+
431
+
432
+ def _apply_zc_list(state: AppState, d_day: date_t) -> int:
433
+ """Make D's Zone Controllers match the dashboard's list. Returns how
434
+ many rows on D are ZC because of the list.
435
+
436
+ * Everyone on the saved list who is rostered on a shift and
437
+ assignable on D is promoted. A name carried over from yesterday who
438
+ is off today is simply skipped, not an error.
439
+ * Everyone recorded as *removed* for D is taken back off ZC — this is
440
+ how a ``/ZC`` that is still in the roster file gets overridden.
441
+
442
+ Idempotent, like every applier here.
443
+ """
444
+ names, _source, _ = zc_store.effective(d_day)
445
+ removed = {_normalize_name(n) for n in zc_store.removed(d_day)}
446
+ d_iso = d_day.isoformat()
447
+ if not any(av.date.isoformat() == d_iso for av in state.availability):
448
+ return 0
449
+ # The list this plan actually runs with becomes that day's own list,
450
+ # so tomorrow's carry-over starts from it.
451
+ zc_store.freeze(d_day)
452
+ wanted = {_normalize_name(n) for n in names} - removed
453
+ applied = 0
454
+ for i, av in enumerate(state.availability):
455
+ if av.date.isoformat() != d_iso:
456
+ continue
457
+ key = _normalize_name(av.name)
458
+ if key in removed:
459
+ if av.role is Role.ZC or av.status in STATUS_IS_ZC:
460
+ state.availability[i] = av.model_copy(update=_demote_update(av))
461
+ continue
462
+ if key not in wanted:
463
+ continue
464
+ if not av.assignable or not av.current_shift:
465
+ continue
466
+ state.availability[i] = av.model_copy(update=_zc_update(av))
467
+ applied += 1
468
+ return applied