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
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flight-alloc
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: IndiGo CLC flight-allocation system — engine + local web console
|
|
5
|
+
Requires-Python: <3.15,>=3.11
|
|
6
|
+
Requires-Dist: openpyxl~=3.1
|
|
7
|
+
Requires-Dist: pydantic~=2.7
|
|
8
|
+
Requires-Dist: ortools~=9.15
|
|
9
|
+
Requires-Dist: pyyaml~=6.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest~=8.0; extra == "dev"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
src/cli.py,sha256=fXb4PbZQB3pbk2BglF85J1BLEtMYZE0-nzEUwYYsWHY,1419
|
|
3
|
+
src/config.py,sha256=fAXTRvMiVGKwsFY_YkhRMcLD86I3o5pumEAqXDn3gkM,6273
|
|
4
|
+
src/greedy_fallback.py,sha256=UthzsxJX50qfTpGYtCFq1B20JHJf7SwkwP0gayXLRYM,4249
|
|
5
|
+
src/plan.py,sha256=Io8kDmDibmPlMO3xtOfoFS9_plMY8e9QR0h2ZtDqNks,7324
|
|
6
|
+
src/recommender_staffing.py,sha256=iW55PsrOFNdNhgCGPqGlTueR9cO0-jJdbDKeWtGC_g0,13494
|
|
7
|
+
src/roster_store.py,sha256=x0AKKbDBXqsAzaxPBEPZMHTy_XQmhFKF72YoYwlZiGc,4961
|
|
8
|
+
src/schemas.py,sha256=C21mdp8tqysFedaKYWi6KjScb10Se67pQ0W77YdBi48,46019
|
|
9
|
+
src/staged_overrides.py,sha256=5kB58a9Rgt6JsltoVFSIgUEAstHQsekouk3FHzkvL3E,17409
|
|
10
|
+
src/state.py,sha256=p84yRK0sYZG7EeqJjcZDP8GnWSHSrKr09ZvMbwfcviI,19078
|
|
11
|
+
src/step1_clean_flights.py,sha256=YbANruEps8HCWXJxr4ZFIwGek8Pb1m4cSRFF8hn-d2U,11353
|
|
12
|
+
src/step2_extract_roster.py,sha256=ZFjCUkUGiSl4DuWEzUlMZZTrx1jMCxXSnyzTjx1J3QI,13318
|
|
13
|
+
src/step3_allocate_flights.py,sha256=h5BMrkDfb_xOP0Fq2RFHrsKz1HnSXlSq--SjFQhqrIA,74715
|
|
14
|
+
src/zc_store.py,sha256=3Oo3sgl7gTIuUVOw3n_dK74-PqGORdwb1ce0P-J-v_k,7819
|
|
15
|
+
src/allocator/__init__.py,sha256=YyS6mA75iNuxn3_5L5Kt6aF_jCYjBdKUqM-GKSOpI7c,229
|
|
16
|
+
src/allocator/caps.py,sha256=yQtvYMvVCzQWpEUHn9-nSCTBDS9XAZVJFE9NAehep0M,21132
|
|
17
|
+
src/allocator/eligibility.py,sha256=DpgQyNHnlUkA5QjYQnCd9ogw7a3cjI7ID7xlEPhikpY,12944
|
|
18
|
+
src/allocator/greedy_fallback.py,sha256=3Y6Baxzl7tyr9cW3WHJvbIXx7RUONUwKgzyZNfQpnLM,4518
|
|
19
|
+
src/allocator/invariants.py,sha256=TaTqrq5rhl4niEzt1iCWC6INQGlj91-c2xW5DZvnQI0,4665
|
|
20
|
+
src/allocator/p2f_priority.py,sha256=odudR4okb69IywNbbuIsvOJtgH-I9O9pnkWYXUndjtA,5882
|
|
21
|
+
src/allocator/pair_validation.py,sha256=x8ZiTUYsMOQqvOSv6boj7E3Rhul1fEj05K5PC_9SL-E,4315
|
|
22
|
+
src/allocator/pairings.py,sha256=9ODg7p-cZw32S-gkufSsFDmNMMyN-jxplf8yWmKKvik,23478
|
|
23
|
+
src/allocator/postpass_break.py,sha256=WtrHKM-deRU4q5kbOGF3DusVNO-9Y8UopGK-0F0ADPs,14901
|
|
24
|
+
src/allocator/postpass_intl.py,sha256=N9IFZlmWYtG4hQoXBQhTgZCVbHRTn1kn59EQ-TWai4w,19221
|
|
25
|
+
src/allocator/postpass_p2f.py,sha256=Lj9MaLSWkHqOM_IVaOK3VLxLS2kZqQhD5dS8SHw0Xpw,30139
|
|
26
|
+
src/allocator/postpass_rebalance.py,sha256=iVGltvwmSbF6X-v4EOyk1BS7irPTdnOEXpc_k0ZOyFo,10147
|
|
27
|
+
src/allocator/postsolve.py,sha256=YkCBV3RJwp0GQ6cUAWBSV08p8Oh17FkmF23l6uvpF5A,22740
|
|
28
|
+
src/allocator/recommender.py,sha256=ReC8RQOw4-D1WRXJWtdRMZUJMyNJBhacO-MMpnBuTpo,13562
|
|
29
|
+
src/allocator/windows.py,sha256=UwWGHalQdcZAWAIKYSIzlKFGWjfkUWAvSOgPnYJ41gQ,16331
|
|
30
|
+
src/io/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
31
|
+
src/io/export.py,sha256=hJH08M5hPPqnl5_ZFKqQvWvH_mHkRBaEIQZzg39WT2A,10200
|
|
32
|
+
src/io/export_xml.py,sha256=KCI7WGgo8kyyCO0sjVFYEiul6Lfy5tjA2zEz5SMB-Uo,2526
|
|
33
|
+
src/io/readers.py,sha256=r8_UayUpnYtL6zK-eLYoBmCMmP3bKsXbgjbR2LRh_RE,38674
|
|
34
|
+
src/io/roster_library.py,sha256=hqgZaRUm8EA9E3V-3KMPaUgTuoUOlMX0_KrPb3T6K5E,3458
|
|
35
|
+
src/solver/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
36
|
+
src/solver/allocator_cpsat.py,sha256=MqOxrZnym7PK92ANW8iOtYocF1iQkEw-fVhGa9Tp4RM,63609
|
|
37
|
+
src/web/__init__.py,sha256=-wJjMJfaf9IlPxVLjxXgLdMkiCS58qrWio8RvdvVdjc,2051
|
|
38
|
+
src/web/__main__.py,sha256=cfMS5N9oZaxTfflkDqPte44KcU_8Lx6PqKcx12KVR-w,163
|
|
39
|
+
src/web/runner.py,sha256=G3rl2CvbYM4Wf514IY4Pch50NBsSTmhg5FAH8WCcNlA,5504
|
|
40
|
+
src/web/server.py,sha256=W5L9t-Teo9BplVSjhBRCNY3_r_3rWH6xfvSNvpFB_p8,6464
|
|
41
|
+
src/web/api/__init__.py,sha256=vL3YK7szKP0sIjXJyFoXAaNKuqfvoJ9jfoqL4YJxmDI,1527
|
|
42
|
+
src/web/api/export.py,sha256=m81TktlSPLPYQ4tSYaiTNrobGT-dOEeyXgFqLGWghMM,1179
|
|
43
|
+
src/web/api/inputs.py,sha256=Hyntl4jYs2IuQuxvsXjalwQz7VAEvKYxg7eQYbWrVdg,4845
|
|
44
|
+
src/web/api/override_rows.py,sha256=HBFboapcq6y4IY0M4mJScvZ7Kuqlp2FmD_n1YAY0pxU,5452
|
|
45
|
+
src/web/api/pages.py,sha256=973ocA6AWnG9I6F50HGIOSQsiS9fQRiswIjptb9N3k4,841
|
|
46
|
+
src/web/api/readbacks.py,sha256=zPGjAVb9WkjmcRFs53HKHXHj9ZeAyVZLMcMTwaPZCYI,2697
|
|
47
|
+
src/web/api/recommender.py,sha256=aQkXpZxrcxW19vUlmY-lJohOcCXgZIvzKLsoaScJk7g,2769
|
|
48
|
+
src/web/api/runs.py,sha256=cZjMmz10hwvC5-u0USAu9J8rHU7gLT3vCWGq3zBBFuw,3763
|
|
49
|
+
src/web/api/settings.py,sha256=VS_pGykBK3VJ6bxk5ezN_WQVBYUwPs0bQEJsXq2QbnA,8021
|
|
50
|
+
src/web/api/zc.py,sha256=SS4-f2CCKmiwykhBS1_h1b-MNGf-e7VexO2HPCQRPok,4212
|
|
51
|
+
src/web/core/__init__.py,sha256=eBMYTOLKhrj0eZIUqj39fa-_ZBixlZ88LSgfFHFUbRE,185
|
|
52
|
+
src/web/core/responses.py,sha256=1IoLRU0K96xcyvcpgUKS3coom2M9qPv1_rqTcJEkAqU,2520
|
|
53
|
+
src/web/core/router.py,sha256=VJIYHX0CBCoVd5in-vOhk7TftURNDzD0SUTuCtzGhGg,5853
|
|
54
|
+
src/web/core/static_files.py,sha256=HHSIa2_UAaB6J89Ywa1_r1sMyASdFBeqcuGWTkhmyto,2943
|
|
55
|
+
src/web/overrides/__init__.py,sha256=ogdjA_cWm7VUptZkHI0w09cp4S1ZnG5UATp6TLmMDK0,1949
|
|
56
|
+
src/web/overrides/airports.py,sha256=hyAXjuJcCMPZ0HAzVi4E_9eHf0omIfkhnkbSv_nW71s,10457
|
|
57
|
+
src/web/overrides/break_time.py,sha256=Nn9g0xDwfN3GOqABcgS_9yMlnn9K1KtVpw7culOOrrk,3010
|
|
58
|
+
src/web/overrides/config_yaml.py,sha256=feZyafDsyms2qwaETYzrkj-BFhJhEkYZdi9N4tse39M,703
|
|
59
|
+
src/web/overrides/filters.py,sha256=jAkP68EHh9xeIQ121LlY2XBm5AIsa6mCSqE1bYWggn8,6964
|
|
60
|
+
src/web/overrides/rows.py,sha256=EyjJG72LyRG1QtrYbCTi41Cp9SHpwkXsuThAio2m4UY,4124
|
|
61
|
+
src/web/readback/__init__.py,sha256=DAwQgJh5VU0id7Me1-FVM3AKpyLdGG4_jIrO3o3PCNU,1748
|
|
62
|
+
src/web/readback/common.py,sha256=INi-ZtfDd4-hRo6dauJY6mvSd52zLvzZXM5jXofcehk,1990
|
|
63
|
+
src/web/readback/dashboard.py,sha256=0xfKqfFCQFqGJlv-usfg7bzkEGRRukrew9NomTOkEYc,3166
|
|
64
|
+
src/web/readback/planning.py,sha256=6BOuISarumaP_g7YfVRRtxrwRJUd6k2Hb_Lj6cbvTbg,13157
|
|
65
|
+
src/web/readback/session.py,sha256=Pyzkre3MXhemHYp9a2wEez2wXTo2_nZUODZjUh7USqs,5796
|
|
66
|
+
src/web/readback/tables.py,sha256=475gq5pSgo2swQrAQbXxqyVMEJEd9k9f_13qYMnxdqI,6088
|
|
67
|
+
flight_alloc-0.0.1.dist-info/METADATA,sha256=9IsooWjPnne6Ws__MNemXpkMgTTfieNhmbW2Q2Gtpbw,350
|
|
68
|
+
flight_alloc-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
69
|
+
flight_alloc-0.0.1.dist-info/entry_points.txt,sha256=2As932cS3ifWNunOGp0MFegD0HIMW-XRjAYbRA-gPpc,46
|
|
70
|
+
flight_alloc-0.0.1.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
|
|
71
|
+
flight_alloc-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
src
|
src/__init__.py
ADDED
|
File without changes
|
src/allocator/caps.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Per-(shift, role) workload bands — defaults from JSON, plus
|
|
2
|
+
iteration-scoped overrides.
|
|
3
|
+
|
|
4
|
+
Task 2 (2026-05-12) replaces the previous hardcoded ``(Role, "day"|"night")``
|
|
5
|
+
tables with a per-shift-role JSON config at ``configs/shift_limits.json``.
|
|
6
|
+
Each (shift, role) entry has three values:
|
|
7
|
+
|
|
8
|
+
min — S1 preferred floor; counts below this incur shortfall penalty
|
|
9
|
+
target — S1 acceptable max; counts above this start to penalize
|
|
10
|
+
max — H16 hard cap; counts above this are infeasible
|
|
11
|
+
|
|
12
|
+
Validation: ``min <= target <= max`` and all positive integers.
|
|
13
|
+
|
|
14
|
+
The same JSON powers the override drawer's "Edit shift limits" form
|
|
15
|
+
(see web/overrides.py). Per-iteration overrides are pushed into
|
|
16
|
+
``_iteration_overrides`` by the drawer or by override rows; they
|
|
17
|
+
shadow the JSON defaults without persisting to disk. ``reset_overrides()``
|
|
18
|
+
clears them and is invoked on the engine's reset path.
|
|
19
|
+
|
|
20
|
+
Per-staff overrides:
|
|
21
|
+
* a ``max_flights`` override row LOWERS a single staff's H16 cap.
|
|
22
|
+
* a ``preferred_target`` override row REPLACES a single staff's
|
|
23
|
+
preferred (default = (shift, role)'s ``min``).
|
|
24
|
+
Both reset when the overrides are cleared.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextlib
|
|
30
|
+
import json
|
|
31
|
+
import threading
|
|
32
|
+
from collections.abc import Iterable
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from ..schemas import Role, StaffMember
|
|
36
|
+
|
|
37
|
+
_CONFIG_PATH = Path(__file__).resolve().parents[2] / "configs" / "shift_limits.json"
|
|
38
|
+
# Per-iteration overrides persist here so they survive the process
|
|
39
|
+
# boundary between the web server (where the drawer writes them) and
|
|
40
|
+
# the CLI subprocess that actually runs the engine. Wiped by
|
|
41
|
+
# ``reset_overrides()`` and by the engine's reset path.
|
|
42
|
+
_STATE_PATH = Path(__file__).resolve().parents[2] / "data" / "state" / "iteration_overrides.json"
|
|
43
|
+
|
|
44
|
+
# All known shift codes — kept in sync with config.status.shifts.
|
|
45
|
+
_KNOWN_SHIFTS = ("M", "A", "N", "M1", "A1")
|
|
46
|
+
_ROLE_KEYS = {Role.STAFF: "STAFF", Role.ZC: "ZC"}
|
|
47
|
+
|
|
48
|
+
# Cached default table loaded from JSON. Refreshed on import and on any
|
|
49
|
+
# explicit ``reload_defaults()`` call (e.g., after the drawer edits the
|
|
50
|
+
# defaults rather than the iteration scratch).
|
|
51
|
+
_defaults: dict[tuple[str, Role], dict[str, int]] = {}
|
|
52
|
+
# Per-iteration shadow values: empty until the drawer writes here.
|
|
53
|
+
_iteration_overrides: dict[tuple[str, Role], dict[str, int]] = {}
|
|
54
|
+
# Per-staff iteration overrides — keyed by employee_id.
|
|
55
|
+
_iteration_per_staff_preferred: dict[str, int] = {}
|
|
56
|
+
_iteration_per_staff_cap: dict[str, int] = {}
|
|
57
|
+
_lock = threading.RLock()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _coerce_int(v: object, key: str) -> int:
|
|
61
|
+
if not isinstance(v, (int, float)) or isinstance(v, bool):
|
|
62
|
+
raise ValueError(f"{key} must be a number, got {v!r}")
|
|
63
|
+
iv = int(v)
|
|
64
|
+
if iv <= 0:
|
|
65
|
+
raise ValueError(f"{key} must be a positive integer, got {iv}")
|
|
66
|
+
return iv
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_band(min_v: int, target_v: int, max_v: int, where: str) -> None:
|
|
70
|
+
"""Reject any (min, target, max) triple that breaks the ordering
|
|
71
|
+
invariant. Single source of truth used by both the JSON loader and
|
|
72
|
+
the drawer write path so a bad form submission can't slip through."""
|
|
73
|
+
if not (min_v <= target_v <= max_v):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"{where}: require min <= target <= max, got "
|
|
76
|
+
f"min={min_v} target={target_v} max={max_v}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_defaults_from_disk() -> dict[tuple[str, Role], dict[str, int]]:
|
|
81
|
+
raw = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
82
|
+
shifts_block = raw.get("shifts", {})
|
|
83
|
+
out: dict[tuple[str, Role], dict[str, int]] = {}
|
|
84
|
+
for shift, role_map in shifts_block.items():
|
|
85
|
+
if shift not in _KNOWN_SHIFTS:
|
|
86
|
+
continue
|
|
87
|
+
for role_name, vals in role_map.items():
|
|
88
|
+
role = Role.STAFF if role_name == "STAFF" else (
|
|
89
|
+
Role.ZC if role_name == "ZC" else None
|
|
90
|
+
)
|
|
91
|
+
if role is None:
|
|
92
|
+
continue
|
|
93
|
+
min_v = _coerce_int(vals.get("min"), f"{shift}/{role_name}.min")
|
|
94
|
+
target_v = _coerce_int(vals.get("target"), f"{shift}/{role_name}.target")
|
|
95
|
+
max_v = _coerce_int(vals.get("max"), f"{shift}/{role_name}.max")
|
|
96
|
+
_validate_band(min_v, target_v, max_v, f"{shift}/{role_name}")
|
|
97
|
+
out[(shift, role)] = {"min": min_v, "target": target_v, "max": max_v}
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def reload_defaults() -> None:
|
|
102
|
+
"""Re-read ``configs/shift_limits.json`` into the module cache.
|
|
103
|
+
|
|
104
|
+
Use this after editing the file out-of-band; the drawer's per-iteration
|
|
105
|
+
overrides write through ``set_iteration_band`` instead — those land
|
|
106
|
+
in ``data/state/iteration_overrides.json`` rather than the defaults
|
|
107
|
+
file."""
|
|
108
|
+
global _defaults
|
|
109
|
+
with _lock:
|
|
110
|
+
_defaults = _load_defaults_from_disk()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _dump_defaults(raw: dict) -> str:
|
|
114
|
+
"""Serialise shift_limits.json in its hand-written layout — the
|
|
115
|
+
comment block one string per line, then one line per (shift, role)
|
|
116
|
+
band — so a Setup sidebar save leaves a readable diff."""
|
|
117
|
+
def dumps(v: object) -> str:
|
|
118
|
+
return json.dumps(v, ensure_ascii=False)
|
|
119
|
+
|
|
120
|
+
# Continuation lines line up under the first role: 4 indent + the
|
|
121
|
+
# 6-wide shift key + "{ ".
|
|
122
|
+
role_pad = " " * 12
|
|
123
|
+
parts: list[str] = []
|
|
124
|
+
for key, value in raw.items():
|
|
125
|
+
if key == "shifts":
|
|
126
|
+
shift_lines = []
|
|
127
|
+
for shift, roles in value.items():
|
|
128
|
+
role_lines = [
|
|
129
|
+
f"{dumps(role) + ':':<9}{{ "
|
|
130
|
+
+ ", ".join(f"{dumps(k)}: {dumps(v)}" for k, v in band.items())
|
|
131
|
+
+ " }"
|
|
132
|
+
for role, band in roles.items()
|
|
133
|
+
]
|
|
134
|
+
body = (",\n" + role_pad).join(role_lines)
|
|
135
|
+
shift_lines.append(f" {dumps(shift) + ':':<6}{{ {body} }}")
|
|
136
|
+
parts.append(' "shifts": {\n' + ",\n".join(shift_lines) + "\n }")
|
|
137
|
+
elif isinstance(value, list):
|
|
138
|
+
items = ",\n".join(f" {dumps(v)}" for v in value)
|
|
139
|
+
parts.append(f" {dumps(key)}: [\n{items}\n ]")
|
|
140
|
+
else:
|
|
141
|
+
parts.append(f" {dumps(key)}: {dumps(value)}")
|
|
142
|
+
return "{\n" + ",\n".join(parts) + "\n}\n"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _state_path_or_none() -> Path | None:
|
|
146
|
+
"""Return the runtime-state file path, or None if its parent
|
|
147
|
+
directory cannot be created. Callers treat None as "no persistence
|
|
148
|
+
available" and skip the disk hop."""
|
|
149
|
+
try:
|
|
150
|
+
_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
except OSError:
|
|
152
|
+
return None
|
|
153
|
+
return _STATE_PATH
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _write_iteration_state_to_disk() -> None:
|
|
157
|
+
"""Serialize the in-memory iteration overrides to JSON so the engine
|
|
158
|
+
subprocess sees the same shadow when the user clicks Allocate."""
|
|
159
|
+
p = _state_path_or_none()
|
|
160
|
+
if p is None:
|
|
161
|
+
return
|
|
162
|
+
payload = {
|
|
163
|
+
"shift_role": [
|
|
164
|
+
{"shift": shift, "role": (role.value if hasattr(role, "value") else role),
|
|
165
|
+
"min": v["min"], "target": v["target"], "max": v["max"]}
|
|
166
|
+
for (shift, role), v in _iteration_overrides.items()
|
|
167
|
+
],
|
|
168
|
+
"per_staff_preferred": dict(_iteration_per_staff_preferred),
|
|
169
|
+
"per_staff_cap": dict(_iteration_per_staff_cap),
|
|
170
|
+
}
|
|
171
|
+
p.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _load_iteration_state_from_disk() -> None:
|
|
175
|
+
"""Populate the in-memory iteration overrides from the state file
|
|
176
|
+
on module import. Silently skips when the file is absent (= no
|
|
177
|
+
overrides for this iteration)."""
|
|
178
|
+
if not _STATE_PATH.exists():
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
payload = json.loads(_STATE_PATH.read_text(encoding="utf-8"))
|
|
182
|
+
except (json.JSONDecodeError, OSError):
|
|
183
|
+
return
|
|
184
|
+
for entry in payload.get("shift_role", []):
|
|
185
|
+
shift = entry.get("shift")
|
|
186
|
+
role_name = entry.get("role")
|
|
187
|
+
role = Role.STAFF if role_name == "STAFF" else (
|
|
188
|
+
Role.ZC if role_name == "ZC" else None
|
|
189
|
+
)
|
|
190
|
+
if shift not in _KNOWN_SHIFTS or role is None:
|
|
191
|
+
continue
|
|
192
|
+
try:
|
|
193
|
+
min_v = _coerce_int(entry.get("min"), "min")
|
|
194
|
+
target_v = _coerce_int(entry.get("target"), "target")
|
|
195
|
+
max_v = _coerce_int(entry.get("max"), "max")
|
|
196
|
+
_validate_band(min_v, target_v, max_v, f"{shift}/{role_name}")
|
|
197
|
+
except ValueError:
|
|
198
|
+
continue
|
|
199
|
+
_iteration_overrides[(shift, role)] = {
|
|
200
|
+
"min": min_v, "target": target_v, "max": max_v,
|
|
201
|
+
}
|
|
202
|
+
for emp_id, v in payload.get("per_staff_preferred", {}).items():
|
|
203
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
204
|
+
_iteration_per_staff_preferred[str(emp_id)] = int(v)
|
|
205
|
+
for emp_id, v in payload.get("per_staff_cap", {}).items():
|
|
206
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
207
|
+
_iteration_per_staff_cap[str(emp_id)] = int(v)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Load once at import. Module remains usable even if the file's missing
|
|
211
|
+
# at import time — we'll fall back to a minimal hardcoded table so unit
|
|
212
|
+
# tests that don't ship the JSON don't crash the import.
|
|
213
|
+
try:
|
|
214
|
+
_defaults = _load_defaults_from_disk()
|
|
215
|
+
except (FileNotFoundError, json.JSONDecodeError, ValueError):
|
|
216
|
+
_defaults = {
|
|
217
|
+
("M", Role.STAFF): {"min": 22, "target": 23, "max": 24},
|
|
218
|
+
("M", Role.ZC): {"min": 14, "target": 15, "max": 16},
|
|
219
|
+
("A", Role.STAFF): {"min": 22, "target": 23, "max": 24},
|
|
220
|
+
("A", Role.ZC): {"min": 14, "target": 15, "max": 16},
|
|
221
|
+
("M1", Role.STAFF): {"min": 22, "target": 23, "max": 24},
|
|
222
|
+
("M1", Role.ZC): {"min": 14, "target": 15, "max": 16},
|
|
223
|
+
("A1", Role.STAFF): {"min": 22, "target": 23, "max": 24},
|
|
224
|
+
("A1", Role.ZC): {"min": 14, "target": 15, "max": 16},
|
|
225
|
+
("N", Role.STAFF): {"min": 20, "target": 21, "max": 22},
|
|
226
|
+
("N", Role.ZC): {"min": 10, "target": 11, "max": 12},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# Pick up any iteration overrides left over from a prior session
|
|
230
|
+
# (e.g., user closed the browser without clicking Reset).
|
|
231
|
+
_load_iteration_state_from_disk()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------- public API ----------
|
|
235
|
+
|
|
236
|
+
def get_band(shift: str, role: Role) -> dict[str, int] | None:
|
|
237
|
+
"""Return ``{"min", "target", "max"}`` for (shift, role), preferring
|
|
238
|
+
any active iteration override over the JSON default. ``None`` when
|
|
239
|
+
no band exists (unknown shift or role)."""
|
|
240
|
+
with _lock:
|
|
241
|
+
if (shift, role) in _iteration_overrides:
|
|
242
|
+
return dict(_iteration_overrides[(shift, role)])
|
|
243
|
+
if (shift, role) in _defaults:
|
|
244
|
+
return dict(_defaults[(shift, role)])
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def default_caps() -> dict[str, dict[str, int]]:
|
|
249
|
+
"""The saved hard cap (``max``) per shift and role, as
|
|
250
|
+
``{shift: {"STAFF": n, "ZC": n}}`` in file order — what the Setup
|
|
251
|
+
sidebar's cap table shows. Iteration overrides are not applied."""
|
|
252
|
+
with _lock:
|
|
253
|
+
out: dict[str, dict[str, int]] = {}
|
|
254
|
+
for (shift, role), band in _defaults.items():
|
|
255
|
+
out.setdefault(shift, {})[_ROLE_KEYS[role]] = band["max"]
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def set_default_caps(caps: dict[str, dict[str, object]]) -> list[str]:
|
|
260
|
+
"""Save new hard caps (``max``) to ``configs/shift_limits.json`` and
|
|
261
|
+
reload them, so the next run uses them. The Setup sidebar's cap
|
|
262
|
+
table is the caller; an iteration override still shadows its band.
|
|
263
|
+
|
|
264
|
+
A cap below a band's min or target pulls those down to it, keeping
|
|
265
|
+
``min <= target <= max``; returns one note per band that moved.
|
|
266
|
+
Raises ``ValueError``, before anything is written, on an unknown
|
|
267
|
+
shift or role, a cap that isn't a positive whole number, or a ZC
|
|
268
|
+
cap above that shift's STAFF cap.
|
|
269
|
+
"""
|
|
270
|
+
global _defaults
|
|
271
|
+
with _lock:
|
|
272
|
+
raw = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
273
|
+
shifts_block = raw.get("shifts", {})
|
|
274
|
+
notes: list[str] = []
|
|
275
|
+
for shift, role_caps in caps.items():
|
|
276
|
+
if not isinstance(role_caps, dict):
|
|
277
|
+
raise ValueError(f"{shift}: expected {{role: cap}}, got {role_caps!r}")
|
|
278
|
+
for role_name, cap in role_caps.items():
|
|
279
|
+
where = f"{shift}/{role_name}"
|
|
280
|
+
band = shifts_block.get(shift, {}).get(role_name)
|
|
281
|
+
if shift not in _KNOWN_SHIFTS or band is None:
|
|
282
|
+
raise ValueError(f"unknown shift/role {where}")
|
|
283
|
+
if isinstance(cap, bool) or not isinstance(cap, int) or cap <= 0:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"{where} cap must be a positive whole number, got {cap!r}"
|
|
286
|
+
)
|
|
287
|
+
band["max"] = cap
|
|
288
|
+
lowered = [
|
|
289
|
+
f"{label} {band[key]}"
|
|
290
|
+
for key, label in (("min", "preferred"), ("target", "target"))
|
|
291
|
+
if band[key] > cap
|
|
292
|
+
]
|
|
293
|
+
if lowered:
|
|
294
|
+
band["min"] = min(band["min"], cap)
|
|
295
|
+
band["target"] = min(band["target"], cap)
|
|
296
|
+
notes.append(f"{where}: {' and '.join(lowered)} lowered to {cap}")
|
|
297
|
+
for shift, roles in shifts_block.items():
|
|
298
|
+
staff, zc = roles.get("STAFF"), roles.get("ZC")
|
|
299
|
+
if staff and zc and zc["max"] > staff["max"]:
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f"{shift}: the ZC cap ({zc['max']}) can't be above the "
|
|
302
|
+
f"STAFF cap ({staff['max']})"
|
|
303
|
+
)
|
|
304
|
+
_CONFIG_PATH.write_text(_dump_defaults(raw), encoding="utf-8")
|
|
305
|
+
_defaults = _load_defaults_from_disk()
|
|
306
|
+
return notes
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def set_iteration_band(
|
|
310
|
+
shift: str, role: Role, *, min_v: int, target_v: int, max_v: int,
|
|
311
|
+
) -> dict[str, int]:
|
|
312
|
+
"""Install a per-iteration override for the (shift, role) band. The
|
|
313
|
+
drawer "Edit shift limits" form is the only intended caller. Returns
|
|
314
|
+
the persisted dict for confirmation.
|
|
315
|
+
|
|
316
|
+
Validates via ``_coerce_int`` (positive integer) AND ``_validate_band``
|
|
317
|
+
(ordering); raises ``ValueError`` on either failure.
|
|
318
|
+
Also written to ``data/state/iteration_overrides.json`` so a band
|
|
319
|
+
tweak survives a server restart. Reset clears it.
|
|
320
|
+
"""
|
|
321
|
+
role_name = role.value if hasattr(role, "value") else role
|
|
322
|
+
where = f"{shift}/{role_name}"
|
|
323
|
+
min_i = _coerce_int(min_v, f"{where}.min")
|
|
324
|
+
target_i = _coerce_int(target_v, f"{where}.target")
|
|
325
|
+
max_i = _coerce_int(max_v, f"{where}.max")
|
|
326
|
+
_validate_band(min_i, target_i, max_i, where)
|
|
327
|
+
with _lock:
|
|
328
|
+
_iteration_overrides[(shift, role)] = {
|
|
329
|
+
"min": min_i, "target": target_i, "max": max_i,
|
|
330
|
+
}
|
|
331
|
+
_write_iteration_state_to_disk()
|
|
332
|
+
return dict(_iteration_overrides[(shift, role)])
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def set_iteration_per_staff_preferred(employee_id: str, value: int) -> None:
|
|
336
|
+
"""Per-staff iteration override for the preferred-target. Used by
|
|
337
|
+
the engine when an an override row of type=preferred_flights is
|
|
338
|
+
present. ``reset_overrides()`` clears this too."""
|
|
339
|
+
with _lock:
|
|
340
|
+
_iteration_per_staff_preferred[str(employee_id)] = int(value)
|
|
341
|
+
_write_iteration_state_to_disk()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def set_iteration_per_staff_cap(employee_id: str, value: int) -> None:
|
|
345
|
+
"""Per-staff iteration override that LOWERS the hard cap (matches
|
|
346
|
+
the legacy ``max_flights`` override semantics)."""
|
|
347
|
+
with _lock:
|
|
348
|
+
_iteration_per_staff_cap[str(employee_id)] = int(value)
|
|
349
|
+
_write_iteration_state_to_disk()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def reset_overrides() -> None:
|
|
353
|
+
"""Wipe every iteration-scoped override (both per-(shift, role) and
|
|
354
|
+
per-staff). Engine's reset path and the drawer "Reset" button both
|
|
355
|
+
call this so a clean run uses the JSON defaults. Also removes the
|
|
356
|
+
on-disk state file so a later run can't pick up stale overrides."""
|
|
357
|
+
with _lock:
|
|
358
|
+
_iteration_overrides.clear()
|
|
359
|
+
_iteration_per_staff_preferred.clear()
|
|
360
|
+
_iteration_per_staff_cap.clear()
|
|
361
|
+
with contextlib.suppress(FileNotFoundError):
|
|
362
|
+
_STATE_PATH.unlink()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def snapshot_iteration_overrides() -> dict[str, dict[str, dict[str, int]]]:
|
|
366
|
+
"""Diagnostic snapshot for the drawer's "show current iteration
|
|
367
|
+
overrides" hint and for the audit harness. Read-only copy.
|
|
368
|
+
|
|
369
|
+
Shape:
|
|
370
|
+
{
|
|
371
|
+
"shift_role": {"M/STAFF": {"min": ..., "target": ..., "max": ...}, ...},
|
|
372
|
+
"per_staff_preferred": {"<emp_id>": 20, ...},
|
|
373
|
+
"per_staff_cap": {"<emp_id>": 18, ...}
|
|
374
|
+
}
|
|
375
|
+
"""
|
|
376
|
+
with _lock:
|
|
377
|
+
return {
|
|
378
|
+
"shift_role": {
|
|
379
|
+
f"{shift}/{(role.value if hasattr(role, 'value') else role)}": dict(v)
|
|
380
|
+
for (shift, role), v in _iteration_overrides.items()
|
|
381
|
+
},
|
|
382
|
+
"per_staff_preferred": dict(_iteration_per_staff_preferred),
|
|
383
|
+
"per_staff_cap": dict(_iteration_per_staff_cap),
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------- engine-facing helpers (used by solver, postsolve, post-passes) ----------
|
|
388
|
+
|
|
389
|
+
def hard_cap_for(staff: StaffMember) -> int:
|
|
390
|
+
"""Return the binding upper bound on flights for ``staff`` today.
|
|
391
|
+
|
|
392
|
+
Order of precedence:
|
|
393
|
+
1. Per-staff iteration override (LOWER only)
|
|
394
|
+
2. StaffMember.max_flights_cap from an override (LOWER only)
|
|
395
|
+
3. JSON default for (shift, role)
|
|
396
|
+
|
|
397
|
+
AMs and off-shift staff get 0 (they don't fly).
|
|
398
|
+
"""
|
|
399
|
+
if staff.role == Role.AM or staff.shift_today is None:
|
|
400
|
+
return 0
|
|
401
|
+
band = get_band(staff.shift_today, staff.role)
|
|
402
|
+
if band is None:
|
|
403
|
+
return 0
|
|
404
|
+
base = band["max"]
|
|
405
|
+
# Per-staff iteration override (only lowers).
|
|
406
|
+
iter_cap = _iteration_per_staff_cap.get(staff.employee_id)
|
|
407
|
+
if iter_cap is not None:
|
|
408
|
+
base = min(base, iter_cap)
|
|
409
|
+
# Legacy max_flights override cap (only lowers).
|
|
410
|
+
if staff.max_flights_cap is not None:
|
|
411
|
+
base = min(base, staff.max_flights_cap)
|
|
412
|
+
return base
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def aggregate_capacity(staff: Iterable[StaffMember]) -> int:
|
|
416
|
+
"""Sum of ``hard_cap_for()`` across all input staff. Compared against
|
|
417
|
+
flight count before invoking the solver — if capacity < demand the
|
|
418
|
+
day is structurally infeasible regardless of solver strategy."""
|
|
419
|
+
return sum(hard_cap_for(s) for s in staff)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def preferred_target_for(staff: StaffMember) -> int:
|
|
423
|
+
"""S1 preferred count for ``staff`` today.
|
|
424
|
+
|
|
425
|
+
Order of precedence:
|
|
426
|
+
1. Per-staff iteration override
|
|
427
|
+
2. JSON default for (shift, role)
|
|
428
|
+
"""
|
|
429
|
+
if staff.role == Role.AM or staff.shift_today is None:
|
|
430
|
+
return 0
|
|
431
|
+
iter_pref = _iteration_per_staff_preferred.get(staff.employee_id)
|
|
432
|
+
if iter_pref is not None:
|
|
433
|
+
return int(iter_pref)
|
|
434
|
+
band = get_band(staff.shift_today, staff.role)
|
|
435
|
+
return band["min"] if band else 0
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def acceptable_max_for(staff: StaffMember) -> int:
|
|
439
|
+
"""Upper end of the acceptable range."""
|
|
440
|
+
if staff.role == Role.AM or staff.shift_today is None:
|
|
441
|
+
return 0
|
|
442
|
+
band = get_band(staff.shift_today, staff.role)
|
|
443
|
+
return band["target"] if band else 0
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def preferred_target_for_shift_role(shift: str, role: Role) -> int:
|
|
447
|
+
"""Aggregate preferred lookup keyed purely on (shift, role).
|
|
448
|
+
|
|
449
|
+
Used by the planner / readback layer for the "people needed" display
|
|
450
|
+
on the dashboard (Task 2a) — the per-staff override path doesn't
|
|
451
|
+
apply because we're calculating a shift-wide capacity estimate, not
|
|
452
|
+
a per-staff bound.
|
|
453
|
+
"""
|
|
454
|
+
band = get_band(shift, role)
|
|
455
|
+
return band["min"] if band else 0
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ---------- legacy compatibility re-exports ----------
|
|
459
|
+
#
|
|
460
|
+
# Other modules (solver, postsolve, tests) reach into these dicts directly.
|
|
461
|
+
# We expose them as live views over the active values so callers don't
|
|
462
|
+
# need to update — but new code should prefer the helper functions above.
|
|
463
|
+
|
|
464
|
+
def _build_legacy_view(group: str) -> dict[tuple[Role, str], int]:
|
|
465
|
+
"""``group`` is one of ``"min"`` / ``"target"`` / ``"max"``. Returns
|
|
466
|
+
a ``(Role, "day"|"night")`` dict where "day" merges M/A/M1/A1 (we
|
|
467
|
+
use M's values as the representative — they're the same in defaults)
|
|
468
|
+
and "night" maps from N."""
|
|
469
|
+
def day_for(role: Role, key: str) -> int:
|
|
470
|
+
band = get_band("M", role)
|
|
471
|
+
return band[key] if band else 0
|
|
472
|
+
def night_for(role: Role, key: str) -> int:
|
|
473
|
+
band = get_band("N", role)
|
|
474
|
+
return band[key] if band else 0
|
|
475
|
+
return {
|
|
476
|
+
(Role.STAFF, "day"): day_for(Role.STAFF, group),
|
|
477
|
+
(Role.STAFF, "night"): night_for(Role.STAFF, group),
|
|
478
|
+
(Role.ZC, "day"): day_for(Role.ZC, group),
|
|
479
|
+
(Role.ZC, "night"): night_for(Role.ZC, group),
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def __getattr__(name: str): # pragma: no cover — module-level dynamic attr
|
|
484
|
+
"""Module-level ``__getattr__`` so the legacy names
|
|
485
|
+
``H16_HARD_CAPS`` / ``S1_PREFERRED_TARGETS`` / ``S1_ACCEPTABLE_MAX``
|
|
486
|
+
still resolve to live dicts. Existing code that did ``from .caps
|
|
487
|
+
import H16_HARD_CAPS`` keeps working without each call site having
|
|
488
|
+
to migrate to the new helper functions in one pass."""
|
|
489
|
+
if name == "H16_HARD_CAPS":
|
|
490
|
+
return _build_legacy_view("max")
|
|
491
|
+
if name == "S1_PREFERRED_TARGETS":
|
|
492
|
+
return _build_legacy_view("min")
|
|
493
|
+
if name == "S1_ACCEPTABLE_MAX":
|
|
494
|
+
return _build_legacy_view("target")
|
|
495
|
+
raise AttributeError(name)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
__all__ = [
|
|
499
|
+
"acceptable_max_for",
|
|
500
|
+
"aggregate_capacity",
|
|
501
|
+
"default_caps",
|
|
502
|
+
"get_band",
|
|
503
|
+
"hard_cap_for",
|
|
504
|
+
"preferred_target_for",
|
|
505
|
+
"preferred_target_for_shift_role",
|
|
506
|
+
"reload_defaults",
|
|
507
|
+
"reset_overrides",
|
|
508
|
+
"set_default_caps",
|
|
509
|
+
"set_iteration_band",
|
|
510
|
+
"set_iteration_per_staff_cap",
|
|
511
|
+
"set_iteration_per_staff_preferred",
|
|
512
|
+
"snapshot_iteration_overrides",
|
|
513
|
+
]
|