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,89 @@
1
+ """Read the stored period rosters as one merged roster.
2
+
3
+ The staff roster arrives about once a month and the periods don't line
4
+ up with calendar months (31 Aug - 27 Sep, then 28 Sep - 25 Oct, ...).
5
+ The app keeps every roster it has been given; this module turns "the
6
+ rosters on file" into the rows for one allocation date:
7
+
8
+ * pick the stored files that have a column for the date (and the day
9
+ after it — Plan reads D and D+1);
10
+ * read each with the normal wide-roster reader;
11
+ * merge people across files by employee id, dated cells from a later
12
+ upload overriding the same date from an earlier one.
13
+
14
+ So on the last day of a roster, D comes from the current file and D+1
15
+ comes from the next one, without the operator doing anything.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterable, Sequence
21
+ from datetime import date as date_t
22
+ from typing import TypeVar
23
+
24
+ from ..config import Config
25
+ from ..schemas import AMRosterRow, CrewRosterRow
26
+ from ..state import AppState
27
+ from .readers import read_am_roster, read_staff_roster, workbook_from_bytes
28
+
29
+ Row = TypeVar("Row", CrewRosterRow, AMRosterRow)
30
+
31
+
32
+ def read_roster_bytes(kind: str, data: bytes, config: Config) -> list:
33
+ """Parse one uploaded roster of ``kind`` into typed rows."""
34
+ reader = {"staff_roster": read_staff_roster, "am_roster": read_am_roster}[kind]
35
+ wb = workbook_from_bytes(data)
36
+ try:
37
+ return reader(wb, config)
38
+ finally:
39
+ wb.close()
40
+
41
+
42
+ def roster_dates(kind: str, data: bytes, config: Config) -> set[date_t]:
43
+ """Every date the workbook has a column for. Also validates the file
44
+ (missing ID column, duplicate ids, unknown layout) — the upload
45
+ endpoint calls this so a bad roster is rejected at the door with the
46
+ reader's own message instead of failing later inside Plan."""
47
+ dates: set[date_t] = set()
48
+ for row in read_roster_bytes(kind, data, config):
49
+ dates.update(row.status_by_date.keys())
50
+ return dates
51
+
52
+
53
+ def merge_rows(per_file_rows: Sequence[Sequence[Row]]) -> list[Row]:
54
+ """Merge rows from several rosters, oldest upload first.
55
+
56
+ Same employee id -> one row: identity fields (name, licence, role)
57
+ come from the newest file that lists the person, and per-date cells
58
+ are unioned with the newest file winning any date both cover.
59
+ """
60
+ merged: dict[str, Row] = {}
61
+ for rows in per_file_rows:
62
+ for row in rows:
63
+ prev = merged.get(row.employee_id)
64
+ if prev is None:
65
+ merged[row.employee_id] = row
66
+ continue
67
+ merged[row.employee_id] = row.model_copy(update={
68
+ "status_by_date": {**prev.status_by_date, **row.status_by_date},
69
+ "raw_status_by_date": {
70
+ **getattr(prev, "raw_status_by_date", {}),
71
+ **getattr(row, "raw_status_by_date", {}),
72
+ },
73
+ })
74
+ return list(merged.values())
75
+
76
+
77
+ def read_library(
78
+ state: AppState, kind: str, config: Config,
79
+ window: Iterable[date_t] | None = None,
80
+ ) -> list:
81
+ """Rows for ``kind`` from the stored rosters relevant to ``window``.
82
+
83
+ Raises FileNotFoundError (with the UI's own wording) when nothing has
84
+ been uploaded for ``kind``.
85
+ """
86
+ files = state.roster_files_for(kind, window)
87
+ if not files:
88
+ state.input_bytes(kind) # raises the standard message
89
+ return merge_rows([read_roster_bytes(kind, f.data, config) for f in files])
src/plan.py ADDED
@@ -0,0 +1,192 @@
1
+ """Plan stage — Step 1 + Step 2 only, plus the summary the assigner
2
+ reviews before allocating.
3
+
4
+ Flow:
5
+
6
+ 1. Step 1: clean the uploaded flight schedule into ``state.cleaned``.
7
+ 2. Step 2: extract the rosters into ``state.availability``.
8
+ 3. Compute the summary (this module's contribution):
9
+ - flights to be planned, per ops_class and per shift window
10
+ - staff on shift today, per shift
11
+ - recommendations: day/night headcount, P2F handlers (1 per 8
12
+ P2F flights per shift)
13
+ - the gap, if any
14
+ 4. Store it on ``state.plan_summary`` / ``state.plan_text`` and print
15
+ a readable version to the server console.
16
+
17
+ The assigner reviews the dashboard, stages any overrides in the drawer,
18
+ then clicks Allocate.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections import Counter
24
+ from datetime import date as date_t
25
+ from pathlib import Path
26
+
27
+ from .allocator.windows import SHIFT_NOMINAL_MIN, std_to_ops_day_minutes
28
+ from .config import load_config
29
+ from .schemas import OpsClass
30
+ from .state import AppState
31
+ from .step1_clean_flights import run as run_step1
32
+ from .step2_extract_roster import run as run_step2
33
+
34
+ # Operational ceilings used for recommendations.
35
+ P2F_FLIGHTS_PER_HANDLER = 8
36
+ TARGET_FLIGHTS_PER_STAFF_DAY = 22 # STAFF day-shift acceptable
37
+ TARGET_FLIGHTS_PER_STAFF_NIGHT = 19 # STAFF night-shift acceptable
38
+
39
+
40
+ def _cleaned_breakdown(state: AppState) -> dict[str, int]:
41
+ """Flight count per ops_class. Every class is present (0 when empty)
42
+ so the dashboard cards never have to guard for missing keys."""
43
+ out = {o.value: 0 for o in OpsClass}
44
+ for ops, rows in state.cleaned.items():
45
+ out[ops.value] = len(rows)
46
+ return out
47
+
48
+
49
+ def _p2f_per_shift(state: AppState, d_day: date_t) -> dict[str, int]:
50
+ """Count P2F flights by which M/A/N shift's nominal window holds them."""
51
+ counts: Counter[str] = Counter()
52
+ for row in state.cleaned.get(OpsClass.P2F, []):
53
+ std_min = std_to_ops_day_minutes(row.std, row.date, d_day)
54
+ for shift, (start, end) in SHIFT_NOMINAL_MIN.items():
55
+ if shift in ("M", "A", "N") and start <= std_min <= end:
56
+ counts[shift] += 1
57
+ break
58
+ return dict(counts)
59
+
60
+
61
+ def _staff_counts_today(state: AppState, d_day: date_t) -> dict[str, int]:
62
+ """Count assignable staff per shift on D."""
63
+ counts: Counter[str] = Counter()
64
+ d_iso = d_day.isoformat()
65
+ for av in state.availability:
66
+ if av.date.isoformat() != d_iso or not av.assignable:
67
+ continue
68
+ if not av.current_shift:
69
+ continue
70
+ counts[av.current_shift] += 1
71
+ return dict(counts)
72
+
73
+
74
+ def _format_summary(
75
+ flights: dict[str, int],
76
+ p2f_per_shift: dict[str, int],
77
+ staff: dict[str, int],
78
+ d_day: date_t,
79
+ ) -> tuple[str, dict[str, object]]:
80
+ """Build the plain-text summary and a JSON-friendly dict for the UI."""
81
+ target_per_day_staff = TARGET_FLIGHTS_PER_STAFF_DAY
82
+ target_per_night_staff = TARGET_FLIGHTS_PER_STAFF_NIGHT
83
+ day_staff = sum(staff.get(s, 0) for s in ("M", "A", "M1", "A1"))
84
+ night_staff = staff.get("N", 0)
85
+
86
+ def _ceil_div(a: int, b: int) -> int:
87
+ return -(-a // b) if b else 0
88
+
89
+ needed_day_staff = _ceil_div(flights["day"], target_per_day_staff)
90
+ needed_night_staff = _ceil_div(flights["night"], target_per_night_staff)
91
+ day_gap = max(0, needed_day_staff - day_staff)
92
+ night_gap = max(0, needed_night_staff - night_staff)
93
+
94
+ p2f_handlers_needed = sum(
95
+ _ceil_div(n, P2F_FLIGHTS_PER_HANDLER) for n in p2f_per_shift.values()
96
+ )
97
+
98
+ lines: list[str] = []
99
+ lines.append(f"=== Plan summary for D={d_day.isoformat()} ===\n")
100
+ lines.append("FLIGHTS TO BE PLANNED")
101
+ lines.append(f" Day ops: {flights['day']:5d}")
102
+ lines.append(f" Night ops:{flights['night']:5d}")
103
+ lines.append(f" P2F: {flights['p2f']:5d} "
104
+ f"per shift: M={p2f_per_shift.get('M', 0)} "
105
+ f"A={p2f_per_shift.get('A', 0)} "
106
+ f"N={p2f_per_shift.get('N', 0)}")
107
+ lines.append(f" Ferry: {flights['ferry']:5d}")
108
+ lines.append(f" Charter: {flights['charter']:5d}")
109
+ lines.append(f" Test: {flights['test']:5d}")
110
+ # GULF is extract-only — the solver never sees it, so it must not
111
+ # inflate the plannable total.
112
+ plannable_total = sum(v for k, v in flights.items() if k != "gulf")
113
+ lines.append(f" Total: {plannable_total:5d}")
114
+ lines.append(f" Gulf (extracted, NOT allocated): {flights['gulf']}")
115
+ lines.append("")
116
+ lines.append("STAFF ON SHIFT TODAY")
117
+ for s in ("M", "A", "N", "M1", "A1"):
118
+ lines.append(f" {s:3} : {staff.get(s, 0):3d}")
119
+ lines.append(f" Day staff (M+A+M1+A1): {day_staff}")
120
+ lines.append(f" Night staff (N): {night_staff}")
121
+ lines.append("")
122
+ lines.append("RECOMMENDATIONS")
123
+ lines.append(
124
+ f" Day staff needed (target {target_per_day_staff}/staff): "
125
+ f"{needed_day_staff} -> gap: {day_gap}"
126
+ )
127
+ lines.append(
128
+ f" Night staff needed (target {target_per_night_staff}/staff): "
129
+ f"{needed_night_staff} -> gap: {night_gap}"
130
+ )
131
+ lines.append(
132
+ f" P2F handlers needed (1 per {P2F_FLIGHTS_PER_HANDLER} flights): "
133
+ f"{p2f_handlers_needed}"
134
+ )
135
+ lines.append("")
136
+ lines.append("NEXT STEP")
137
+ if day_gap or night_gap:
138
+ lines.append(" Add staff for the gap shifts via the Override drawer,")
139
+ lines.append(" OR proceed to allocate (gap flights will be UNALLOCATED).")
140
+ else:
141
+ lines.append(" Staff coverage looks OK. Click Allocate.")
142
+
143
+ text = "\n".join(lines)
144
+ payload = {
145
+ "date": d_day.isoformat(),
146
+ "flights": flights,
147
+ "p2f_per_shift": p2f_per_shift,
148
+ "staff": staff,
149
+ "day_staff_total": day_staff,
150
+ "night_staff_total": night_staff,
151
+ "needed_day_staff": needed_day_staff,
152
+ "needed_night_staff": needed_night_staff,
153
+ "day_gap": day_gap,
154
+ "night_gap": night_gap,
155
+ "p2f_handlers_needed": p2f_handlers_needed,
156
+ }
157
+ return text, payload
158
+
159
+
160
+ def run(
161
+ state: AppState,
162
+ d_day: date_t,
163
+ config_path: Path | str = "configs/config.yml",
164
+ ) -> dict[str, object]:
165
+ """Run Step 1 + Step 2, then build the plan summary. Returns the
166
+ JSON-friendly payload (also stored on ``state.plan_summary``)."""
167
+ load_config(config_path) # fail fast if the config is unreadable
168
+ run_step1(state, d_day, config_path)
169
+ run_step2(state, d_day, config_path)
170
+
171
+ flights = _cleaned_breakdown(state)
172
+ p2f_per_shift = _p2f_per_shift(state, d_day)
173
+ staff = _staff_counts_today(state, d_day)
174
+ text, payload = _format_summary(flights, p2f_per_shift, staff, d_day)
175
+ print(text)
176
+
177
+ with state.lock:
178
+ state.run_date = d_day
179
+ state.plan_summary = payload
180
+ state.plan_text = text
181
+
182
+ # Staffing recommender: required = max(peak_floor, volume_floor,
183
+ # p2f_floor) per shift. Advisory — a failure here must not break Plan.
184
+ from . import recommender_staffing as _rs
185
+ try:
186
+ _rs.run_for_state(state, d_day)
187
+ except Exception as exc: # noqa: BLE001
188
+ print(f" WARNING: staffing recommender failed: {exc}")
189
+
190
+ state.stamp_run(mode="Plan", solver_status="(Plan only — no solve)",
191
+ duration_s=None)
192
+ return payload
@@ -0,0 +1,329 @@
1
+ """Staffing recommender — per-shift required-headcount calculator.
2
+
3
+ Per user direction 2026-05-22:
4
+
5
+ required(S) = max(
6
+ peak_floor(S), # peak-hour flights / 4 (H10 spacing)
7
+ volume_floor(S), # total flights / hard_cap (capacity ceiling)
8
+ p2f_floor(S), # 1 if any P2F flight in shift, else 0
9
+ )
10
+
11
+ No sick/surge buffer (staff handle that operationally). No mentor /
12
+ pre-planner floor (user direction: only P2F qualifies as a 'must-fill'
13
+ position for the recommender's purpose).
14
+
15
+ Output schema (see ``StaffingRecommendation`` below) is stored on
16
+ ``state.staffing`` and surfaced via /api/staffing for the dashboard.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import math
23
+ from collections import Counter
24
+ from collections.abc import Iterable
25
+ from dataclasses import asdict, dataclass
26
+ from datetime import date as date_t
27
+ from datetime import time as time_t
28
+ from datetime import timedelta
29
+ from pathlib import Path
30
+
31
+ from .schemas import Role, ShiftCode
32
+ from .state import AppState
33
+
34
+ # H10 hard spacing floor (minutes between consecutive same-staff flights).
35
+ _H10_SPACING_MIN = 15
36
+ # Max flights one staff can handle per hour, derived from H10.
37
+ _FLIGHTS_PER_STAFF_PER_HOUR = 60 // _H10_SPACING_MIN # = 4
38
+
39
+ # Pool-based primary STD bands. The recommender treats M/M1 and A/A1 as
40
+ # shared-coverage pools (since both shifts work overlapping hours and the
41
+ # engine allocates across both freely). Output rows = {MORNING, AFTERNOON,
42
+ # NIGHT}. The per-shift split inside each pool is reported as a context
43
+ # breakdown but the required-headcount math is computed pool-level.
44
+ # 2026-05-24 (user direction): AFTERNOON shrunk back to 13:00-21:00 so
45
+ # the 21:00-22:55 evening surge (~160 flights on a busy day) lands in
46
+ # NIGHT — A1 is still active there but N is the destination shift
47
+ # for those legs. Net: AFTERNOON required drops from ~51 to ~44 (in
48
+ # line with the 35-36 the assigner expects operationally), NIGHT is
49
+ # higher but A1 overlap helps cover the early hours.
50
+ _POOL_BANDS_MIN: dict[str, tuple[int, int]] = {
51
+ "MORNING": (5 * 60 + 5, 13 * 60),
52
+ "AFTERNOON": (13 * 60, 21 * 60),
53
+ "NIGHT": (21 * 60, 24 * 60 + 5 * 60 + 5),
54
+ }
55
+ _POOL_SHIFTS: dict[str, tuple[ShiftCode, ...]] = {
56
+ "MORNING": ("M", "M1"),
57
+ "AFTERNOON": ("A", "A1"),
58
+ "NIGHT": ("N",),
59
+ }
60
+ # 2026-05-24 (user direction): A1 stays in AFTERNOON pool but shares
61
+ # capacity with N during the 21:00-23:00 overlap. When computing
62
+ # NIGHT pool's required headcount, attribute some of the 21:00-23:00
63
+ # flight load to A1 (proportional to A1:N staff ratio), so we don't
64
+ # over-count N's burden. A1's pool membership and breakdown action
65
+ # remain on AFTERNOON only.
66
+ _A1_NIGHT_OVERLAP_BAND = (21 * 60, 23 * 60) # 21:00 - 22:59
67
+
68
+ # Per-(shift, role) hard caps from configs/shift_limits.json. We pull the
69
+ # STAFF cap since the volume floor is computed against the dominant role.
70
+ _SHIFT_LIMITS_PATH = Path(__file__).resolve().parents[1] / "configs" / "shift_limits.json"
71
+
72
+ _SHIFTS: tuple[ShiftCode, ...] = ("M", "M1", "A", "A1", "N")
73
+ _POOLS: tuple[str, ...] = ("MORNING", "AFTERNOON", "NIGHT")
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class StaffingRecommendation:
78
+ """One row of the recommendation — per shift pool, with a per-shift
79
+ breakdown so the operator knows exactly which shift to add to or
80
+ remove from."""
81
+
82
+ pool: str
83
+ shifts_in_pool: str
84
+ assigned_today: int
85
+ assigned_breakdown: str
86
+ target_breakdown: str # 2026-05-22: per-shift target headcount after applying recommendation, e.g. "M=28, M1=9"
87
+ breakdown_action: str # 2026-05-22: actionable per-shift delta, e.g. "REMOVE 6 from M, REMOVE 2 from M1"
88
+ flights_in_window: int
89
+ peak_hour: str
90
+ peak_hour_flights: int
91
+ has_p2f_flight: bool
92
+ target_per_staff: int
93
+ hard_cap_per_staff: int
94
+ peak_floor: int
95
+ volume_floor: int
96
+ p2f_floor: int
97
+ required: int
98
+ bottleneck: str
99
+ gap: int
100
+ action: str
101
+ implied_avg_per_staff: float
102
+
103
+
104
+ def _load_shift_limits() -> dict[str, dict[str, dict[str, int]]]:
105
+ """Read configs/shift_limits.json. Returns the ``shifts`` map."""
106
+ raw = json.loads(_SHIFT_LIMITS_PATH.read_text(encoding="utf-8"))
107
+ return raw["shifts"]
108
+
109
+
110
+ def _std_minutes(std: time_t) -> int:
111
+ """Convert a time-of-day to minutes since midnight."""
112
+ return std.hour * 60 + std.minute
113
+
114
+
115
+ def _flight_in_window(std: time_t, flight_date: date_t, d_day: date_t,
116
+ window_inner: tuple[int, int]) -> bool:
117
+ """True iff the flight's STD falls within the shift's inner window.
118
+
119
+ Window minutes are relative to D-day 00:00; N-shift wraps past midnight
120
+ (end > 24*60), so D+1 flights with STD < 05:00 are treated as ops-day
121
+ minute = std + 24*60.
122
+ """
123
+ start_min, end_min = window_inner
124
+ flt_min = _std_minutes(std)
125
+ if flight_date == d_day + timedelta(days=1):
126
+ flt_min += 24 * 60
127
+ return start_min <= flt_min < end_min
128
+
129
+
130
+ def _hour_label(std: time_t, flight_date: date_t, d_day: date_t) -> str:
131
+ """Bucket label for hourly histogram. D+1 flights display as 24+ to
132
+ keep N-shift contiguous when reading top-to-bottom."""
133
+ h = std.hour
134
+ if flight_date == d_day + timedelta(days=1):
135
+ h += 24
136
+ return f"{h % 24:02d}:00"
137
+
138
+
139
+ def compute_staffing(
140
+ flights: Iterable[tuple[date_t, time_t, str]],
141
+ assigned_by_shift: dict[str, int],
142
+ d_day: date_t,
143
+ ) -> list[StaffingRecommendation]:
144
+ """Compute required headcount per shift.
145
+
146
+ Args:
147
+ flights: iterable of (flight_date, std, ops_class) tuples. ops_class
148
+ is a lowercase string ('day', 'night', 'p2f',
149
+ 'ferry', 'test', 'charter', 'gulf'). Gulf flights are
150
+ filtered out before the math — extract-only.
151
+ assigned_by_shift: {'M': n, 'M1': n, ...} headcount from the roster
152
+ (STAFF + ZC, AM excluded).
153
+ d_day: the operating D-day.
154
+ """
155
+ limits = _load_shift_limits()
156
+ # Filter out Gulf (extract-only, never counted toward staffing).
157
+ flights = [(d, s, oc) for (d, s, oc) in flights if oc != "gulf"]
158
+
159
+ out: list[StaffingRecommendation] = []
160
+ for pool in _POOLS:
161
+ window = _POOL_BANDS_MIN[pool]
162
+ in_window: list[tuple[date_t, time_t, str]] = [
163
+ (d, s, oc) for (d, s, oc) in flights
164
+ if _flight_in_window(s, d, d_day, window)
165
+ ]
166
+ hourly: Counter[str] = Counter()
167
+ has_p2f = False
168
+ for d, s, oc in in_window:
169
+ hourly[_hour_label(s, d, d_day)] += 1
170
+ if oc == "p2f":
171
+ has_p2f = True
172
+ peak_hour = ""
173
+ peak_count = 0
174
+ if hourly:
175
+ peak_hour, peak_count = hourly.most_common(1)[0]
176
+
177
+ lead_shift = _POOL_SHIFTS[pool][0]
178
+ staff_cfg = limits[lead_shift]["STAFF"]
179
+ target = int(staff_cfg["min"])
180
+ hard_cap = int(staff_cfg["max"])
181
+
182
+ peak_floor = math.ceil(peak_count / _FLIGHTS_PER_STAFF_PER_HOUR) if peak_count else 0
183
+ # 2026-05-22: volume_floor uses TARGET load, not hard cap.
184
+ # Hard cap (24/22) is the absolute ceiling, but the solver
185
+ # cannot pack everyone to it — H10 spacing, INTL pin windows,
186
+ # P2F buffers, and shift-window edges create slack the math
187
+ # ignores. Using target ensures the recommendation stays
188
+ # operationally feasible. Earlier test (2026-05-22) confirmed
189
+ # cap-based volume_floor caused INFEASIBLE solves when applied.
190
+ effective_flight_count = len(in_window)
191
+ # 2026-05-24: for NIGHT pool, discount A1's helping capacity
192
+ # during the 21:00-23:00 overlap. A1 is still operationally
193
+ # active until 22:55 and shares the load with N before N has
194
+ # to fully cover. We attribute a proportional share of the
195
+ # overlap-window flights to A1 (using its target as effective
196
+ # per-staff capacity over the 2-hour window), so N's required
197
+ # headcount reflects the real night burden.
198
+ if pool == "NIGHT":
199
+ ovl_lo, ovl_hi = _A1_NIGHT_OVERLAP_BAND
200
+ overlap_count = 0
201
+ for d, s, oc in in_window:
202
+ lbl = _hour_label(s, d, d_day)
203
+ hh = int(lbl.split(":")[0])
204
+ # ops-day hour can wrap (00 = midnight)
205
+ std_min_hh = hh * 60
206
+ if ovl_lo <= std_min_hh < ovl_hi:
207
+ overlap_count += 1
208
+ a1_help = min(
209
+ overlap_count,
210
+ # A1 max contribution during the 2-hour overlap = headcount * target/8hr * 2hr
211
+ int(assigned_by_shift.get("A1", 0) * target / 8 * 2),
212
+ )
213
+ effective_flight_count = max(0, effective_flight_count - a1_help)
214
+ # Peak floor also relaxes if the peak hour falls inside the
215
+ # overlap band — split between A1 and N by staff ratio.
216
+ try:
217
+ peak_hh = int(peak_hour.split(":")[0]) if peak_hour else -1
218
+ except (ValueError, AttributeError):
219
+ peak_hh = -1
220
+ if ovl_lo <= peak_hh * 60 < ovl_hi:
221
+ n_count = assigned_by_shift.get("N", 0)
222
+ a1_count = assigned_by_shift.get("A1", 0)
223
+ total = n_count + a1_count
224
+ if total > 0:
225
+ n_share = peak_count * n_count / total
226
+ peak_floor = math.ceil(n_share / _FLIGHTS_PER_STAFF_PER_HOUR)
227
+ volume_floor = math.ceil(effective_flight_count / target) if effective_flight_count and target else 0
228
+ p2f_floor = 1 if has_p2f else 0
229
+
230
+ floors = {"peak": peak_floor, "volume": volume_floor, "p2f": p2f_floor}
231
+ required = max(floors.values())
232
+ bottleneck = max(floors, key=lambda k: floors[k])
233
+
234
+ pool_shifts = _POOL_SHIFTS[pool]
235
+ assigned_per_shift = {s: assigned_by_shift.get(s, 0) for s in pool_shifts}
236
+ assigned = sum(assigned_per_shift.values())
237
+ breakdown = ", ".join(f"{s}={assigned_per_shift[s]}" for s in pool_shifts)
238
+
239
+ gap = required - assigned
240
+ if gap > 0:
241
+ action = f"ADD {gap}"
242
+ elif gap < -2:
243
+ action = f"REMOVE {-gap}"
244
+ else:
245
+ action = "OK"
246
+ implied_avg = (len(in_window) / required) if required else 0.0
247
+
248
+ # 2026-05-22: per-shift breakdown of how to apply the recommendation.
249
+ # Distribute the target proportionally to the CURRENT shift ratio
250
+ # so existing M:M1 / A:A1 balance is preserved. For NIGHT (single
251
+ # shift) it's trivial.
252
+ target_per_shift: dict[str, int] = {}
253
+ if len(pool_shifts) == 1:
254
+ target_per_shift[pool_shifts[0]] = required
255
+ elif assigned == 0:
256
+ # Edge case: no one assigned yet — split evenly.
257
+ even = required // len(pool_shifts)
258
+ for s in pool_shifts:
259
+ target_per_shift[s] = even
260
+ target_per_shift[pool_shifts[0]] += required - even * len(pool_shifts)
261
+ else:
262
+ # Proportional to current headcount.
263
+ running = 0
264
+ for s in pool_shifts[:-1]:
265
+ t = round(required * assigned_per_shift[s] / assigned)
266
+ target_per_shift[s] = t
267
+ running += t
268
+ target_per_shift[pool_shifts[-1]] = max(0, required - running)
269
+ target_brk = ", ".join(f"{s}={target_per_shift[s]}" for s in pool_shifts)
270
+ # Build the actionable delta string.
271
+ deltas: list[str] = []
272
+ for s in pool_shifts:
273
+ d = target_per_shift[s] - assigned_per_shift[s]
274
+ if d > 0:
275
+ deltas.append(f"ADD {d} to {s}")
276
+ elif d < 0:
277
+ deltas.append(f"REMOVE {-d} from {s}")
278
+ breakdown_action = " · ".join(deltas) if deltas else "no change"
279
+
280
+ out.append(StaffingRecommendation(
281
+ pool=pool,
282
+ shifts_in_pool=" + ".join(pool_shifts),
283
+ assigned_today=assigned,
284
+ assigned_breakdown=breakdown,
285
+ target_breakdown=target_brk,
286
+ breakdown_action=breakdown_action,
287
+ flights_in_window=len(in_window),
288
+ peak_hour=peak_hour,
289
+ peak_hour_flights=peak_count,
290
+ has_p2f_flight=has_p2f,
291
+ target_per_staff=target,
292
+ hard_cap_per_staff=hard_cap,
293
+ peak_floor=peak_floor,
294
+ volume_floor=volume_floor,
295
+ p2f_floor=p2f_floor,
296
+ required=required,
297
+ bottleneck=bottleneck,
298
+ gap=gap,
299
+ action=action,
300
+ implied_avg_per_staff=round(implied_avg, 1),
301
+ ))
302
+ return out
303
+
304
+
305
+ # ---------- state glue ----------
306
+
307
+
308
+ def run_for_state(state: AppState, d_day: date_t) -> list[dict]:
309
+ """Compute the recommendation from current state, store it on
310
+ ``state.staffing`` and return the JSON-friendly rows."""
311
+ flights = [
312
+ (r.date, r.std, r.ops_class.value) for r in state.allocatable_cleaned()
313
+ ]
314
+ # Assigned by shift: STAFF + ZC only (AM excluded — they don't fly).
315
+ assigned: dict[str, int] = {s: 0 for s in _SHIFTS}
316
+ d_iso = d_day.isoformat()
317
+ for av in state.availability:
318
+ if av.date.isoformat() != d_iso or not av.assignable:
319
+ continue
320
+ if av.role is Role.AM:
321
+ continue
322
+ if av.current_shift in assigned:
323
+ assigned[av.current_shift] += 1
324
+
325
+ rows = compute_staffing(flights, assigned, d_day)
326
+ out = [asdict(r) for r in rows]
327
+ with state.lock:
328
+ state.staffing = out
329
+ return out