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
src/state.py ADDED
@@ -0,0 +1,494 @@
1
+ """In-memory application state — the single store the whole app runs on.
2
+
3
+ There is no workbook, no database and no per-date file tree. The three
4
+ input files arrive as uploads from the browser and are held as bytes;
5
+ every engine stage reads its inputs from this object and writes its
6
+ outputs back into it; the web layer serialises it to JSON for the
7
+ dashboard and to a one-off .xlsx for the Download button.
8
+
9
+ Lifetime is the server process. Restarting the server clears the
10
+ results, the overrides and the daily flight schedule. The two roster
11
+ files are the exception: they cover a whole period, so every upload is
12
+ also kept on disk (``roster_store``) and reloaded at start-up, and
13
+ several rosters can be held at once — the one that covers the
14
+ allocation date is the one that gets used.
15
+
16
+ Threading: ``ThreadingHTTPServer`` means several requests can touch the
17
+ state at once, so every mutation goes through ``STATE.lock``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import threading
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+ from collections.abc import Iterable
27
+ from datetime import date as date_t
28
+ from datetime import datetime, timedelta
29
+ from typing import Any
30
+
31
+ from . import roster_store
32
+ from .schemas import (
33
+ AllocationRow,
34
+ AvailabilityRow,
35
+ CleanFlightRow,
36
+ OpsClass,
37
+ Pair,
38
+ WarningRow,
39
+ WorkloadSummaryRow,
40
+ )
41
+
42
+ # The three files the operator uploads. Keys are the API's ``kind``
43
+ # path segment; the labels are what the UI shows next to each slot.
44
+ INPUT_KINDS: tuple[str, ...] = ("sv_portal", "staff_roster", "am_roster")
45
+ INPUT_LABELS: dict[str, str] = {
46
+ "sv_portal": "Flight schedule (SV portal export)",
47
+ "staff_roster": "Staff roster",
48
+ "am_roster": "AM List",
49
+ }
50
+
51
+ # How often each file actually changes, which is what decides where the
52
+ # UI puts it. The flight schedule is a fresh export every morning, so it
53
+ # stays on the dashboard where the day's work starts. The two rosters are
54
+ # published for a whole period — uploaded once and then left alone — so
55
+ # they live in the Setup sidebar, out of the daily path.
56
+ DAILY_INPUT_KINDS: tuple[str, ...] = ("sv_portal",)
57
+ SETUP_INPUT_KINDS: tuple[str, ...] = ("staff_roster", "am_roster")
58
+ INPUT_CADENCE: dict[str, str] = {
59
+ **{k: "daily" for k in DAILY_INPUT_KINDS},
60
+ **{k: "setup" for k in SETUP_INPUT_KINDS},
61
+ }
62
+
63
+ # Where the operator fixes a given file, quoted back in the
64
+ # "not uploaded yet" error so the message names a real place in the UI.
65
+ INPUT_HOMES: dict[str, str] = {
66
+ "daily": "the Flight schedule card on the dashboard",
67
+ "setup": "the Setup sidebar (Setup button, top right)",
68
+ }
69
+
70
+ # Column set for an override row. Previously the IN_Override sheet's
71
+ # header; now just the keys each override dict may carry. Order is the
72
+ # column order the drawer renders.
73
+ OVERRIDE_HEADERS: tuple[str, ...] = (
74
+ "type", "employee", "shift", "limit",
75
+ "flight", "std", "other_std",
76
+ "flt", "dep", "arr", "ops_class", "date",
77
+ "ac_type", "ac", "pax", "owner", "role",
78
+ "from_time", "to_time",
79
+ )
80
+
81
+ # What the drawer renders as editable columns. The rest stay in the row
82
+ # payload (so a Phase-R row keeps its flight/std) but are not worth a
83
+ # column each in a hand-edited table.
84
+ OVERRIDE_HEADERS_VISIBLE: tuple[str, ...] = (
85
+ "type", "employee", "shift", "limit",
86
+ )
87
+
88
+
89
+ @dataclass
90
+ class UploadedInput:
91
+ """One uploaded .xlsx, held in memory."""
92
+
93
+ kind: str
94
+ filename: str
95
+ data: bytes
96
+ uploaded_at: datetime = field(default_factory=datetime.now)
97
+
98
+ def as_json(self) -> dict[str, Any]:
99
+ return {
100
+ "kind": self.kind,
101
+ "label": INPUT_LABELS.get(self.kind, self.kind),
102
+ "cadence": INPUT_CADENCE.get(self.kind, "daily"),
103
+ "filename": self.filename,
104
+ "size_kb": round(len(self.data) / 1024, 1),
105
+ "uploaded_at": self.uploaded_at.isoformat(timespec="seconds"),
106
+ }
107
+
108
+
109
+ @dataclass
110
+ class RosterFile:
111
+ """One uploaded period roster. Unlike the daily schedule, several of
112
+ these can be held at once (Aug-Sep, then Sep-Oct, ...); ``dates`` is
113
+ every calendar date the workbook has a column for."""
114
+
115
+ id: str
116
+ kind: str
117
+ filename: str
118
+ data: bytes
119
+ dates: list[date_t]
120
+ uploaded_at: datetime = field(default_factory=datetime.now)
121
+
122
+ @property
123
+ def start(self) -> date_t | None:
124
+ return min(self.dates) if self.dates else None
125
+
126
+ @property
127
+ def end(self) -> date_t | None:
128
+ return max(self.dates) if self.dates else None
129
+
130
+ def as_json(self) -> dict[str, Any]:
131
+ return {
132
+ "id": self.id,
133
+ "filename": self.filename,
134
+ "size_kb": round(len(self.data) / 1024, 1),
135
+ "uploaded_at": self.uploaded_at.isoformat(timespec="seconds"),
136
+ "start": self.start.isoformat() if self.start else "",
137
+ "end": self.end.isoformat() if self.end else "",
138
+ "days": len(self.dates),
139
+ }
140
+
141
+
142
+ @dataclass
143
+ class AppState:
144
+ """Everything the app knows, for one operator, for one day."""
145
+
146
+ lock: threading.RLock = field(default_factory=threading.RLock)
147
+
148
+ # ---- inputs (uploaded from the dashboard and the Setup sidebar) ----
149
+ inputs: dict[str, UploadedInput] = field(default_factory=dict)
150
+ # Period rosters (SETUP_INPUT_KINDS), oldest upload first. Kept apart
151
+ # from ``inputs`` because there can be several per kind.
152
+ rosters: dict[str, list[RosterFile]] = field(default_factory=dict)
153
+ run_date: date_t | None = None
154
+
155
+ # ---- overrides (edited via the drawer) ----
156
+ overrides: list[dict[str, str]] = field(default_factory=list)
157
+
158
+ # ---- Plan stage outputs ----
159
+ cleaned: dict[OpsClass, list[CleanFlightRow]] = field(default_factory=dict)
160
+ availability: list[AvailabilityRow] = field(default_factory=list)
161
+ plan_summary: dict[str, Any] | None = None
162
+ plan_text: str = ""
163
+ staffing: list[dict[str, Any]] = field(default_factory=list)
164
+
165
+ # ---- Allocate stage outputs ----
166
+ allocations: list[AllocationRow] = field(default_factory=list)
167
+ pairs: list[Pair] = field(default_factory=list)
168
+ workload: list[WorkloadSummaryRow] = field(default_factory=list)
169
+ warnings: list[WarningRow] = field(default_factory=list)
170
+ unallocated: list[dict[str, Any]] = field(default_factory=list)
171
+ recommendations: dict[str, Any] = field(default_factory=dict)
172
+
173
+ # ---- run status ----
174
+ last_run: str = ""
175
+ mode: str = ""
176
+ solver_status: str = ""
177
+ duration_s: float | None = None
178
+
179
+ # Snapshot of the previous solve's {flight_key: staff_name}. Marks
180
+ # redistributed rows in the Allocations tab and warm-starts the
181
+ # next solve.
182
+ prev_staff_by_key: dict[str, str] = field(default_factory=dict)
183
+
184
+ # ---------------- inputs ----------------
185
+
186
+ def set_input(self, kind: str, filename: str, data: bytes) -> UploadedInput:
187
+ """Store a single-file input (the daily schedule). Rosters go
188
+ through ``add_roster`` because they accumulate."""
189
+ if kind not in INPUT_KINDS:
190
+ raise ValueError(f"unknown input kind {kind!r}")
191
+ if kind in SETUP_INPUT_KINDS:
192
+ raise ValueError(f"{kind!r} is a period roster; use add_roster()")
193
+ with self.lock:
194
+ up = UploadedInput(kind=kind, filename=filename, data=data)
195
+ self.inputs[kind] = up
196
+ return up
197
+
198
+ def clear_input(self, kind: str) -> None:
199
+ """Drop an input. For a roster kind that means every stored roster
200
+ of that kind, from memory and from disk."""
201
+ with self.lock:
202
+ if kind in SETUP_INPUT_KINDS:
203
+ for rf in self.rosters.pop(kind, []):
204
+ roster_store.delete(rf.id)
205
+ else:
206
+ self.inputs.pop(kind, None)
207
+
208
+ def input_bytes(self, kind: str) -> bytes:
209
+ if kind in SETUP_INPUT_KINDS:
210
+ files = self.rosters.get(kind) or []
211
+ if not files:
212
+ raise FileNotFoundError(self._missing_message(kind))
213
+ return files[-1].data
214
+ up = self.inputs.get(kind)
215
+ if up is None:
216
+ raise FileNotFoundError(self._missing_message(kind))
217
+ return up.data
218
+
219
+ @staticmethod
220
+ def _missing_message(kind: str) -> str:
221
+ home = INPUT_HOMES.get(INPUT_CADENCE.get(kind, "daily"), "the dashboard")
222
+ return (
223
+ f"{INPUT_LABELS.get(kind, kind)} has not been uploaded yet — "
224
+ f"upload it from {home}."
225
+ )
226
+
227
+ def _has_input(self, kind: str) -> bool:
228
+ if kind in SETUP_INPUT_KINDS:
229
+ return bool(self.rosters.get(kind))
230
+ return kind in self.inputs
231
+
232
+ def missing_inputs(self, cadence: str | None = None) -> list[str]:
233
+ """Kinds with no file yet. ``cadence`` narrows it to one group, so
234
+ the UI can tell "no schedule today" apart from "rosters were
235
+ never set up" — two different fixes, in two different places."""
236
+ return [
237
+ k for k in INPUT_KINDS
238
+ if not self._has_input(k)
239
+ and (cadence is None or INPUT_CADENCE.get(k) == cadence)
240
+ ]
241
+
242
+ def inputs_as_json(self) -> list[dict[str, Any]]:
243
+ out = []
244
+ for kind in INPUT_KINDS:
245
+ base = {
246
+ "kind": kind,
247
+ "label": INPUT_LABELS[kind],
248
+ "cadence": INPUT_CADENCE.get(kind, "daily"),
249
+ }
250
+ if kind in SETUP_INPUT_KINDS:
251
+ files = self.rosters.get(kind) or []
252
+ newest = files[-1] if files else None
253
+ out.append({
254
+ **base,
255
+ "filename": newest.filename if newest else "",
256
+ "size_kb": round(len(newest.data) / 1024, 1) if newest else 0,
257
+ "uploaded_at": (
258
+ newest.uploaded_at.isoformat(timespec="seconds")
259
+ if newest else ""
260
+ ),
261
+ # Newest first: the one on top is the one that wins
262
+ # wherever two rosters overlap.
263
+ "files": [f.as_json() for f in reversed(files)],
264
+ })
265
+ continue
266
+ up = self.inputs.get(kind)
267
+ if up is None:
268
+ out.append({**base, "filename": "", "size_kb": 0, "uploaded_at": ""})
269
+ else:
270
+ out.append(up.as_json())
271
+ return out
272
+
273
+ # ---------------- period rosters ----------------
274
+
275
+ def add_roster(
276
+ self, kind: str, filename: str, data: bytes, dates: Iterable[date_t],
277
+ *, persist: bool = True,
278
+ ) -> RosterFile:
279
+ """Keep an uploaded roster alongside the ones already held.
280
+
281
+ Re-uploading the same file (same name or same bytes) replaces
282
+ the earlier copy rather than piling up duplicates. A roster for
283
+ a different period is added; the rosters stay ordered by upload
284
+ time, and where two overlap the later upload wins.
285
+ """
286
+ if kind not in SETUP_INPUT_KINDS:
287
+ raise ValueError(f"{kind!r} is not a period roster")
288
+ digest = hashlib.sha256(data).hexdigest()
289
+ with self.lock:
290
+ kept: list[RosterFile] = []
291
+ for rf in self.rosters.get(kind, []):
292
+ same = (
293
+ rf.filename == filename
294
+ or hashlib.sha256(rf.data).hexdigest() == digest
295
+ )
296
+ if same:
297
+ roster_store.delete(rf.id)
298
+ else:
299
+ kept.append(rf)
300
+ now = datetime.now()
301
+ rf = RosterFile(
302
+ id=f"{now:%Y%m%dT%H%M%S}_{uuid.uuid4().hex[:6]}",
303
+ kind=kind, filename=filename, data=data,
304
+ dates=sorted(set(dates)), uploaded_at=now,
305
+ )
306
+ kept.append(rf)
307
+ self.rosters[kind] = kept
308
+ if persist:
309
+ roster_store.save(
310
+ file_id=rf.id, kind=kind, filename=filename,
311
+ uploaded_at=now, dates=rf.dates, data=data,
312
+ )
313
+ return rf
314
+
315
+ def remove_roster(self, kind: str, file_id: str) -> bool:
316
+ with self.lock:
317
+ files = self.rosters.get(kind, [])
318
+ keep = [f for f in files if f.id != file_id]
319
+ if len(keep) == len(files):
320
+ return False
321
+ self.rosters[kind] = keep
322
+ roster_store.delete(file_id)
323
+ return True
324
+
325
+ def load_persisted_rosters(self) -> int:
326
+ """Reload the rosters saved by earlier sessions. Called once at
327
+ server start; returns how many came back."""
328
+ loaded = 0
329
+ with self.lock:
330
+ for stored in roster_store.load_all():
331
+ if stored.kind not in SETUP_INPUT_KINDS:
332
+ continue
333
+ self.rosters.setdefault(stored.kind, []).append(RosterFile(
334
+ id=stored.id, kind=stored.kind, filename=stored.filename,
335
+ data=stored.data, dates=sorted(stored.dates),
336
+ uploaded_at=stored.uploaded_at,
337
+ ))
338
+ loaded += 1
339
+ for files in self.rosters.values():
340
+ files.sort(key=lambda f: f.uploaded_at)
341
+ return loaded
342
+
343
+ def roster_files_for(
344
+ self, kind: str, window: Iterable[date_t] | None = None,
345
+ ) -> list[RosterFile]:
346
+ """The stored rosters that matter for ``window`` (oldest upload
347
+ first, so a later upload overrides an earlier one on the merge).
348
+
349
+ Only rosters with a column in the window are returned. When none
350
+ has, every stored roster is returned instead: the reader then
351
+ loads real rows, finds no cell for the date, and the coverage
352
+ warning (W010 / W011) names the missing date — better than
353
+ silently planning with an empty roster.
354
+ """
355
+ with self.lock:
356
+ files = list(self.rosters.get(kind, []))
357
+ if not files or window is None:
358
+ return files
359
+ wanted = set(window)
360
+ hits = [f for f in files if wanted.intersection(f.dates)]
361
+ return hits or files
362
+
363
+ def roster_coverage(self, day: date_t) -> dict[str, Any]:
364
+ """Does each roster kind have a column for ``day`` and ``day+1``
365
+ (the two days the Plan stage reads)? Drives the notice in the
366
+ Setup sidebar so a stale roster is caught before Plan runs."""
367
+ next_day = day + timedelta(days=1)
368
+ out: dict[str, Any] = {"date": day.isoformat(), "notices": []}
369
+ with self.lock:
370
+ for kind in SETUP_INPUT_KINDS:
371
+ files = self.rosters.get(kind, [])
372
+ covered = {d for f in files for d in f.dates}
373
+ info = {
374
+ "loaded": bool(files),
375
+ "covers_date": day in covered,
376
+ "covers_next_day": next_day in covered,
377
+ }
378
+ out[kind] = info
379
+ label = INPUT_LABELS[kind]
380
+ if not files:
381
+ continue
382
+ if not info["covers_date"]:
383
+ out["notices"].append(
384
+ f"{label}: no roster covers {day:%d %b %Y}. "
385
+ "Upload the roster for that period."
386
+ )
387
+ elif not info["covers_next_day"]:
388
+ out["notices"].append(
389
+ f"{label}: the roster ends on {day:%d %b %Y}. "
390
+ "Upload the next one before planning the following day."
391
+ )
392
+ return out
393
+
394
+ # ---------------- overrides ----------------
395
+
396
+ def add_override(self, row: dict[str, str]) -> int:
397
+ """Append an override row. Returns its 0-based index."""
398
+ with self.lock:
399
+ self.overrides.append(_clean_override(row))
400
+ return len(self.overrides) - 1
401
+
402
+ def update_override(self, index: int, row: dict[str, str]) -> None:
403
+ with self.lock:
404
+ if not 0 <= index < len(self.overrides):
405
+ raise IndexError(f"no override row at index {index}")
406
+ self.overrides[index] = _clean_override(row)
407
+
408
+ def delete_override(self, index: int) -> None:
409
+ with self.lock:
410
+ if not 0 <= index < len(self.overrides):
411
+ raise IndexError(f"no override row at index {index}")
412
+ del self.overrides[index]
413
+
414
+ # ---------------- lifecycle ----------------
415
+
416
+ def reset_results(self) -> dict[str, int]:
417
+ """Clear every engine output but keep the uploads and the
418
+ override rows. This is what the Reset button does."""
419
+ with self.lock:
420
+ cleared = {
421
+ "cleaned": sum(len(v) for v in self.cleaned.values()),
422
+ "availability": len(self.availability),
423
+ "allocations": len(self.allocations),
424
+ "pairs": len(self.pairs),
425
+ "workload": len(self.workload),
426
+ "warnings": len(self.warnings),
427
+ "unallocated": len(self.unallocated),
428
+ }
429
+ self.cleaned = {}
430
+ self.availability = []
431
+ self.plan_summary = None
432
+ self.plan_text = ""
433
+ self.staffing = []
434
+ self.allocations = []
435
+ self.pairs = []
436
+ self.workload = []
437
+ self.warnings = []
438
+ self.unallocated = []
439
+ self.recommendations = {}
440
+ self.prev_staff_by_key = {}
441
+ self.last_run = ""
442
+ self.mode = ""
443
+ self.solver_status = ""
444
+ self.duration_s = None
445
+ return cleared
446
+
447
+ def stamp_run(
448
+ self, *, mode: str, solver_status: str, duration_s: float | None,
449
+ ) -> None:
450
+ with self.lock:
451
+ self.last_run = datetime.now().isoformat(timespec="seconds")
452
+ self.mode = mode
453
+ self.solver_status = solver_status
454
+ self.duration_s = duration_s
455
+
456
+ # ---------------- convenience views ----------------
457
+
458
+ def all_cleaned(self) -> list[CleanFlightRow]:
459
+ out: list[CleanFlightRow] = []
460
+ for rows in self.cleaned.values():
461
+ out.extend(rows)
462
+ return out
463
+
464
+ def cleaned_for(self, *ops: OpsClass) -> list[CleanFlightRow]:
465
+ out: list[CleanFlightRow] = []
466
+ for o in ops:
467
+ out.extend(self.cleaned.get(o, []))
468
+ return out
469
+
470
+ def allocatable_cleaned(self) -> list[CleanFlightRow]:
471
+ """Everything the solver is allowed to assign — every cleaned
472
+ flight except GULF, which is extract-only."""
473
+ return [
474
+ r for r in self.all_cleaned() if r.ops_class is not OpsClass.GULF
475
+ ]
476
+
477
+
478
+ def _clean_override(row: dict[str, str]) -> dict[str, str]:
479
+ """Normalise an override row: lowercased keys, stripped string
480
+ values, blanks dropped."""
481
+ out: dict[str, str] = {}
482
+ for k, v in row.items():
483
+ key = str(k).strip().lower()
484
+ if not key:
485
+ continue
486
+ s = "" if v is None else str(v).strip()
487
+ if s:
488
+ out[key] = s
489
+ return out
490
+
491
+
492
+ # The process-wide singleton. Imported directly by the engine stages and
493
+ # the web layer — there is exactly one operator per server.
494
+ STATE = AppState()