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/allocator/windows.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Shift-window tables and time helpers for Step 4.
|
|
2
|
+
|
|
3
|
+
The numbers below are the single source of truth for H7 (tail extensions),
|
|
4
|
+
H9 (ZC report buffers), H10 (15-min spacing, 30 min domestic→INTL and
|
|
5
|
+
P2F↔P2F),
|
|
6
|
+
H12 (international ±60min),
|
|
7
|
+
and H15 (awkward-window routing). They mirror REF_Constraints in the
|
|
8
|
+
unified workbook — keep them in lockstep when either side changes.
|
|
9
|
+
|
|
10
|
+
Time arithmetic uses "ops-day minutes": minutes from 00:00 of the ops day
|
|
11
|
+
(D). A flight at STD 02:00 on calendar D+1 (the N-shift continuation) has
|
|
12
|
+
ops_day_minutes = 120 + 1440 = 1560, which is inside N's window
|
|
13
|
+
[1230, 1740] = [20:30 D, 05:00 D+1]. This convention sidesteps midnight-
|
|
14
|
+
crossing edge cases everywhere downstream.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from datetime import date as date_t
|
|
21
|
+
from datetime import time as time_t
|
|
22
|
+
|
|
23
|
+
from ..schemas import OpsClass, ShiftCode
|
|
24
|
+
|
|
25
|
+
# ---------- shift hours (nominal) ----------
|
|
26
|
+
# (start_offset, end_offset) in minutes from 00:00 of the ops day.
|
|
27
|
+
# N's end is 24*60 + 5*60 = 1740 because it crosses midnight into D+1.
|
|
28
|
+
|
|
29
|
+
SHIFT_NOMINAL_MIN: dict[ShiftCode, tuple[int, int]] = {
|
|
30
|
+
"M": (4 * 60, 12 * 60 + 30), # 04:00 - 12:30
|
|
31
|
+
"M1": (6 * 60, 14 * 60 + 30), # 06:00 - 14:30
|
|
32
|
+
"A": (12 * 60 + 30, 21 * 60), # 12:30 - 21:00
|
|
33
|
+
"A1": (14 * 60 + 30, 23 * 60), # 14:30 - 23:00
|
|
34
|
+
"N": (20 * 60 + 30, 24 * 60 + 5 * 60), # 20:30 - 05:00 D+1
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Handover overlap (round-3 update): for STDs in (nominal_end, nominal_end +
|
|
38
|
+
# HANDOVER_WINDOW_MIN], BOTH the prev shift AND the next shift are eligible
|
|
39
|
+
# (soft, not hard). The solver picks via S1 count balance + S5 handover
|
|
40
|
+
# preference. Postsolve uses is_in_tail_ext (same range) to decide whether
|
|
41
|
+
# RELIEVED_BY annotation applies.
|
|
42
|
+
HANDOVER_WINDOW_MIN = 30
|
|
43
|
+
|
|
44
|
+
# ---------- STD distribution windows (user direction 2026-05-12) ----------
|
|
45
|
+
# These windows constrain which shifts a flight's STD can be ALLOCATED to.
|
|
46
|
+
# Distinct from SHIFT_NOMINAL_MIN (which is staff working hours): a shift
|
|
47
|
+
# only takes flights whose STD lies inside this distribution band.
|
|
48
|
+
#
|
|
49
|
+
# M / M1 / N — hard bounds [inner_lo, inner_hi].
|
|
50
|
+
# A / A1 — hard inner band; soft outer extension (extra ~5 min beyond
|
|
51
|
+
# inner_hi). Outer-band assignments are allowed but the solver
|
|
52
|
+
# pays a penalty to minimize their use (S_outer term).
|
|
53
|
+
#
|
|
54
|
+
# All values are ops-day minutes. N wraps midnight: end=29:00 = 05:00 D+1.
|
|
55
|
+
SHIFT_STD_WINDOW_INNER: dict[ShiftCode, tuple[int, int]] = {
|
|
56
|
+
"M": (5 * 60 + 5, 12 * 60 + 55), # 05:05 - 12:55
|
|
57
|
+
"M1": (7 * 60 + 5, 14 * 60 + 55), # 07:05 - 14:55
|
|
58
|
+
# Patch 2026-05-14 (post-Phase-4 unallocated fix): A inner_lo was
|
|
59
|
+
# 13:10, leaving a 15-min coverage gap [12:56, 13:09] where only M1
|
|
60
|
+
# (12 staff) could absorb the rush. 9 unallocated 13:00 flights on
|
|
61
|
+
# the 05-03 dataset traced to M1 saturation. Pulling A's left edge
|
|
62
|
+
# to 13:00 lets the much larger A pool (32 STAFF) absorb the rush.
|
|
63
|
+
"A": (13 * 60, 21 * 60 + 5), # 13:00 - 21:05
|
|
64
|
+
"A1": (15 * 60 + 10, 22 * 60 + 55), # 15:10 - 22:55
|
|
65
|
+
# Same patch on the A->N side: N inner_lo was 21:20, leaving a gap
|
|
66
|
+
# [21:06, 21:19] where only A1 (12 staff) could absorb. Pulling N's
|
|
67
|
+
# left edge to 21:05 lets N share the load.
|
|
68
|
+
"N": (21 * 60 + 5, 24 * 60 + 5 * 60), # 21:05 - 05:00 D+1
|
|
69
|
+
}
|
|
70
|
+
# Soft outer end-extension for A and A1 only. The window grows from
|
|
71
|
+
# inner_hi to outer_hi; entries here override that growth for the listed
|
|
72
|
+
# shifts. Other shifts have outer = inner (no extension).
|
|
73
|
+
SHIFT_STD_WINDOW_OUTER_END: dict[ShiftCode, int] = {
|
|
74
|
+
"A": 21 * 60 + 10, # +5 min to 21:10
|
|
75
|
+
"A1": 23 * 60, # +5 min to 23:00
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# ---------- shift hours with handover window (H7, round-3 update) ----------
|
|
79
|
+
# Computed from SHIFT_NOMINAL_MIN + HANDOVER_WINDOW_MIN. Uniform 30 min
|
|
80
|
+
# across all shifts — replaces the per-shift D6/r1/r2-1 values.
|
|
81
|
+
|
|
82
|
+
SHIFT_TAIL_END_MIN: dict[ShiftCode, int] = {
|
|
83
|
+
shift: nominal_end + HANDOVER_WINDOW_MIN
|
|
84
|
+
for shift, (_, nominal_end) in SHIFT_NOMINAL_MIN.items()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# ---------- ZC report buffers (H9) ----------
|
|
88
|
+
# Half-open intervals [start, end) in ops-day minutes. STDs in either buffer
|
|
89
|
+
# are excluded for ZCs of that shift.
|
|
90
|
+
|
|
91
|
+
ZC_BUFFER_START_MIN: dict[ShiftCode, tuple[int, int]] = {
|
|
92
|
+
"M": (4 * 60, 5 * 60 + 30), # 04:00 - 05:30
|
|
93
|
+
"A": (12 * 60 + 30, 14 * 60), # 12:30 - 14:00
|
|
94
|
+
"M1": (6 * 60, 7 * 60 + 30), # 06:00 - 07:30
|
|
95
|
+
"A1": (14 * 60 + 30, 16 * 60), # 14:30 - 16:00
|
|
96
|
+
"N": (20 * 60 + 30, 22 * 60), # 20:30 - 22:00
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ZC_BUFFER_END_MIN: dict[ShiftCode, tuple[int, int]] = {
|
|
100
|
+
"M": (11 * 60, 12 * 60 + 30), # 11:00 - 12:30
|
|
101
|
+
"A": (19 * 60 + 30, 21 * 60), # 19:30 - 21:00
|
|
102
|
+
"M1": (13 * 60, 14 * 60 + 30), # 13:00 - 14:30
|
|
103
|
+
"A1": (21 * 60 + 30, 23 * 60), # 21:30 - 23:00
|
|
104
|
+
"N": (24 * 60 + 3 * 60 + 30, 24 * 60 + 5 * 60), # 03:30 - 05:00 D+1
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# ---------- H15 awkward-window routing ----------
|
|
108
|
+
# Keyed by ops-day minutes; value = tuple of shifts eligible at that STD.
|
|
109
|
+
# Only applies to flights on the ops day itself (not D+1 N-tail flights —
|
|
110
|
+
# those belong to tomorrow's M shift).
|
|
111
|
+
|
|
112
|
+
AWKWARD_ROUTING_MIN: dict[int, tuple[ShiftCode, ...]] = {
|
|
113
|
+
# M -> A boundary
|
|
114
|
+
12 * 60 + 55: ("M",),
|
|
115
|
+
13 * 60: ("M", "M1"),
|
|
116
|
+
13 * 60 + 5: ("M1",),
|
|
117
|
+
13 * 60 + 10: ("M1", "A"),
|
|
118
|
+
13 * 60 + 15: ("A",),
|
|
119
|
+
# M1 -> A1 boundary (per D7 sample evidence: A absorbs entirely)
|
|
120
|
+
14 * 60 + 55: ("A",),
|
|
121
|
+
15 * 60: ("A",),
|
|
122
|
+
15 * 60 + 5: ("A",),
|
|
123
|
+
15 * 60 + 10: ("A",),
|
|
124
|
+
15 * 60 + 15: ("A",),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# ---------- buffer constants ----------
|
|
128
|
+
# H10 (post-round-3 update per user direction 2026-05-10): hard floor
|
|
129
|
+
# raised from 10 → 15 min (no two flights for one handler within 15 min);
|
|
130
|
+
# soft warning band raised from 15 → 20 min so the assigner sees a flag
|
|
131
|
+
# whenever spacing is between 15 and 20 min.
|
|
132
|
+
SPACING_HARD_MIN = 15
|
|
133
|
+
SPACING_SOFT_WARN_MIN = 20
|
|
134
|
+
|
|
135
|
+
# H10 (user direction 2026-09-26): the 15-min floor is strict — the
|
|
136
|
+
# 10-min rush-band relaxation of 2026-05-15 is gone — and a domestic
|
|
137
|
+
# flight followed by an international one needs 30 min, so the handler
|
|
138
|
+
# has time to prepare for the INTL departure. INTL→domestic and
|
|
139
|
+
# INTL→INTL keep the plain 15-min floor.
|
|
140
|
+
SPACING_DOM_TO_INTL_MIN = 30
|
|
141
|
+
# H10 (user direction 2026-09-26): one handler can run two P2F flights
|
|
142
|
+
# only when they are at least 30 min apart.
|
|
143
|
+
SPACING_P2F_PAIR_MIN = 30
|
|
144
|
+
#: Widest gap any pair of flights can need — callers scanning a sorted
|
|
145
|
+
#: schedule can stop once two flights are this far apart.
|
|
146
|
+
SPACING_MAX_MIN = max(SPACING_HARD_MIN, SPACING_DOM_TO_INTL_MIN, SPACING_P2F_PAIR_MIN)
|
|
147
|
+
|
|
148
|
+
#: A flight as the H10 rules see it:
|
|
149
|
+
#: ``(STD in ops-day minutes, is_international, is_p2f)``.
|
|
150
|
+
SpacingKey = tuple[int, bool, bool]
|
|
151
|
+
|
|
152
|
+
# Operationally-dense rush windows (user direction 2026-05-15). They
|
|
153
|
+
# used to relax H10 to 10 min; now they only drive the solver's
|
|
154
|
+
# "prefer a handler whose shift isn't starting or ending" tiebreaker.
|
|
155
|
+
# Each entry is (start_min, end_min) in ops-day minutes, inclusive.
|
|
156
|
+
# Derived from the manual 06-May-2026 allocation's 89 H10 violations
|
|
157
|
+
# (88 of them fall in these six windows).
|
|
158
|
+
RUSH_BANDS_MIN: tuple[tuple[int, int], ...] = (
|
|
159
|
+
(5 * 60, 5 * 60 + 30), # 05:00 - 05:30
|
|
160
|
+
(6 * 60 + 55, 7 * 60 + 30), # 06:55 - 07:30
|
|
161
|
+
(12 * 60 + 55, 13 * 60 + 15), # 12:55 - 13:15
|
|
162
|
+
(17 * 60, 17 * 60 + 15), # 17:00 - 17:15
|
|
163
|
+
(20 * 60, 22 * 60), # 20:00 - 22:00
|
|
164
|
+
(23 * 60 + 15, 23 * 60 + 35), # 23:15 - 23:35
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_in_rush_band(
|
|
169
|
+
std: time_t, flight_date: date_t, ops_day: date_t,
|
|
170
|
+
) -> bool:
|
|
171
|
+
"""True if a flight's STD lies in one of the rush bands (inclusive
|
|
172
|
+
on both ends)."""
|
|
173
|
+
minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
|
|
174
|
+
return any(lo <= minutes <= hi for lo, hi in RUSH_BANDS_MIN)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def spacing_key(flight, ops_day: date_t) -> SpacingKey:
|
|
178
|
+
"""``flight``'s :data:`SpacingKey` — anything with ``std``,
|
|
179
|
+
``date``, ``is_international`` and ``ops_class`` will do."""
|
|
180
|
+
return (
|
|
181
|
+
std_to_ops_day_minutes(flight.std, flight.date, ops_day),
|
|
182
|
+
bool(flight.is_international),
|
|
183
|
+
flight.ops_class == OpsClass.P2F,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def required_spacing_min(a: SpacingKey, b: SpacingKey) -> int:
|
|
188
|
+
"""Required min gap between two same-staff flights, in either
|
|
189
|
+
order: 30 when the earlier one is domestic and the later one
|
|
190
|
+
international, 30 when both are P2F, else 15."""
|
|
191
|
+
earlier, later = (a, b) if a[0] <= b[0] else (b, a)
|
|
192
|
+
gap = SPACING_HARD_MIN
|
|
193
|
+
if later[1] and not earlier[1]:
|
|
194
|
+
gap = max(gap, SPACING_DOM_TO_INTL_MIN)
|
|
195
|
+
if earlier[2] and later[2]:
|
|
196
|
+
gap = max(gap, SPACING_P2F_PAIR_MIN)
|
|
197
|
+
return gap
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def spacing_clear(key: SpacingKey, others: Iterable[SpacingKey]) -> bool:
|
|
201
|
+
"""True when a flight keeps the H10 gap to every one of ``others``
|
|
202
|
+
— the same staff member's other flights."""
|
|
203
|
+
return all(
|
|
204
|
+
abs(key[0] - other[0]) >= required_spacing_min(key, other)
|
|
205
|
+
for other in others
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def shift_boundary_within_30min(
|
|
210
|
+
shift: ShiftCode, std: time_t, flight_date: date_t, ops_day: date_t,
|
|
211
|
+
) -> bool:
|
|
212
|
+
"""True iff ``shift``'s nominal START or END lies within ±30 min of
|
|
213
|
+
the flight's STD.
|
|
214
|
+
|
|
215
|
+
Used by the band-targeted "prefer stable shift" soft preference
|
|
216
|
+
(user direction 2026-05-15): within the rush bands, the
|
|
217
|
+
solver should prefer to assign a flight to a staff whose shift is
|
|
218
|
+
NOT in transition. A shift is "in transition" at STD T when its
|
|
219
|
+
nominal start or end is within 30 minutes of T.
|
|
220
|
+
|
|
221
|
+
Handles N's midnight wrap: N's nominal end is at ops-day-min 1740
|
|
222
|
+
(= 05:00 D+1). A D-day flight at 04:50 (ops-min 290) is far from
|
|
223
|
+
that 1740 in the linear sense, but std_to_ops_day_minutes adds
|
|
224
|
+
1440 when ``flight_date == ops_day + 1``, so the comparison stays
|
|
225
|
+
correct on either side of midnight.
|
|
226
|
+
"""
|
|
227
|
+
nominal = SHIFT_NOMINAL_MIN.get(shift)
|
|
228
|
+
if nominal is None:
|
|
229
|
+
return False
|
|
230
|
+
start_min, end_min = nominal
|
|
231
|
+
minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
|
|
232
|
+
return abs(minutes - start_min) <= 30 or abs(minutes - end_min) <= 30
|
|
233
|
+
|
|
234
|
+
# H4 P2F buffer (user direction 2026-09-26): a P2F handler takes no
|
|
235
|
+
# normal flights from 2 hrs before to 1 hr after each of their P2F
|
|
236
|
+
# flights' STD, both ends inclusive. Replaces the round-3 D-3hrs ±15
|
|
237
|
+
# hard block and the D-1hr / D+20min ±15 max-1 windows. Enforced as an
|
|
238
|
+
# eligibility filter (eligibility.py F6), so the solver and every
|
|
239
|
+
# post-pass that checks eligibility respect it.
|
|
240
|
+
P2F_BLOCK_BEFORE_MIN = 120
|
|
241
|
+
P2F_BLOCK_AFTER_MIN = 60
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def in_p2f_block(std_min: int, p2f_std_mins: Iterable[int]) -> bool:
|
|
245
|
+
"""True when a normal flight at ``std_min`` falls inside the
|
|
246
|
+
no-normal-flight window of any of a handler's P2F flights
|
|
247
|
+
(``p2f_std_mins``; all in ops-day minutes)."""
|
|
248
|
+
return any(
|
|
249
|
+
p - P2F_BLOCK_BEFORE_MIN <= std_min <= p + P2F_BLOCK_AFTER_MIN
|
|
250
|
+
for p in p2f_std_mins
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# International ±60 min from shift start/end (H12, correction r2-3).
|
|
254
|
+
INTL_SHIFT_EDGE_MIN = 60
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------- time-arithmetic helpers ----------
|
|
258
|
+
|
|
259
|
+
def std_to_ops_day_minutes(std: time_t, flight_date: date_t, ops_day: date_t) -> int:
|
|
260
|
+
"""Convert (calendar date, STD) to minutes-from-00:00-of-ops-day.
|
|
261
|
+
|
|
262
|
+
A 02:00 STD on calendar (ops_day + 1) returns 1560, which N's window
|
|
263
|
+
[1230, 1740] correctly contains.
|
|
264
|
+
"""
|
|
265
|
+
base = std.hour * 60 + std.minute
|
|
266
|
+
delta_days = (flight_date - ops_day).days
|
|
267
|
+
return base + delta_days * 1440
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def is_in_shift(
|
|
271
|
+
std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
|
|
272
|
+
) -> bool:
|
|
273
|
+
"""True if (flight_date, std) falls within ``shift``'s window+tail-ext on
|
|
274
|
+
``ops_day``. Inclusive of both endpoints — a 13:00 STD is still M's
|
|
275
|
+
(M's tail ends at 13:00 sharp, so STD=13:00 is the last allowed)."""
|
|
276
|
+
minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
|
|
277
|
+
start = SHIFT_NOMINAL_MIN[shift][0]
|
|
278
|
+
end = SHIFT_TAIL_END_MIN[shift]
|
|
279
|
+
return start <= minutes <= end
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def is_in_zc_buffer(
|
|
283
|
+
std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
|
|
284
|
+
) -> bool:
|
|
285
|
+
"""True if STD is in the ZC's start-of-shift OR end-of-shift report
|
|
286
|
+
buffer for ``shift``. Half-open [start, end), so the buffer's upper
|
|
287
|
+
edge is the first eligible STD."""
|
|
288
|
+
minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
|
|
289
|
+
s1, e1 = ZC_BUFFER_START_MIN[shift]
|
|
290
|
+
s2, e2 = ZC_BUFFER_END_MIN[shift]
|
|
291
|
+
return (s1 <= minutes < e1) or (s2 <= minutes < e2)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def awkward_eligible_shifts(
|
|
295
|
+
std: time_t, flight_date: date_t, ops_day: date_t,
|
|
296
|
+
) -> tuple[ShiftCode, ...] | None:
|
|
297
|
+
"""Return the tuple of shifts allowed at this STD per H15, OR None if
|
|
298
|
+
the STD is not in any awkward window. D+1 flights never trigger H15
|
|
299
|
+
(next-day's M owns them, not today's solver)."""
|
|
300
|
+
if flight_date != ops_day:
|
|
301
|
+
return None
|
|
302
|
+
minutes = std.hour * 60 + std.minute
|
|
303
|
+
return AWKWARD_ROUTING_MIN.get(minutes)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def is_in_tail_ext(
|
|
307
|
+
std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
|
|
308
|
+
) -> bool:
|
|
309
|
+
"""True if STD is in the tail-extension portion of ``shift`` —
|
|
310
|
+
i.e., past the nominal end but within the configured tail end.
|
|
311
|
+
|
|
312
|
+
Tail-ext flights are still owned by ``shift`` (STAFF column points
|
|
313
|
+
to the prev-shift handler), but the next shift's paired person does
|
|
314
|
+
the post-airborne (D+20) work and is recorded as RELIEVED_BY.
|
|
315
|
+
Flights at or before nominal end are NOT relieved (the prev staff
|
|
316
|
+
handles their own D+20 within shift).
|
|
317
|
+
|
|
318
|
+
Note: the uniform 30-min handover window is applied to N as well, so
|
|
319
|
+
05:01-05:30 on D+1 return True for N. Earlier comments claimed N has
|
|
320
|
+
no tail extension; postsolve is unaffected because it has no relief
|
|
321
|
+
shift for N. Behaviour is pinned in tests/test_windows.py.
|
|
322
|
+
"""
|
|
323
|
+
minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
|
|
324
|
+
nominal_end = SHIFT_NOMINAL_MIN[shift][1]
|
|
325
|
+
tail_end = SHIFT_TAIL_END_MIN[shift]
|
|
326
|
+
return nominal_end < minutes <= tail_end
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def is_within_intl_shift_edge(
|
|
330
|
+
std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
|
|
331
|
+
) -> bool:
|
|
332
|
+
"""H12 (correction r2-3): for international flights, exclude staff
|
|
333
|
+
whose STD is within 60 min of their shift's nominal start or end.
|
|
334
|
+
|
|
335
|
+
Strict-less-than / strict-greater-than per the correction's literal
|
|
336
|
+
wording: STD < shift_start + 60min OR STD > shift_end - 60min.
|
|
337
|
+
|
|
338
|
+
Retired 2026-05-12 (F9 dropped in eligibility.py). Kept here for
|
|
339
|
+
history / tests; not called by the live pipeline.
|
|
340
|
+
"""
|
|
341
|
+
minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
|
|
342
|
+
start, end = SHIFT_NOMINAL_MIN[shift]
|
|
343
|
+
return minutes < start + INTL_SHIFT_EDGE_MIN or minutes > end - INTL_SHIFT_EDGE_MIN
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def is_in_std_window(
|
|
347
|
+
std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
|
|
348
|
+
) -> bool:
|
|
349
|
+
"""True if the flight's STD lies in ``shift``'s STD distribution
|
|
350
|
+
window (inner OR outer band; outer applies to A and A1 only).
|
|
351
|
+
|
|
352
|
+
Per user direction 2026-05-12 §1: a flight is eligible for a shift
|
|
353
|
+
only when its STD falls in that shift's STD window. This is a
|
|
354
|
+
NARROWER check than ``is_in_shift`` (which spans the full shift
|
|
355
|
+
hours + handover overlap). Replaces F5 in eligibility.py.
|
|
356
|
+
"""
|
|
357
|
+
minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
|
|
358
|
+
lo, hi = SHIFT_STD_WINDOW_INNER[shift]
|
|
359
|
+
outer_hi = SHIFT_STD_WINDOW_OUTER_END.get(shift, hi)
|
|
360
|
+
return lo <= minutes <= outer_hi
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def is_in_std_outer_band(
|
|
364
|
+
std: time_t, flight_date: date_t, ops_day: date_t, shift: ShiftCode,
|
|
365
|
+
) -> bool:
|
|
366
|
+
"""True if STD lies in the soft outer band (inner_hi, outer_hi].
|
|
367
|
+
|
|
368
|
+
Used by the solver to penalize outer-band assignments via a soft
|
|
369
|
+
objective term — A and A1 only have non-empty bands. Other shifts
|
|
370
|
+
return False (no outer band defined).
|
|
371
|
+
"""
|
|
372
|
+
if shift not in SHIFT_STD_WINDOW_OUTER_END:
|
|
373
|
+
return False
|
|
374
|
+
minutes = std_to_ops_day_minutes(std, flight_date, ops_day)
|
|
375
|
+
_, inner_hi = SHIFT_STD_WINDOW_INNER[shift]
|
|
376
|
+
outer_hi = SHIFT_STD_WINDOW_OUTER_END[shift]
|
|
377
|
+
return inner_hi < minutes <= outer_hi
|
src/cli.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Launcher for the Flight Allocation console.
|
|
2
|
+
|
|
3
|
+
State lives in the server process for the length of a session, so there
|
|
4
|
+
is nothing for a per-stage CLI command to read or write — the stages are
|
|
5
|
+
driven from the dashboard. This module exists to start the UI.
|
|
6
|
+
|
|
7
|
+
python -m src.cli # launch the console
|
|
8
|
+
python -m src.cli --port 9000 --no-browser
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from .web.server import DEFAULT_HOST, DEFAULT_PORT, serve
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
p = argparse.ArgumentParser(
|
|
21
|
+
prog="flight-alloc",
|
|
22
|
+
description="Launch the Flight Allocation console (local web UI).",
|
|
23
|
+
)
|
|
24
|
+
p.add_argument("--host", default=DEFAULT_HOST,
|
|
25
|
+
help=f"bind address (default {DEFAULT_HOST})")
|
|
26
|
+
p.add_argument("--port", type=int, default=DEFAULT_PORT,
|
|
27
|
+
help=f"bind port (default {DEFAULT_PORT})")
|
|
28
|
+
p.add_argument("--no-browser", action="store_true",
|
|
29
|
+
help="do not auto-open the browser")
|
|
30
|
+
# Accepted and ignored so an old shortcut like `flight-alloc web`
|
|
31
|
+
# still launches instead of erroring out.
|
|
32
|
+
p.add_argument("command", nargs="?", default="web",
|
|
33
|
+
help=argparse.SUPPRESS)
|
|
34
|
+
args = p.parse_args(argv)
|
|
35
|
+
|
|
36
|
+
serve(host=args.host, port=args.port, open_browser=not args.no_browser)
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
sys.exit(main())
|
src/config.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Load and validate ``configs/config.yml`` (plan §1.5.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
#: Floating mid-shift break length, in minutes. The bounds are shared by
|
|
13
|
+
#: the config model below and the Setup sidebar's edit form: under 15 is
|
|
14
|
+
#: shorter than the H10 flight spacing, and every shift is 8.5 h with the
|
|
15
|
+
#: first and last hour kept break-free.
|
|
16
|
+
BREAK_MINUTES_DEFAULT = 30
|
|
17
|
+
BREAK_MINUTES_LOWEST = 15
|
|
18
|
+
BREAK_MINUTES_HIGHEST = 120
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Frozen(BaseModel):
|
|
22
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AllocationConfig(_Frozen):
|
|
26
|
+
cycle_year: int
|
|
27
|
+
default_date: date
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SolverConfig(_Frozen):
|
|
31
|
+
max_seconds: int
|
|
32
|
+
deficit_penalty_lambda: int
|
|
33
|
+
# CPU cores handed to CP-SAT for parallel portfolio search. 0 (default)
|
|
34
|
+
# means "auto": use every available core, capped at 8 (see
|
|
35
|
+
# solver/allocator_cpsat.py — OR-Tools' own guidance is that portfolio
|
|
36
|
+
# search gets most of its benefit by ~8 diverse workers). Set explicitly
|
|
37
|
+
# only if you know this machine should use more/fewer.
|
|
38
|
+
num_workers: int = 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StateConfig(_Frozen):
|
|
42
|
+
staleness_max_days: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SVPortalConfig(_Frozen):
|
|
46
|
+
sheet: str
|
|
47
|
+
columns: dict[str, str]
|
|
48
|
+
date_format: str
|
|
49
|
+
pax_regex: str
|
|
50
|
+
# Phase 3 / Change 1 (2026-05-14, INTL overhaul): flights with
|
|
51
|
+
# ``DEP ∈ routing_drop_dep_codes`` are dropped at Step 1 extraction.
|
|
52
|
+
# ARR-side highlighting was removed in the same amendment — Gulf-3
|
|
53
|
+
# ARR flights are plain domestic now.
|
|
54
|
+
routing_drop_dep_codes: list[str] = []
|
|
55
|
+
# Step 1 tags a flight is_international=True if **DEP** is in this
|
|
56
|
+
# list (Change 9, 2026-05-14: DEP-side only — was DEP OR ARR).
|
|
57
|
+
international_airport_codes: list[str] = []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class StaffRosterConfig(_Frozen):
|
|
61
|
+
sheet: str
|
|
62
|
+
name_column: str
|
|
63
|
+
date_header_format: str
|
|
64
|
+
skip_blank_columns: bool
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AMRosterConfig(_Frozen):
|
|
68
|
+
sheet_glob: str
|
|
69
|
+
name_column: str
|
|
70
|
+
date_header_format: str
|
|
71
|
+
subheader_rows_to_skip: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class IOConfig(_Frozen):
|
|
75
|
+
sv_portal: SVPortalConfig
|
|
76
|
+
staff_roster: StaffRosterConfig
|
|
77
|
+
am_roster: AMRosterConfig
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class StatusConfig(_Frozen):
|
|
81
|
+
aliases: dict[str, str]
|
|
82
|
+
shifts: list[str]
|
|
83
|
+
non_shift_recognized: list[str]
|
|
84
|
+
am_shifts: list[str]
|
|
85
|
+
zc_shifts: list[str]
|
|
86
|
+
unrecognized_treatment: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class P2FAdjustmentConfig(_Frozen):
|
|
90
|
+
"""User direction 2026-05-12 §3 — P2F post-pass tolerance window.
|
|
91
|
+
|
|
92
|
+
For each P2F flight assigned to a handler, the post-pass removes:
|
|
93
|
+
* 2 flights with STD within ±tolerance_minutes of (P2F_STD - 3h)
|
|
94
|
+
* 1 flight with STD within ±tolerance_minutes of (P2F_STD - 1h)
|
|
95
|
+
* 1 flight with STD within ±tolerance_minutes of (P2F_STD + 20min)
|
|
96
|
+
Removed flights go to the §4 redistribution pool. Partial removal
|
|
97
|
+
allowed (e.g., only 1 of 2 candidates found within tolerance).
|
|
98
|
+
|
|
99
|
+
2026-05-28 (user direction): ``logic_v2`` switches the P2F post-pass
|
|
100
|
+
from the anchored 3-point model (D-3h / D-1h / D+20m, host-vs-owner
|
|
101
|
+
split) to a single union window per P2F flight. For each P2F at
|
|
102
|
+
STD=T, the entire P2F handler chain (owner shift's handler +
|
|
103
|
+
pre-planner shift's handler) loses ALL their flights in [T-2h, T+1h]
|
|
104
|
+
— the P2F flight itself stays with the owner. Removed flights enter
|
|
105
|
+
the redistribution pool. When ``logic_v2: true`` the P2F post-pass
|
|
106
|
+
also runs BEFORE the INTL post-pass (default order is INTL first)
|
|
107
|
+
so INTL D-75 removal doesn't waste a slot the P2F window would
|
|
108
|
+
already free.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
tolerance_minutes: int = 15
|
|
112
|
+
logic_v2: bool = False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BreakPassConfig(_Frozen):
|
|
116
|
+
"""Floating mid-shift break (``allocator/postpass_break.py``). Every
|
|
117
|
+
on-shift staff member gets one flight-free window of
|
|
118
|
+
``length_minutes``. Editable from the Setup sidebar."""
|
|
119
|
+
|
|
120
|
+
length_minutes: int = Field(
|
|
121
|
+
default=BREAK_MINUTES_DEFAULT,
|
|
122
|
+
ge=BREAK_MINUTES_LOWEST,
|
|
123
|
+
le=BREAK_MINUTES_HIGHEST,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Config(_Frozen):
|
|
128
|
+
allocation: AllocationConfig
|
|
129
|
+
# Task 2a (2026-05-12): required_staffing removed. The per-shift
|
|
130
|
+
# "people needed" baseline is now derived at display time from the
|
|
131
|
+
# current preferred targets and the day's flight count
|
|
132
|
+
# (ceil(flights / preferred_per_staff)). Editable values for
|
|
133
|
+
# per-(shift, role) preferred / acceptable / cap live in
|
|
134
|
+
# configs/shift_limits.json — see allocator/caps.py.
|
|
135
|
+
solver: SolverConfig
|
|
136
|
+
state: StateConfig
|
|
137
|
+
io: IOConfig
|
|
138
|
+
status: StatusConfig
|
|
139
|
+
ops_class_by_aircraft_type: dict[str, str] = {}
|
|
140
|
+
# GULF classification (user direction 2026-05-19): a flight is GULF
|
|
141
|
+
# when EITHER its DEP airport (3-letter) is in
|
|
142
|
+
# ``ops_class_gulf_dep_codes`` (default AUH / DOH / DXB), OR its
|
|
143
|
+
# Aircraft Owner / TYPE letter equals one of
|
|
144
|
+
# ``ops_class_gulf_owner_codes`` (default QR). GULF lands on the
|
|
145
|
+
# dedicated GULF class for reference but is NOT read
|
|
146
|
+
# by Step 3 — extract-only, never allocated. Replaces the legacy
|
|
147
|
+
# ``routing_drop_dep_codes`` outright drop.
|
|
148
|
+
ops_class_gulf_dep_codes: list[str] = []
|
|
149
|
+
ops_class_gulf_owner_codes: list[str] = []
|
|
150
|
+
# User-defined extraction filters (per user direction 2026-05-25).
|
|
151
|
+
# Each filter is a dict with any subset of keys: ``ac_type``,
|
|
152
|
+
# ``ac_owner``, ``ac``, ``dep``, ``arr``, plus an optional
|
|
153
|
+
# ``custom_header`` + ``custom_value`` for matching arbitrary
|
|
154
|
+
# SV-portal columns. Empty / missing field means "don't match on
|
|
155
|
+
# that field". A flight matches the filter iff ALL specified fields
|
|
156
|
+
# match (AND semantics, case-insensitive exact match). When a
|
|
157
|
+
# flight matches any filter it routes to the GULF class — the
|
|
158
|
+
# existing extract-only sheet — same as the static Gulf codes
|
|
159
|
+
# above. Edits via the Override drawer's Extraction Filters form
|
|
160
|
+
# or directly in config.yml.
|
|
161
|
+
extraction_filters: list[dict[str, str]] = []
|
|
162
|
+
p2f_adjustment: P2FAdjustmentConfig = P2FAdjustmentConfig()
|
|
163
|
+
break_pass: BreakPassConfig = BreakPassConfig()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_config(path: Path | str) -> Config:
|
|
167
|
+
raw: Any = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
168
|
+
return Config.model_validate(raw)
|
src/greedy_fallback.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Greedy fallback allocator — Step 4 safety net.
|
|
2
|
+
|
|
3
|
+
CP-SAT (``solver/allocator_cpsat.py``) is the primary allocator: it
|
|
4
|
+
enforces every hard rule (H1/H10/H16/H4/H17/H18...) simultaneously and,
|
|
5
|
+
when it reaches OPTIMAL, provably gives the best weighted result. But
|
|
6
|
+
it is an EXACT solver — if any combination of hard constraints turns
|
|
7
|
+
out to be mutually unsatisfiable (a mis-tuned config value, a day with
|
|
8
|
+
an unusual shape, a bug not yet caught), it reports INFEASIBLE and
|
|
9
|
+
hands back ZERO assignments for the WHOLE day, even for the 99% of
|
|
10
|
+
flights that had nothing to do with the conflict. That is what
|
|
11
|
+
happened in production on 2026-09-22 (a static N/ZC workload floor of
|
|
12
|
+
14 with zero actual night-ops flights).
|
|
13
|
+
|
|
14
|
+
This module is the fallback for exactly that situation. It is invoked
|
|
15
|
+
ONLY when CP-SAT returns a non-feasible status. It ignores every soft
|
|
16
|
+
objective (workload balance, handover preference, INTL spacing
|
|
17
|
+
fairness, day-level targeting...) — it exists purely so the operator
|
|
18
|
+
gets a usable, physically-valid allocation instead of a wall of
|
|
19
|
+
unallocated flights, with a loud warning that manual review is needed.
|
|
20
|
+
|
|
21
|
+
Hard rules this DOES still enforce (the ones that are non-negotiable
|
|
22
|
+
even in a degraded mode):
|
|
23
|
+
H1 one staff per flight — never double-books a flight
|
|
24
|
+
H10 same-staff spacing — never overlaps/underspaces
|
|
25
|
+
one person's flights
|
|
26
|
+
H16 per-staff hard cap — never exceeds a staff's max
|
|
27
|
+
|
|
28
|
+
Hard rules this does NOT enforce (left to the operator to review via
|
|
29
|
+
the Warnings / Unallocated tabs when a greedy-fallback run happens):
|
|
30
|
+
H4 P2F partial-block windows, H17 P2F-per-handler cap of 8, H18
|
|
31
|
+
workload-spread bucketing, INTL D-75 coverage nuances.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from datetime import date as date_t
|
|
38
|
+
|
|
39
|
+
from ..schemas import FlightInput, StaffMember
|
|
40
|
+
from .caps import hard_cap_for
|
|
41
|
+
from .windows import required_spacing_min, std_to_ops_day_minutes
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def greedy_allocate(
|
|
45
|
+
flights: list[FlightInput],
|
|
46
|
+
staff: list[StaffMember],
|
|
47
|
+
eligibility: dict[str, set[str]],
|
|
48
|
+
*,
|
|
49
|
+
ops_day: date_t,
|
|
50
|
+
) -> dict[str, str]:
|
|
51
|
+
"""Best-effort assignment respecting H1, H10, H16 only.
|
|
52
|
+
|
|
53
|
+
Walks flights in chronological (STD) order; for each, picks the
|
|
54
|
+
least-loaded eligible staff who doesn't violate that staff's
|
|
55
|
+
spacing or hard cap. No optimality guarantee and no workload
|
|
56
|
+
balancing beyond "prefer whoever has fewer flights so far" — this
|
|
57
|
+
is a safety net, not a replacement for the CP-SAT model.
|
|
58
|
+
|
|
59
|
+
Returns ``{flight.unique_id: employee_id}`` for whatever it
|
|
60
|
+
managed to place. A flight with no eligible-and-available staff is
|
|
61
|
+
simply absent from the result, exactly like a partial CP-SAT
|
|
62
|
+
solve — the caller's existing UNALLOCATED bookkeeping picks it up
|
|
63
|
+
the same way either way.
|
|
64
|
+
"""
|
|
65
|
+
staff_by_id = {s.employee_id: s for s in staff}
|
|
66
|
+
caps = {s.employee_id: hard_cap_for(s) for s in staff}
|
|
67
|
+
counts: dict[str, int] = defaultdict(int)
|
|
68
|
+
# Per-staff list of assigned STD minutes for H10 spacing checks.
|
|
69
|
+
assigned_by_staff: dict[str, list[int]] = defaultdict(list)
|
|
70
|
+
|
|
71
|
+
ordered = sorted(
|
|
72
|
+
flights,
|
|
73
|
+
key=lambda f: std_to_ops_day_minutes(f.std, f.date, ops_day),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
assignments: dict[str, str] = {}
|
|
77
|
+
for f in ordered:
|
|
78
|
+
std_min = std_to_ops_day_minutes(f.std, f.date, ops_day)
|
|
79
|
+
candidates: list[str] = []
|
|
80
|
+
for eid in eligibility.get(f.unique_id, ()):
|
|
81
|
+
if staff_by_id.get(eid) is None:
|
|
82
|
+
continue
|
|
83
|
+
cap = caps.get(eid, 0)
|
|
84
|
+
if cap <= 0 or counts[eid] >= cap:
|
|
85
|
+
continue
|
|
86
|
+
ok = True
|
|
87
|
+
for other_std in assigned_by_staff[eid]:
|
|
88
|
+
lo, hi = min(std_min, other_std), max(std_min, other_std)
|
|
89
|
+
if hi - lo < required_spacing_min(lo, hi):
|
|
90
|
+
ok = False
|
|
91
|
+
break
|
|
92
|
+
if ok:
|
|
93
|
+
candidates.append(eid)
|
|
94
|
+
if not candidates:
|
|
95
|
+
continue
|
|
96
|
+
candidates.sort(key=lambda eid: (counts[eid], eid))
|
|
97
|
+
pick = candidates[0]
|
|
98
|
+
assignments[f.unique_id] = pick
|
|
99
|
+
counts[pick] += 1
|
|
100
|
+
assigned_by_staff[pick].append(std_min)
|
|
101
|
+
|
|
102
|
+
return assignments
|
src/io/__init__.py
ADDED
|
File without changes
|