flight-alloc 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flight_alloc-0.0.1.dist-info/METADATA +11 -0
- flight_alloc-0.0.1.dist-info/RECORD +71 -0
- flight_alloc-0.0.1.dist-info/WHEEL +5 -0
- flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
- flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/allocator/__init__.py +6 -0
- src/allocator/caps.py +513 -0
- src/allocator/eligibility.py +302 -0
- src/allocator/greedy_fallback.py +106 -0
- src/allocator/invariants.py +124 -0
- src/allocator/p2f_priority.py +142 -0
- src/allocator/pair_validation.py +108 -0
- src/allocator/pairings.py +554 -0
- src/allocator/postpass_break.py +366 -0
- src/allocator/postpass_intl.py +483 -0
- src/allocator/postpass_p2f.py +723 -0
- src/allocator/postpass_rebalance.py +244 -0
- src/allocator/postsolve.py +549 -0
- src/allocator/recommender.py +348 -0
- src/allocator/windows.py +377 -0
- src/cli.py +41 -0
- src/config.py +168 -0
- src/greedy_fallback.py +102 -0
- src/io/__init__.py +0 -0
- src/io/export.py +270 -0
- src/io/export_xml.py +66 -0
- src/io/readers.py +1048 -0
- src/io/roster_library.py +89 -0
- src/plan.py +192 -0
- src/recommender_staffing.py +329 -0
- src/roster_store.py +159 -0
- src/schemas.py +1244 -0
- src/solver/__init__.py +0 -0
- src/solver/allocator_cpsat.py +1412 -0
- src/staged_overrides.py +468 -0
- src/state.py +494 -0
- src/step1_clean_flights.py +286 -0
- src/step2_extract_roster.py +316 -0
- src/step3_allocate_flights.py +1639 -0
- src/web/__init__.py +47 -0
- src/web/__main__.py +9 -0
- src/web/api/__init__.py +56 -0
- src/web/api/export.py +37 -0
- src/web/api/inputs.py +122 -0
- src/web/api/override_rows.py +138 -0
- src/web/api/pages.py +30 -0
- src/web/api/readbacks.py +72 -0
- src/web/api/recommender.py +72 -0
- src/web/api/runs.py +102 -0
- src/web/api/settings.py +201 -0
- src/web/api/zc.py +117 -0
- src/web/core/__init__.py +5 -0
- src/web/core/responses.py +91 -0
- src/web/core/router.py +167 -0
- src/web/core/static_files.py +85 -0
- src/web/overrides/__init__.py +66 -0
- src/web/overrides/airports.py +261 -0
- src/web/overrides/break_time.py +83 -0
- src/web/overrides/config_yaml.py +21 -0
- src/web/overrides/filters.py +187 -0
- src/web/overrides/rows.py +110 -0
- src/web/readback/__init__.py +67 -0
- src/web/readback/common.py +68 -0
- src/web/readback/dashboard.py +83 -0
- src/web/readback/planning.py +335 -0
- src/web/readback/session.py +158 -0
- src/web/readback/tables.py +163 -0
- src/web/runner.py +168 -0
- src/web/server.py +185 -0
- src/zc_store.py +221 -0
src/io/roster_library.py
ADDED
|
@@ -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
|