flight-alloc 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. flight_alloc-0.0.1.dist-info/METADATA +11 -0
  2. flight_alloc-0.0.1.dist-info/RECORD +71 -0
  3. flight_alloc-0.0.1.dist-info/WHEEL +5 -0
  4. flight_alloc-0.0.1.dist-info/entry_points.txt +2 -0
  5. flight_alloc-0.0.1.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/allocator/__init__.py +6 -0
  8. src/allocator/caps.py +513 -0
  9. src/allocator/eligibility.py +302 -0
  10. src/allocator/greedy_fallback.py +106 -0
  11. src/allocator/invariants.py +124 -0
  12. src/allocator/p2f_priority.py +142 -0
  13. src/allocator/pair_validation.py +108 -0
  14. src/allocator/pairings.py +554 -0
  15. src/allocator/postpass_break.py +366 -0
  16. src/allocator/postpass_intl.py +483 -0
  17. src/allocator/postpass_p2f.py +723 -0
  18. src/allocator/postpass_rebalance.py +244 -0
  19. src/allocator/postsolve.py +549 -0
  20. src/allocator/recommender.py +348 -0
  21. src/allocator/windows.py +377 -0
  22. src/cli.py +41 -0
  23. src/config.py +168 -0
  24. src/greedy_fallback.py +102 -0
  25. src/io/__init__.py +0 -0
  26. src/io/export.py +270 -0
  27. src/io/export_xml.py +66 -0
  28. src/io/readers.py +1048 -0
  29. src/io/roster_library.py +89 -0
  30. src/plan.py +192 -0
  31. src/recommender_staffing.py +329 -0
  32. src/roster_store.py +159 -0
  33. src/schemas.py +1244 -0
  34. src/solver/__init__.py +0 -0
  35. src/solver/allocator_cpsat.py +1412 -0
  36. src/staged_overrides.py +468 -0
  37. src/state.py +494 -0
  38. src/step1_clean_flights.py +286 -0
  39. src/step2_extract_roster.py +316 -0
  40. src/step3_allocate_flights.py +1639 -0
  41. src/web/__init__.py +47 -0
  42. src/web/__main__.py +9 -0
  43. src/web/api/__init__.py +56 -0
  44. src/web/api/export.py +37 -0
  45. src/web/api/inputs.py +122 -0
  46. src/web/api/override_rows.py +138 -0
  47. src/web/api/pages.py +30 -0
  48. src/web/api/readbacks.py +72 -0
  49. src/web/api/recommender.py +72 -0
  50. src/web/api/runs.py +102 -0
  51. src/web/api/settings.py +201 -0
  52. src/web/api/zc.py +117 -0
  53. src/web/core/__init__.py +5 -0
  54. src/web/core/responses.py +91 -0
  55. src/web/core/router.py +167 -0
  56. src/web/core/static_files.py +85 -0
  57. src/web/overrides/__init__.py +66 -0
  58. src/web/overrides/airports.py +261 -0
  59. src/web/overrides/break_time.py +83 -0
  60. src/web/overrides/config_yaml.py +21 -0
  61. src/web/overrides/filters.py +187 -0
  62. src/web/overrides/rows.py +110 -0
  63. src/web/readback/__init__.py +67 -0
  64. src/web/readback/common.py +68 -0
  65. src/web/readback/dashboard.py +83 -0
  66. src/web/readback/planning.py +335 -0
  67. src/web/readback/session.py +158 -0
  68. src/web/readback/tables.py +163 -0
  69. src/web/runner.py +168 -0
  70. src/web/server.py +185 -0
  71. src/zc_store.py +221 -0
@@ -0,0 +1,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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flight-alloc = src.cli:main
@@ -0,0 +1 @@
1
+ src
src/__init__.py ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Step 4 allocator — pre-solve matrix construction, pair generation, and
2
+ post-solve assembly.
3
+
4
+ The CP-SAT model itself lives in ``src.solver.allocator_cpsat``;
5
+ this package holds the deterministic plumbing on either side.
6
+ """
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
+ ]