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,187 @@
1
+ """User-defined extraction filters, stored under the top-level
2
+ ``extraction_filters:`` key in ``configs/config.yml``.
3
+
4
+ Each filter is a dict with any subset of {ac_type, ac_owner, ac, dep,
5
+ arr, custom_header, custom_value}. Empty fields are skipped during
6
+ matching (AND semantics across specified fields, case-insensitive
7
+ exact). Writes splice the block in place so surrounding comments live.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ import yaml
15
+
16
+ from .config_yaml import CONFIG_YML, config_write_lock
17
+
18
+ #: The fields a filter may specify. Anything else in a submitted dict is
19
+ #: dropped by _normalize_filter.
20
+ _FILTER_FIELDS = (
21
+ "ac_type", "ac_owner", "ac", "dep", "arr",
22
+ "custom_header", "custom_value",
23
+ )
24
+
25
+
26
+
27
+
28
+ def list_extraction_filters() -> list[dict[str, str]]:
29
+ """Read current extraction filters from config.yml. Only returns
30
+ fields that have a non-empty value — pad-with-empties would
31
+ produce YAML `''` scalars on round-trip and corrupt the file."""
32
+ raw = yaml.safe_load(CONFIG_YML.read_text(encoding="utf-8"))
33
+ filters = raw.get("extraction_filters") or []
34
+ out: list[dict[str, str]] = []
35
+ for f in filters:
36
+ if not isinstance(f, dict):
37
+ continue
38
+ entry: dict[str, str] = {}
39
+ for k in _FILTER_FIELDS:
40
+ v = f.get(k)
41
+ if v is None:
42
+ continue
43
+ sv = str(v).strip()
44
+ if sv:
45
+ entry[k] = sv
46
+ if entry:
47
+ out.append(entry)
48
+ return out
49
+
50
+
51
+ def _normalize_filter(filt: dict[str, str]) -> dict[str, str]:
52
+ """Strip whitespace and uppercase the short codes (airport / type).
53
+ Custom header/value are stripped only (case preserved for header
54
+ since that needs to match the SV-portal column header exactly).
55
+ """
56
+ n: dict[str, str] = {}
57
+ for key in _FILTER_FIELDS:
58
+ v = str(filt.get(key) or "").strip()
59
+ if not v:
60
+ continue
61
+ if key in ("ac_type", "ac_owner", "ac", "dep", "arr"):
62
+ n[key] = v.upper()
63
+ else:
64
+ n[key] = v
65
+ if not n:
66
+ raise ValueError("filter has no fields set — at least one of "
67
+ f"{_FILTER_FIELDS} must be non-empty")
68
+ # If only one of custom_header / custom_value is set, that's a
69
+ # config error — both required together.
70
+ if ("custom_header" in n) ^ ("custom_value" in n):
71
+ raise ValueError(
72
+ "custom_header and custom_value must be provided together"
73
+ )
74
+ return n
75
+
76
+
77
+ def _yaml_scalar(v: str) -> str:
78
+ """Render a string scalar safely for inline YAML. Plain strings
79
+ are written as-is; if the value contains characters YAML would
80
+ interpret (`:`, `#`, `&`, `*`, leading `-`, etc.) we quote it.
81
+ yaml.safe_dump is NOT used here because it appends a `\n...\n`
82
+ document-end marker for scalars, which corrupts the splice.
83
+ """
84
+ s = str(v)
85
+ needs_quote = (
86
+ s == ""
87
+ or any(c in s for c in ":#&*!|>%@`")
88
+ or s[0] in "-?,[]{}"
89
+ or s.lower() in ("yes", "no", "true", "false", "null", "~", "on", "off")
90
+ )
91
+ if needs_quote:
92
+ # Single-quoted YAML: escape any internal single quotes by doubling.
93
+ escaped = s.replace("'", "''")
94
+ return f"'{escaped}'"
95
+ return s
96
+
97
+
98
+ def _render_filters_block(filters: list[dict[str, str]]) -> str:
99
+ """Format a list of filter dicts as YAML under the top-level
100
+ ``extraction_filters:`` key. Always terminates with a newline.
101
+ """
102
+ if not filters:
103
+ return "extraction_filters: []\n"
104
+ out = "extraction_filters:\n"
105
+ for entry in filters:
106
+ lines = [f"{k}: {_yaml_scalar(v)}" for k, v in entry.items()]
107
+ out += f" - {lines[0]}\n"
108
+ for ln in lines[1:]:
109
+ out += f" {ln}\n"
110
+ return out
111
+
112
+
113
+ def _splice_filters_into_config(filters: list[dict[str, str]]) -> None:
114
+ """Write ``filters`` back into ``configs/config.yml``'s
115
+ ``extraction_filters:`` section, leaving every other byte of the
116
+ file intact (comments, whitespace, order)."""
117
+ raw_text = CONFIG_YML.read_text(encoding="utf-8")
118
+ new_block = _render_filters_block(filters).rstrip("\n") + "\n"
119
+ # Match the extraction_filters key + its current value (whether
120
+ # `[]`, `:` followed by a list, or just `:` followed by a blank).
121
+ # Use ^extraction_filters\s*: as anchor; consume up to (but not
122
+ # including) the next top-level key OR end of file.
123
+ pattern = re.compile(
124
+ r"^extraction_filters\s*:[^\n]*\n(?:[ \t-].*\n)*",
125
+ re.MULTILINE,
126
+ )
127
+ if pattern.search(raw_text):
128
+ new_text = pattern.sub(new_block, raw_text, count=1)
129
+ else:
130
+ # Defensive — config.yml ships with the key, so this branch
131
+ # is for upgrades where it was missing.
132
+ new_text = raw_text.rstrip() + "\n\n" + new_block
133
+ CONFIG_YML.write_text(new_text, encoding="utf-8")
134
+
135
+
136
+ def add_extraction_filter(filt: dict[str, str]) -> dict[str, str]:
137
+ """Validate and append one extraction filter to config.yml.
138
+ Returns the normalised dict that was written. Idempotent — a
139
+ duplicate filter is a no-op (same dict returned)."""
140
+ norm = _normalize_filter(filt)
141
+ with config_write_lock:
142
+ existing = list_extraction_filters()
143
+ # Idempotent: skip duplicates (compare non-empty fields only).
144
+ for e in existing:
145
+ if {k: v for k, v in e.items() if v} == norm:
146
+ return norm
147
+ existing.append(norm)
148
+ _splice_filters_into_config(existing)
149
+ return norm
150
+
151
+
152
+ def remove_extraction_filter(idx: int) -> dict[str, str]:
153
+ """Remove the filter at 1-based index ``idx``. Returns the removed
154
+ filter dict. Raises IndexError if out of range."""
155
+ with config_write_lock:
156
+ existing = list_extraction_filters()
157
+ if idx < 1 or idx > len(existing):
158
+ raise IndexError(
159
+ f"extraction_filter index {idx} out of range (1..{len(existing)})"
160
+ )
161
+ removed = existing.pop(idx - 1)
162
+ _splice_filters_into_config(existing)
163
+ return removed
164
+
165
+
166
+ def update_extraction_filter(
167
+ idx: int, new_filter: dict[str, str],
168
+ ) -> tuple[dict[str, str], dict[str, str]]:
169
+ """Replace the filter at 1-based index ``idx`` with ``new_filter``
170
+ atomically (single config write). Returns (old, new) so the caller
171
+ can render a diff. Raises IndexError if out of range, ValueError
172
+ if the new filter is invalid (e.g. all fields empty)."""
173
+ norm = _normalize_filter(new_filter)
174
+ with config_write_lock:
175
+ existing = list_extraction_filters()
176
+ if idx < 1 or idx > len(existing):
177
+ raise IndexError(
178
+ f"extraction_filter index {idx} out of range (1..{len(existing)})"
179
+ )
180
+ old = existing[idx - 1]
181
+ if {k: v for k, v in old.items() if v} == norm:
182
+ return old, norm # idempotent — no write needed
183
+ existing[idx - 1] = norm
184
+ _splice_filters_into_config(existing)
185
+ return old, norm
186
+
187
+
@@ -0,0 +1,110 @@
1
+ """Override-row mutation: the drawer's table rows, the recommender's
2
+ one-click fixes, and the staged add/remove forms.
3
+
4
+ Override rows live in ``state.STATE.overrides`` as dicts keyed by the
5
+ column names in ``state.OVERRIDE_HEADERS``. Each row type uses its own
6
+ subset of those keys (a ``p2f`` row cares about employee + shift; a
7
+ ``waive_h10_pair`` row about flight + std + other_std), so nothing here
8
+ validates the shape — the reader for each type does that at solve time,
9
+ exactly as it would for a row typed in by hand.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ...state import OVERRIDE_HEADERS, OVERRIDE_HEADERS_VISIBLE, AppState
15
+
16
+ # Which columns each recommender "kind" must supply.
17
+ _PHASE_R_PAYLOAD_COLS = {
18
+ "waive_h10_pair": ("flight", "std", "employee", "other_std"),
19
+ "raise_cap": ("flight", "std", "employee"),
20
+ "skip_p2f_buffer": ("flight", "std", "employee"),
21
+ "skip_intl_removal": ("flight", "std"),
22
+ }
23
+
24
+
25
+ def add_override(state: AppState, values: list[str]) -> int:
26
+ """Append a drawer row. ``values`` are positional against
27
+ ``OVERRIDE_HEADERS_VISIBLE``. Returns the new row's index."""
28
+ row = {
29
+ h: v for h, v in zip(OVERRIDE_HEADERS_VISIBLE, values)
30
+ if str(v).strip()
31
+ }
32
+ return state.add_override(row)
33
+
34
+
35
+ def update_override(state: AppState, index: int, values: list[str]) -> None:
36
+ """Overwrite the visible cells of row ``index``, preserving any
37
+ type-specific fields (Phase-R flight / std) already on the row."""
38
+ with state.lock:
39
+ if not 0 <= index < len(state.overrides):
40
+ raise IndexError(f"no override row at index {index}")
41
+ merged = dict(state.overrides[index])
42
+ for h, v in zip(OVERRIDE_HEADERS_VISIBLE, values):
43
+ s = str(v).strip()
44
+ if s:
45
+ merged[h] = s
46
+ else:
47
+ merged.pop(h, None)
48
+ state.update_override(index, merged)
49
+
50
+
51
+ def delete_override(state: AppState, index: int) -> None:
52
+ state.delete_override(index)
53
+
54
+
55
+ def clear_all_overrides(state: AppState) -> int:
56
+ """Drop every override row. Returns the count removed.
57
+
58
+ A fresh Plan resets the list so leftover nominations from the
59
+ previous cycle don't silently shadow the new roster's handler picks.
60
+ """
61
+ with state.lock:
62
+ n = len(state.overrides)
63
+ state.overrides = []
64
+ return n
65
+
66
+
67
+ def apply_phase_r_override(
68
+ state: AppState, kind: str, payload: dict[str, str],
69
+ ) -> int:
70
+ """Write a Phase-R recommender suggestion as an override row.
71
+
72
+ ``kind`` is one of waive_h10_pair / raise_cap / skip_p2f_buffer /
73
+ skip_intl_removal. Returns the new row's index. Raises ValueError on
74
+ an unknown kind and KeyError when a required field is missing.
75
+ """
76
+ if kind not in _PHASE_R_PAYLOAD_COLS:
77
+ raise ValueError(f"unknown recommender kind: {kind!r}")
78
+ row: dict[str, str] = {"type": kind}
79
+ for col in _PHASE_R_PAYLOAD_COLS[kind]:
80
+ value = str(payload.get(col, "")).strip()
81
+ if not value:
82
+ raise KeyError(f"missing payload field {col!r} for kind {kind!r}")
83
+ row[col] = value
84
+ return state.add_override(row)
85
+
86
+
87
+ def write_staged_override(
88
+ state: AppState, op_type: str, payload: dict[str, str],
89
+ ) -> int:
90
+ """Write a staged-form row (Add flight / Remove flight / Add staff /
91
+ Remove staff). The caller is expected to follow up with
92
+ ``staged_overrides.apply_staged_overrides`` so the change shows on
93
+ the dashboard immediately.
94
+
95
+ Unknown payload keys are dropped rather than stored, so a typo in a
96
+ form field can't quietly become a column nothing reads.
97
+ """
98
+ from ...schemas import STAGED_FORM_OVERRIDE_TYPES
99
+ valid = {t.value for t in STAGED_FORM_OVERRIDE_TYPES}
100
+ if op_type not in valid:
101
+ raise ValueError(
102
+ f"unknown op_type: {op_type!r}. Valid: {sorted(valid)}"
103
+ )
104
+ row: dict[str, str] = {"type": op_type}
105
+ for key, value in payload.items():
106
+ k = str(key).strip().lower()
107
+ if k in OVERRIDE_HEADERS and str(value).strip():
108
+ row[k] = str(value).strip()
109
+ return state.add_override(row)
110
+
@@ -0,0 +1,67 @@
1
+ """Serialise the in-memory state into the JSON shapes the UI needs.
2
+
3
+ Every function takes the ``AppState`` and returns plain dicts/lists
4
+ ready for ``json.dumps``. Nothing here reads a file, so a GET is cheap
5
+ and can re-serialise on every request.
6
+
7
+ The package is split by what the caller is looking at:
8
+
9
+ common run date / INTL codes / roster-liveness predicates
10
+ dashboard headline stat cards + the at-risk table
11
+ tables the per-tab row lists (allocations, workload, pairs, ...)
12
+ planning Plan summary, handlers, staffing, roster, shift bands
13
+ session uploads, override rows, SV-portal columns
14
+
15
+ Import the names from here, not from the sub-modules — this is the
16
+ stable surface the API layer binds its routes to.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from ...schemas import Severity
22
+ from .dashboard import read_dashboard
23
+ from .planning import (
24
+ read_handlers,
25
+ read_plan_summary,
26
+ read_roster_full,
27
+ read_shift_limits,
28
+ read_staff_names,
29
+ read_staffing,
30
+ )
31
+ from .session import (
32
+ read_inputs,
33
+ read_overrides,
34
+ read_roster_counts,
35
+ read_server_date,
36
+ read_sv_portal_columns,
37
+ )
38
+ from .tables import (
39
+ read_allocations,
40
+ read_pairs,
41
+ read_recommendations,
42
+ read_unallocated,
43
+ read_warnings,
44
+ read_workload,
45
+ )
46
+
47
+ __all__ = [
48
+ "Severity",
49
+ "read_allocations",
50
+ "read_dashboard",
51
+ "read_handlers",
52
+ "read_inputs",
53
+ "read_overrides",
54
+ "read_pairs",
55
+ "read_plan_summary",
56
+ "read_recommendations",
57
+ "read_roster_counts",
58
+ "read_roster_full",
59
+ "read_server_date",
60
+ "read_shift_limits",
61
+ "read_staff_names",
62
+ "read_staffing",
63
+ "read_sv_portal_columns",
64
+ "read_unallocated",
65
+ "read_warnings",
66
+ "read_workload",
67
+ ]
@@ -0,0 +1,68 @@
1
+ """Shared primitives for the readback modules.
2
+
3
+ Everything here is pure: it reads ``AppState`` (or the config file) and
4
+ returns plain values. Keeping it in one place means a new readback
5
+ module never has to re-derive the run date or the INTL code set.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ...schemas import CrewStatus
14
+ from ...state import AppState
15
+
16
+ # Absolute path to configs/config.yml relative to THIS source file, so a
17
+ # readback works whatever CWD the server was launched from.
18
+ CONFIG_PATH = Path(__file__).resolve().parents[3] / "configs" / "config.yml"
19
+
20
+ #: Canonical shift order used by every per-shift readback.
21
+ SHIFTS: tuple[str, ...] = ("M", "A", "N", "M1", "A1")
22
+
23
+
24
+ def intl_codes() -> set[str]:
25
+ from ...config import load_config
26
+ cfg = load_config(CONFIG_PATH)
27
+ return {c.upper() for c in cfg.io.sv_portal.international_airport_codes}
28
+
29
+
30
+ def run_date_iso(state: AppState) -> str:
31
+ return state.run_date.isoformat() if state.run_date else ""
32
+
33
+
34
+ def is_live(state: AppState, av: Any) -> bool:
35
+ """A roster row counts for the dashboard when it is assignable, sits
36
+ on a shift, and belongs to the run date."""
37
+ if not av.assignable or not av.current_shift:
38
+ return False
39
+ d_iso = run_date_iso(state)
40
+ return not d_iso or av.date.isoformat() == d_iso
41
+
42
+
43
+ def canonical_status(raw: str) -> CrewStatus | None:
44
+ """Resolve a status literal, tolerating the legacy slash-reversed
45
+ form (``P2F/M``) by sorting the parts alphabetically."""
46
+ try:
47
+ return CrewStatus(raw)
48
+ except ValueError:
49
+ pass
50
+ if "/" in raw:
51
+ canonical = "/".join(
52
+ sorted(p.strip().upper() for p in raw.split("/") if p.strip())
53
+ )
54
+ try:
55
+ return CrewStatus(canonical)
56
+ except ValueError:
57
+ return None
58
+ return None
59
+
60
+
61
+ __all__ = [
62
+ "CONFIG_PATH",
63
+ "SHIFTS",
64
+ "canonical_status",
65
+ "intl_codes",
66
+ "is_live",
67
+ "run_date_iso",
68
+ ]
@@ -0,0 +1,83 @@
1
+ """Dashboard headline readback — the stat cards and the at-risk table.
2
+
3
+ One function, deliberately: the dashboard is the aggregate view, so it
4
+ touches allocations, roster and warnings at once. Per-tab detail lives
5
+ in the sibling modules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ...state import AppState
13
+ from .common import run_date_iso
14
+
15
+
16
+
17
+ def read_dashboard(state: AppState) -> dict[str, Any]:
18
+ """Run-status block + the headline counts behind the stat cards."""
19
+ with state.lock:
20
+ # Per-class cards show CLEANED counts (flights to plan), so the
21
+ # numbers are real right after Plan rather than zeros that only
22
+ # fill in after Allocate.
23
+ by_class = {ops.value: len(rows) for ops, rows in state.cleaned.items()}
24
+ special_sub = {
25
+ "test": by_class.get("test", 0),
26
+ "ferry": by_class.get("ferry", 0),
27
+ "charter": by_class.get("charter", 0),
28
+ }
29
+
30
+ # "Flights allocated" counts allocation rows. A pre-plan-deferred
31
+ # row has empty staff but a populated planned_by — someone is
32
+ # preparing it, so it is not unallocated.
33
+ flights_total = len(state.allocations)
34
+
35
+ staff_at_risk: list[dict[str, Any]] = []
36
+ for w in state.workload:
37
+ # At risk = real cap violations only. Under-preferred is just
38
+ # a light load; above-preferred is informational.
39
+ joined = "; ".join(w.violations)
40
+ lowered = joined.lower()
41
+ if not any(p in lowered for p in (
42
+ "above hard cap", "exceeds cap", "exceeds hard cap",
43
+ "over hard cap",
44
+ )):
45
+ continue
46
+ staff_at_risk.append({
47
+ "employee_id": w.employee_id,
48
+ "name": w.name,
49
+ "shift": w.shift or "",
50
+ "actual": w.actual,
51
+ "target_preferred": w.target_preferred,
52
+ "hard_cap": w.hard_cap,
53
+ "violations": joined,
54
+ })
55
+
56
+ warnings_total = len(state.warnings)
57
+
58
+ return {
59
+ "run_date": run_date_iso(state),
60
+ "last_run": state.last_run,
61
+ "mode": state.mode,
62
+ "solver_status": state.solver_status,
63
+ "duration_s": state.duration_s,
64
+ "has_inputs": not state.missing_inputs(),
65
+ "missing_inputs": state.missing_inputs(),
66
+ "stats": {
67
+ "flights_total": flights_total,
68
+ "flights_unallocated": len(state.unallocated),
69
+ "day_ops": by_class.get("day", 0),
70
+ "night_ops": by_class.get("night", 0),
71
+ "p2f": by_class.get("p2f", 0),
72
+ "special_ops": sum(special_sub.values()),
73
+ "special_ops_test": special_sub["test"],
74
+ "special_ops_ferry": special_sub["ferry"],
75
+ "special_ops_charter": special_sub["charter"],
76
+ "staff_total": len(state.workload),
77
+ "staff_at_risk": len(staff_at_risk),
78
+ "warnings_total": warnings_total,
79
+ },
80
+ "staff_at_risk": staff_at_risk[:10],
81
+ }
82
+
83
+