layover 0.1.0.dev0__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.
- layover/__init__.py +3 -0
- layover/cli.py +1137 -0
- layover/cost/__init__.py +1 -0
- layover/cost/borders.py +449 -0
- layover/cost/connection.py +173 -0
- layover/cost/constraints.py +213 -0
- layover/cost/curve.py +145 -0
- layover/cost/delay.py +173 -0
- layover/cost/generalized.py +1207 -0
- layover/cost/lounge.py +110 -0
- layover/currency.py +166 -0
- layover/intent.py +1016 -0
- layover/interact/__init__.py +1 -0
- layover/interact/assumptions.py +120 -0
- layover/models.py +363 -0
- layover/money.py +112 -0
- layover/oracles/__init__.py +1 -0
- layover/oracles/api/__init__.py +1 -0
- layover/oracles/api/azair.py +480 -0
- layover/oracles/api/gf_parse.py +344 -0
- layover/oracles/api/google_flights.py +322 -0
- layover/oracles/api/protobuf.py +110 -0
- layover/oracles/api/serpapi_flights.py +415 -0
- layover/oracles/base.py +224 -0
- layover/oracles/consent.py +199 -0
- layover/oracles/knowledge.py +208 -0
- layover/oracles/record.py +98 -0
- layover/oracles/transport.py +429 -0
- layover/prefs/__init__.py +1 -0
- layover/prefs/profile.py +333 -0
- layover/prefs/travelers.py +125 -0
- layover/session.py +328 -0
- layover/solver/__init__.py +1 -0
- layover/solver/budget.py +208 -0
- layover/solver/probes.py +105 -0
- layover/solver/search.py +166 -0
- layover-0.1.0.dev0.dist-info/METADATA +365 -0
- layover-0.1.0.dev0.dist-info/RECORD +41 -0
- layover-0.1.0.dev0.dist-info/WHEEL +4 -0
- layover-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- layover-0.1.0.dev0.dist-info/licenses/LICENSE +28 -0
layover/cost/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Cost model: generalized cost in money-equivalent terms (DESIGN 8)."""
|
layover/cost/borders.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Borders: transit regimes, passports and authorizations (DESIGN §8.6).
|
|
2
|
+
|
|
3
|
+
Two connections with the same clock gap are not equally feasible. Some airports
|
|
4
|
+
move international passengers airside with a security re-screen; the United States
|
|
5
|
+
has no airside transit at all, so a "connection" there is immigration, customs,
|
|
6
|
+
baggage re-claim, re-check and re-screening.
|
|
7
|
+
|
|
8
|
+
Resolved **per traveller**, then combined: added time takes the party maximum (a
|
|
9
|
+
couple clears immigration together, at the slower queue), fees and applications are
|
|
10
|
+
per person, and any traveller who cannot be admitted makes the routing infeasible
|
|
11
|
+
for everyone.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import date, timedelta
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from layover.prefs.travelers import Party, Traveler
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Regime(StrEnum):
|
|
26
|
+
AIRSIDE = "airside"
|
|
27
|
+
"""International-to-international transit without immigration."""
|
|
28
|
+
|
|
29
|
+
AIRSIDE_WITHIN = "airside_within"
|
|
30
|
+
"""No control for members of the bloc (Schengen internal legs)."""
|
|
31
|
+
|
|
32
|
+
ENTRY_REQUIRED = "entry_required"
|
|
33
|
+
"""Full entry and exit: immigration, bags, re-screening (US, CA)."""
|
|
34
|
+
|
|
35
|
+
UNKNOWN = "unknown"
|
|
36
|
+
"""Not in the table. Asked, never assumed airside — guessing wrong here costs
|
|
37
|
+
a flight, not money."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class AuthorizationRule:
|
|
42
|
+
kind: str
|
|
43
|
+
fee: float
|
|
44
|
+
currency: str = "USD"
|
|
45
|
+
effort_hours: float = 1.0
|
|
46
|
+
lead_time_days: int = 3
|
|
47
|
+
apply_now_days: int = 7
|
|
48
|
+
validity_years: float = 2.0
|
|
49
|
+
amortize: bool = True
|
|
50
|
+
expected_uses: int = 2
|
|
51
|
+
eligible_nationalities: frozenset[str] | None = None
|
|
52
|
+
"""None = anyone may apply. Otherwise only these nationalities are eligible —
|
|
53
|
+
a Brazilian passport cannot get an ESTA at all, which is a different situation
|
|
54
|
+
from not having one yet."""
|
|
55
|
+
exempt_nationalities: frozenset[str] = field(default_factory=frozenset)
|
|
56
|
+
"""Nationalities needing no authorization at all (Irish citizens for the UK
|
|
57
|
+
ETA). Citizens of the destination itself are exempt without being listed —
|
|
58
|
+
nobody needs permission to enter their own country."""
|
|
59
|
+
exempt_bloc_members: bool = False
|
|
60
|
+
"""True when the destination's whole bloc is exempt: an EU/EEA citizen needs no
|
|
61
|
+
ETIAS in any member state, because that is freedom of movement rather than a
|
|
62
|
+
per-country courtesy. False for the UK ETA, which does apply to EU nationals."""
|
|
63
|
+
ineligible_note: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class CountryRule:
|
|
68
|
+
country: str
|
|
69
|
+
international_transit: Regime
|
|
70
|
+
added_time_min: float = 0.0
|
|
71
|
+
entry_from_outside_min: float = 0.0
|
|
72
|
+
requires_authorization: str | None = None
|
|
73
|
+
bloc: str | None = None
|
|
74
|
+
"""e.g. "schengen" — members transit internally without control."""
|
|
75
|
+
bloc_members: frozenset[str] = field(default_factory=frozenset)
|
|
76
|
+
own_nationals_must_use_own_passport: bool = False
|
|
77
|
+
as_of: str = "unknown"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RegimeTable:
|
|
81
|
+
"""Country transit rules plus authorization definitions."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
countries: dict[str, CountryRule],
|
|
86
|
+
authorizations: dict[str, AuthorizationRule],
|
|
87
|
+
*,
|
|
88
|
+
as_of: str = "unknown",
|
|
89
|
+
) -> None:
|
|
90
|
+
self.countries = countries
|
|
91
|
+
self.authorizations = authorizations
|
|
92
|
+
self.as_of = as_of
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_toml(cls, regimes_path: Path, authorizations_path: Path) -> RegimeTable:
|
|
96
|
+
raw = tomllib.loads(regimes_path.read_text())
|
|
97
|
+
blocs = {
|
|
98
|
+
name: frozenset(x.upper() for x in (body.get("members") or []))
|
|
99
|
+
for name, body in (raw.get("bloc") or {}).items()
|
|
100
|
+
}
|
|
101
|
+
countries: dict[str, CountryRule] = {}
|
|
102
|
+
for code, body in (raw.get("country") or {}).items():
|
|
103
|
+
bloc = body.get("bloc")
|
|
104
|
+
members = blocs.get(bloc, frozenset()) if bloc else frozenset()
|
|
105
|
+
countries[code.upper()] = CountryRule(
|
|
106
|
+
country=code.upper(),
|
|
107
|
+
international_transit=Regime(body["international_transit"]),
|
|
108
|
+
added_time_min=float(body.get("added_time_min", 0.0)),
|
|
109
|
+
entry_from_outside_min=float(body.get("entry_from_outside_min", 0.0)),
|
|
110
|
+
requires_authorization=body.get("requires_authorization"),
|
|
111
|
+
bloc=bloc,
|
|
112
|
+
bloc_members=members
|
|
113
|
+
| frozenset(x.upper() for x in body.get("bloc_members", [])),
|
|
114
|
+
own_nationals_must_use_own_passport=bool(
|
|
115
|
+
body.get("own_nationals_must_use_own_passport", False)
|
|
116
|
+
),
|
|
117
|
+
as_of=str(body.get("as_of", raw.get("as_of", "unknown"))),
|
|
118
|
+
)
|
|
119
|
+
auth_raw = tomllib.loads(authorizations_path.read_text())
|
|
120
|
+
authorizations: dict[str, AuthorizationRule] = {}
|
|
121
|
+
for kind, body in (auth_raw.get("authorization") or {}).items():
|
|
122
|
+
eligible = body.get("eligible_nationalities")
|
|
123
|
+
authorizations[kind] = AuthorizationRule(
|
|
124
|
+
kind=kind,
|
|
125
|
+
fee=float(body["fee"]),
|
|
126
|
+
currency=str(body.get("currency", "USD")),
|
|
127
|
+
effort_hours=float(body.get("effort_hours", 1.0)),
|
|
128
|
+
lead_time_days=int(body.get("lead_time_days", 3)),
|
|
129
|
+
apply_now_days=int(body.get("apply_now_days", 7)),
|
|
130
|
+
validity_years=float(body.get("validity_years", 2.0)),
|
|
131
|
+
amortize=bool(body.get("amortize", True)),
|
|
132
|
+
expected_uses=int(body.get("expected_uses", 2)),
|
|
133
|
+
eligible_nationalities=(
|
|
134
|
+
frozenset(x.upper() for x in eligible) if eligible else None
|
|
135
|
+
),
|
|
136
|
+
exempt_nationalities=frozenset(
|
|
137
|
+
x.upper() for x in body.get("exempt_nationalities", [])
|
|
138
|
+
),
|
|
139
|
+
exempt_bloc_members=bool(body.get("exempt_bloc_members", False)),
|
|
140
|
+
ineligible_note=str(body.get("ineligible_note", "")),
|
|
141
|
+
)
|
|
142
|
+
return cls(countries, authorizations, as_of=str(raw.get("as_of", "unknown")))
|
|
143
|
+
|
|
144
|
+
def rule_for(self, country: str) -> CountryRule:
|
|
145
|
+
return self.countries.get(
|
|
146
|
+
country.upper(),
|
|
147
|
+
CountryRule(country=country.upper(), international_transit=Regime.UNKNOWN),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AuthorizationStatus(StrEnum):
|
|
152
|
+
NOT_NEEDED = "not_needed"
|
|
153
|
+
HELD = "held"
|
|
154
|
+
OBTAINABLE = "obtainable"
|
|
155
|
+
TOO_LATE = "too_late"
|
|
156
|
+
"""Inside the official lead time: a risk, not a plan."""
|
|
157
|
+
INELIGIBLE = "ineligible"
|
|
158
|
+
"""This passport cannot get this authorization at all (BR for ESTA)."""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True, slots=True)
|
|
162
|
+
class TravelerBorder:
|
|
163
|
+
"""One traveller's exposure at one border."""
|
|
164
|
+
|
|
165
|
+
traveler_id: str
|
|
166
|
+
country: str
|
|
167
|
+
regime: Regime
|
|
168
|
+
passport_used: str | None
|
|
169
|
+
added_time_min: float
|
|
170
|
+
authorization_kind: str | None = None
|
|
171
|
+
authorization_status: AuthorizationStatus = AuthorizationStatus.NOT_NEEDED
|
|
172
|
+
money_cost: float = 0.0
|
|
173
|
+
"""Fee plus effort, already amortized when the rule says so."""
|
|
174
|
+
money_cost_full: float = 0.0
|
|
175
|
+
"""The un-amortized figure: what actually leaves the account."""
|
|
176
|
+
effort_hours: float = 0.0
|
|
177
|
+
note: str = ""
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def feasible(self) -> bool:
|
|
181
|
+
return self.authorization_status not in (
|
|
182
|
+
AuthorizationStatus.TOO_LATE,
|
|
183
|
+
AuthorizationStatus.INELIGIBLE,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class PartyBorder:
|
|
189
|
+
"""The party's combined exposure at one border."""
|
|
190
|
+
|
|
191
|
+
country: str
|
|
192
|
+
regime: Regime
|
|
193
|
+
per_traveler: list[TravelerBorder]
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def added_time_min(self) -> float:
|
|
197
|
+
"""Party maximum: you clear immigration together, at the slower queue."""
|
|
198
|
+
return max((b.added_time_min for b in self.per_traveler), default=0.0)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def money_cost(self) -> float:
|
|
202
|
+
"""Per person, summed."""
|
|
203
|
+
return sum(b.money_cost for b in self.per_traveler)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def money_cost_full(self) -> float:
|
|
207
|
+
return sum(b.money_cost_full for b in self.per_traveler)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def effort_hours(self) -> float:
|
|
211
|
+
return sum(b.effort_hours for b in self.per_traveler)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def feasible(self) -> bool:
|
|
215
|
+
return all(b.feasible for b in self.per_traveler)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def blocking_notes(self) -> list[str]:
|
|
219
|
+
return [b.note for b in self.per_traveler if not b.feasible]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _choose_passport(traveler: Traveler, rule: CountryRule, day: date) -> str | None:
|
|
223
|
+
"""Pick the cheapest admissible passport for one border.
|
|
224
|
+
|
|
225
|
+
Preference order: a passport of the destination country itself (and several
|
|
226
|
+
countries require their citizens to use it), then a bloc member's passport that
|
|
227
|
+
removes the control entirely, then whatever is valid.
|
|
228
|
+
"""
|
|
229
|
+
valid = [p.nationality for p in traveler.passports if p.valid_on(day)]
|
|
230
|
+
if not valid:
|
|
231
|
+
return None
|
|
232
|
+
if rule.country in valid:
|
|
233
|
+
return rule.country
|
|
234
|
+
bloc = [n for n in valid if n in rule.bloc_members]
|
|
235
|
+
if bloc:
|
|
236
|
+
return bloc[0]
|
|
237
|
+
return valid[0]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def resolve_traveler(
|
|
241
|
+
traveler: Traveler,
|
|
242
|
+
country: str,
|
|
243
|
+
*,
|
|
244
|
+
table: RegimeTable,
|
|
245
|
+
departure: date,
|
|
246
|
+
wage_rate_per_hour: float,
|
|
247
|
+
is_transit: bool = True,
|
|
248
|
+
) -> TravelerBorder:
|
|
249
|
+
"""Work out what this border costs this traveller."""
|
|
250
|
+
rule = table.rule_for(country)
|
|
251
|
+
passport = _choose_passport(traveler, rule, departure)
|
|
252
|
+
|
|
253
|
+
if passport is None:
|
|
254
|
+
return TravelerBorder(
|
|
255
|
+
traveler.id,
|
|
256
|
+
rule.country,
|
|
257
|
+
rule.international_transit,
|
|
258
|
+
None,
|
|
259
|
+
0.0,
|
|
260
|
+
authorization_status=AuthorizationStatus.INELIGIBLE,
|
|
261
|
+
note=f"{traveler.id} has no passport valid on {departure}",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Time first, authorization second — and the authorization question is asked on
|
|
265
|
+
# every path, because a traveller can be waved through a Schengen-internal
|
|
266
|
+
# border and still need an authorization elsewhere.
|
|
267
|
+
regime = rule.international_transit
|
|
268
|
+
time_note = ""
|
|
269
|
+
if regime is Regime.AIRSIDE_WITHIN:
|
|
270
|
+
if passport in rule.bloc_members:
|
|
271
|
+
added = 0.0
|
|
272
|
+
time_note = f"{passport} passport: no control ({rule.bloc or 'bloc'} internal)"
|
|
273
|
+
else:
|
|
274
|
+
regime = Regime.ENTRY_REQUIRED
|
|
275
|
+
added = rule.entry_from_outside_min
|
|
276
|
+
time_note = f"entry into {rule.bloc or rule.country} from outside: {added:.0f}m"
|
|
277
|
+
else:
|
|
278
|
+
added = (
|
|
279
|
+
rule.added_time_min
|
|
280
|
+
if is_transit
|
|
281
|
+
else rule.entry_from_outside_min or rule.added_time_min
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if rule.requires_authorization is None:
|
|
285
|
+
return TravelerBorder(
|
|
286
|
+
traveler.id, rule.country, regime, passport, added, note=time_note
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
auth = table.authorizations.get(rule.requires_authorization)
|
|
290
|
+
if auth is None:
|
|
291
|
+
return TravelerBorder(
|
|
292
|
+
traveler.id,
|
|
293
|
+
rule.country,
|
|
294
|
+
Regime.UNKNOWN,
|
|
295
|
+
passport,
|
|
296
|
+
added,
|
|
297
|
+
authorization_kind=rule.requires_authorization,
|
|
298
|
+
authorization_status=AuthorizationStatus.NOT_NEEDED,
|
|
299
|
+
note=f"authorization {rule.requires_authorization!r} not in the table — verify",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Exemptions, checked before "do they hold one?" — an exempt traveller has
|
|
303
|
+
# nothing to hold:
|
|
304
|
+
# * citizens of the destination (nobody needs permission to come home),
|
|
305
|
+
# * whole blocs where freedom of movement applies (an EU/EEA citizen needs no
|
|
306
|
+
# ETIAS in *any* member state, not merely their own),
|
|
307
|
+
# * named nationalities exempt by treaty (Irish citizens for the UK ETA).
|
|
308
|
+
# Bloc exemption is per-authorization, not automatic: the UK ETA does apply to
|
|
309
|
+
# EU nationals, so `exempt_bloc_members` is false there.
|
|
310
|
+
exempt = auth.exempt_nationalities | {rule.country}
|
|
311
|
+
if auth.exempt_bloc_members:
|
|
312
|
+
exempt |= rule.bloc_members
|
|
313
|
+
exempt_held = [n for n in traveler.nationalities if n in exempt]
|
|
314
|
+
if exempt_held:
|
|
315
|
+
return TravelerBorder(
|
|
316
|
+
traveler.id,
|
|
317
|
+
rule.country,
|
|
318
|
+
regime,
|
|
319
|
+
exempt_held[0],
|
|
320
|
+
added,
|
|
321
|
+
authorization_kind=auth.kind,
|
|
322
|
+
authorization_status=AuthorizationStatus.NOT_NEEDED,
|
|
323
|
+
note=f"{exempt_held[0]} passport: no {auth.kind.upper()} required",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if traveler.holds(auth.kind, passport, departure):
|
|
327
|
+
return TravelerBorder(
|
|
328
|
+
traveler.id,
|
|
329
|
+
rule.country,
|
|
330
|
+
regime,
|
|
331
|
+
passport,
|
|
332
|
+
added,
|
|
333
|
+
authorization_kind=auth.kind,
|
|
334
|
+
authorization_status=AuthorizationStatus.HELD,
|
|
335
|
+
note=f"{auth.kind.upper()} held on the {passport} passport",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Some passports cannot get the authorization at all — a different problem from
|
|
339
|
+
# simply not having applied yet, and one that a second passport may solve.
|
|
340
|
+
eligible_passports = [
|
|
341
|
+
p.nationality
|
|
342
|
+
for p in traveler.passports
|
|
343
|
+
if p.valid_on(departure)
|
|
344
|
+
and (
|
|
345
|
+
auth.eligible_nationalities is None or p.nationality in auth.eligible_nationalities
|
|
346
|
+
)
|
|
347
|
+
]
|
|
348
|
+
if not eligible_passports:
|
|
349
|
+
return TravelerBorder(
|
|
350
|
+
traveler.id,
|
|
351
|
+
rule.country,
|
|
352
|
+
regime,
|
|
353
|
+
passport,
|
|
354
|
+
added,
|
|
355
|
+
authorization_kind=auth.kind,
|
|
356
|
+
authorization_status=AuthorizationStatus.INELIGIBLE,
|
|
357
|
+
note=(
|
|
358
|
+
f"{traveler.id}: no passport eligible for {auth.kind.upper()} "
|
|
359
|
+
f"({', '.join(traveler.nationalities)}). {auth.ineligible_note}".strip()
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Prefer an eligible passport, even if another would have been marginally
|
|
364
|
+
# cheaper at the border: eligibility beats queue time.
|
|
365
|
+
if passport not in eligible_passports:
|
|
366
|
+
passport = eligible_passports[0]
|
|
367
|
+
if traveler.holds(auth.kind, passport, departure):
|
|
368
|
+
return TravelerBorder(
|
|
369
|
+
traveler.id,
|
|
370
|
+
rule.country,
|
|
371
|
+
regime,
|
|
372
|
+
passport,
|
|
373
|
+
added,
|
|
374
|
+
authorization_kind=auth.kind,
|
|
375
|
+
authorization_status=AuthorizationStatus.HELD,
|
|
376
|
+
note=f"{auth.kind.upper()} held on the {passport} passport",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
full = auth.fee + auth.effort_hours * wage_rate_per_hour
|
|
380
|
+
amortized = full / max(1, auth.expected_uses) if auth.amortize else full
|
|
381
|
+
days_out = (departure - date.today()).days
|
|
382
|
+
|
|
383
|
+
if days_out < auth.lead_time_days:
|
|
384
|
+
return TravelerBorder(
|
|
385
|
+
traveler.id,
|
|
386
|
+
rule.country,
|
|
387
|
+
regime,
|
|
388
|
+
passport,
|
|
389
|
+
added,
|
|
390
|
+
authorization_kind=auth.kind,
|
|
391
|
+
authorization_status=AuthorizationStatus.TOO_LATE,
|
|
392
|
+
money_cost=amortized,
|
|
393
|
+
money_cost_full=full,
|
|
394
|
+
effort_hours=auth.effort_hours,
|
|
395
|
+
note=(
|
|
396
|
+
f"{auth.kind.upper()} needs {auth.lead_time_days} days; departure is "
|
|
397
|
+
f"in {days_out} — infeasible, not merely expensive"
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
urgency = (
|
|
402
|
+
f" — apply now, {days_out} days to departure" if days_out <= auth.apply_now_days else ""
|
|
403
|
+
)
|
|
404
|
+
return TravelerBorder(
|
|
405
|
+
traveler.id,
|
|
406
|
+
rule.country,
|
|
407
|
+
regime,
|
|
408
|
+
passport,
|
|
409
|
+
added,
|
|
410
|
+
authorization_kind=auth.kind,
|
|
411
|
+
authorization_status=AuthorizationStatus.OBTAINABLE,
|
|
412
|
+
money_cost=amortized,
|
|
413
|
+
money_cost_full=full,
|
|
414
|
+
effort_hours=auth.effort_hours,
|
|
415
|
+
note=(
|
|
416
|
+
f"{auth.kind.upper()} needed on {passport} (~{auth.effort_hours:g}h, "
|
|
417
|
+
f"{full:.0f} one-off, {amortized:.0f} amortized over {auth.expected_uses} "
|
|
418
|
+
f"trips, valid {auth.validity_years:g}y){urgency}"
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def resolve_party(
|
|
424
|
+
party: Party,
|
|
425
|
+
country: str,
|
|
426
|
+
*,
|
|
427
|
+
table: RegimeTable,
|
|
428
|
+
departure: date,
|
|
429
|
+
wage_rate_per_hour: float,
|
|
430
|
+
is_transit: bool = True,
|
|
431
|
+
) -> PartyBorder:
|
|
432
|
+
per = [
|
|
433
|
+
resolve_traveler(
|
|
434
|
+
t,
|
|
435
|
+
country,
|
|
436
|
+
table=table,
|
|
437
|
+
departure=departure,
|
|
438
|
+
wage_rate_per_hour=wage_rate_per_hour,
|
|
439
|
+
is_transit=is_transit,
|
|
440
|
+
)
|
|
441
|
+
for t in party.travelers
|
|
442
|
+
]
|
|
443
|
+
regime = per[0].regime if per else Regime.UNKNOWN
|
|
444
|
+
return PartyBorder(country.upper(), regime, per)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def validity_window(rule: AuthorizationRule, granted: date) -> tuple[date, date]:
|
|
448
|
+
"""Convenience for the report: an authorization's coverage period."""
|
|
449
|
+
return granted, granted + timedelta(days=int(365.25 * rule.validity_years))
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Connection feasibility: required buffers, built rather than looked up (DESIGN §8.9).
|
|
2
|
+
|
|
3
|
+
A self-transfer — two tickets, no airline responsible, nothing through-checked —
|
|
4
|
+
needs materially more time than a protected connection, and how much more depends
|
|
5
|
+
on baggage, on the border regime in the middle of it, and on when the inbound leg
|
|
6
|
+
lands. The same gap that is comfortable at 09:00 is not comfortable at 19:00,
|
|
7
|
+
because the inbound is statistically 20 minutes later.
|
|
8
|
+
|
|
9
|
+
The composer calls this *before* pricing anything: an infeasible connection is not
|
|
10
|
+
an expensive plan, it is not a plan.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from layover.cost.delay import DelayEstimate
|
|
18
|
+
from layover.models import Connection, Protection
|
|
19
|
+
from layover.prefs.profile import FeasibilityPrefs
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class BufferBreakdown:
|
|
24
|
+
"""Why the required buffer is what it is — printed in the report verbatim."""
|
|
25
|
+
|
|
26
|
+
base_minutes: float
|
|
27
|
+
bag_recheck_minutes: float
|
|
28
|
+
border_minutes: float
|
|
29
|
+
delay_allowance_minutes: float
|
|
30
|
+
multiplier: float
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def required_minutes(self) -> float:
|
|
34
|
+
raw = (
|
|
35
|
+
self.base_minutes
|
|
36
|
+
+ self.bag_recheck_minutes
|
|
37
|
+
+ self.border_minutes
|
|
38
|
+
+ self.delay_allowance_minutes
|
|
39
|
+
)
|
|
40
|
+
return raw * self.multiplier
|
|
41
|
+
|
|
42
|
+
def explain(self) -> str:
|
|
43
|
+
parts = [f"base {self.base_minutes:.0f}m"]
|
|
44
|
+
if self.bag_recheck_minutes:
|
|
45
|
+
parts.append(f"bag re-check {self.bag_recheck_minutes:.0f}m")
|
|
46
|
+
if self.border_minutes:
|
|
47
|
+
parts.append(f"border {self.border_minutes:.0f}m")
|
|
48
|
+
if self.delay_allowance_minutes:
|
|
49
|
+
parts.append(f"delay allowance {self.delay_allowance_minutes:.0f}m")
|
|
50
|
+
tail = f" x{self.multiplier:g}" if self.multiplier != 1.0 else ""
|
|
51
|
+
return " + ".join(parts) + tail
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class FeasibilityVerdict:
|
|
56
|
+
feasible: bool
|
|
57
|
+
required_minutes: float
|
|
58
|
+
gap_minutes: float
|
|
59
|
+
breakdown: BufferBreakdown
|
|
60
|
+
reason: str = ""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def slack_minutes(self) -> float:
|
|
64
|
+
return self.gap_minutes - self.required_minutes
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def required_buffer(
|
|
68
|
+
connection: Connection,
|
|
69
|
+
prefs: FeasibilityPrefs,
|
|
70
|
+
*,
|
|
71
|
+
checked_bag: bool,
|
|
72
|
+
inbound_delay: DelayEstimate,
|
|
73
|
+
border_minutes: float = 0.0,
|
|
74
|
+
entry_required: bool = False,
|
|
75
|
+
carrier_mct_minutes: float = 45.0,
|
|
76
|
+
stake_multiplier: float = 1.0,
|
|
77
|
+
) -> BufferBreakdown:
|
|
78
|
+
"""Build the minimum connection time for one gap.
|
|
79
|
+
|
|
80
|
+
Protected connections use the airline's own MCT unmodified — the airline owns
|
|
81
|
+
the risk, and second-guessing its published minimum adds nothing.
|
|
82
|
+
|
|
83
|
+
`stake_multiplier` is how the stakes of a downstream hard constraint reach this
|
|
84
|
+
calculation: a connection standing between you and an unmissable event needs more
|
|
85
|
+
cushion than the same connection on a trip where being late costs a taxi. It
|
|
86
|
+
replaces the old per-trip "business mode" multiplier, which inflated every buffer
|
|
87
|
+
on a trip regardless of what was actually at risk.
|
|
88
|
+
"""
|
|
89
|
+
if connection.protection is Protection.SINGLE_TICKET:
|
|
90
|
+
return BufferBreakdown(carrier_mct_minutes, 0.0, 0.0, 0.0, 1.0)
|
|
91
|
+
|
|
92
|
+
if connection.airport_change:
|
|
93
|
+
base = prefs.airport_change_h * 60
|
|
94
|
+
elif entry_required:
|
|
95
|
+
base = max(
|
|
96
|
+
prefs.self_transfer_entry_required_h * 60,
|
|
97
|
+
prefs.self_transfer_bag_recheck_h * 60,
|
|
98
|
+
)
|
|
99
|
+
elif checked_bag:
|
|
100
|
+
base = prefs.self_transfer_bag_recheck_h * 60
|
|
101
|
+
else:
|
|
102
|
+
base = prefs.self_transfer_same_airport_h * 60
|
|
103
|
+
|
|
104
|
+
# A checked bag on a separate ticket means claim, queue, re-check. When the base
|
|
105
|
+
# already assumes a re-check (or a border forces one), don't charge it twice.
|
|
106
|
+
bag = 0.0
|
|
107
|
+
if checked_bag and not entry_required and base <= prefs.self_transfer_same_airport_h * 60:
|
|
108
|
+
bag = (prefs.self_transfer_bag_recheck_h - prefs.self_transfer_same_airport_h) * 60
|
|
109
|
+
|
|
110
|
+
# Twice the inbound's mean slip: an evening inbound needs about an hour more
|
|
111
|
+
# than a morning one, which is the honest way to encode "evenings are worse".
|
|
112
|
+
delay_allowance = 2 * inbound_delay.mean_min
|
|
113
|
+
|
|
114
|
+
return BufferBreakdown(
|
|
115
|
+
base_minutes=base,
|
|
116
|
+
bag_recheck_minutes=bag,
|
|
117
|
+
border_minutes=border_minutes,
|
|
118
|
+
delay_allowance_minutes=delay_allowance,
|
|
119
|
+
multiplier=stake_multiplier,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def assess(
|
|
124
|
+
connection: Connection,
|
|
125
|
+
prefs: FeasibilityPrefs,
|
|
126
|
+
*,
|
|
127
|
+
checked_bag: bool,
|
|
128
|
+
inbound_delay: DelayEstimate,
|
|
129
|
+
border_minutes: float = 0.0,
|
|
130
|
+
entry_required: bool = False,
|
|
131
|
+
carrier_mct_minutes: float = 45.0,
|
|
132
|
+
stopover_requested: bool = False,
|
|
133
|
+
stake_multiplier: float = 1.0,
|
|
134
|
+
) -> FeasibilityVerdict:
|
|
135
|
+
"""Decide whether a connection can be composed at all."""
|
|
136
|
+
breakdown = required_buffer(
|
|
137
|
+
connection,
|
|
138
|
+
prefs,
|
|
139
|
+
checked_bag=checked_bag,
|
|
140
|
+
inbound_delay=inbound_delay,
|
|
141
|
+
border_minutes=border_minutes,
|
|
142
|
+
entry_required=entry_required,
|
|
143
|
+
carrier_mct_minutes=carrier_mct_minutes,
|
|
144
|
+
stake_multiplier=stake_multiplier,
|
|
145
|
+
)
|
|
146
|
+
gap = connection.gap_minutes
|
|
147
|
+
required = breakdown.required_minutes
|
|
148
|
+
|
|
149
|
+
if not connection.is_protected:
|
|
150
|
+
if prefs.require_protected:
|
|
151
|
+
return FeasibilityVerdict(
|
|
152
|
+
False, required, gap, breakdown, "this run accepts protected itineraries only"
|
|
153
|
+
)
|
|
154
|
+
# The absolute floor is not negotiable downward: below it, no combination of
|
|
155
|
+
# optimism makes the connection real.
|
|
156
|
+
floor = prefs.absolute_floor_h * 60
|
|
157
|
+
if gap < floor:
|
|
158
|
+
return FeasibilityVerdict(
|
|
159
|
+
False, required, gap, breakdown, f"below the {prefs.absolute_floor_h:g}h floor"
|
|
160
|
+
)
|
|
161
|
+
# An airport change is no longer gated by a flag: it is priced (discomfort
|
|
162
|
+
# plus the ground leg) and has its own physical minimum, so the objective can
|
|
163
|
+
# decide. A requested stopover there makes it ordinary rather than overhead.
|
|
164
|
+
|
|
165
|
+
if gap < required:
|
|
166
|
+
return FeasibilityVerdict(
|
|
167
|
+
False,
|
|
168
|
+
required,
|
|
169
|
+
gap,
|
|
170
|
+
breakdown,
|
|
171
|
+
f"needs {required / 60:.1f}h ({breakdown.explain()}), has {gap / 60:.1f}h",
|
|
172
|
+
)
|
|
173
|
+
return FeasibilityVerdict(True, required, gap, breakdown)
|