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.
@@ -0,0 +1 @@
1
+ """Cost model: generalized cost in money-equivalent terms (DESIGN 8)."""
@@ -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)