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
|
@@ -0,0 +1,1207 @@
|
|
|
1
|
+
"""Generalized cost: the ranking objective (DESIGN §8).
|
|
2
|
+
|
|
3
|
+
generalized_cost = money_total # fares, ancillaries, hotels, lounges
|
|
4
|
+
+ time_cost # hours you don't get back, x wage rate
|
|
5
|
+
+ discomfort # unpleasantness, at its own price
|
|
6
|
+
+ stopover_net + flexibility + borders + ground
|
|
7
|
+
+ expected_risk # P(miss) x consequence, incl. stakes
|
|
8
|
+
|
|
9
|
+
Three families, kept apart because they answer different questions: **time** (what
|
|
10
|
+
an hour is worth), **discomfort** (how much you mind things — not multiplied by the
|
|
11
|
+
wage rate), and **risk** (probabilities times consequences, including what missing a
|
|
12
|
+
hard constraint would cost). Nothing is a "premium" any more, and nothing is a mode.
|
|
13
|
+
|
|
14
|
+
One scalar band, in the normalization currency, with every term itemized so a
|
|
15
|
+
ranking can be explained in euros and argued with. `money_total` — what actually
|
|
16
|
+
leaves the account — is always carried alongside and never hidden.
|
|
17
|
+
|
|
18
|
+
Pricing is a pure function of (plan, context), so the same plan can be priced under
|
|
19
|
+
different valuations without mutating anything.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable, Sequence
|
|
25
|
+
from dataclasses import dataclass, field, replace
|
|
26
|
+
from datetime import date, datetime, timedelta
|
|
27
|
+
from enum import StrEnum
|
|
28
|
+
|
|
29
|
+
from layover.cost import borders as borders_mod
|
|
30
|
+
from layover.cost import curve
|
|
31
|
+
from layover.cost import lounge as lounge_mod
|
|
32
|
+
from layover.cost.borders import PartyBorder, Regime, RegimeTable
|
|
33
|
+
from layover.cost.connection import FeasibilityVerdict, assess
|
|
34
|
+
from layover.cost.constraints import ConstraintKind, ConstraintViolation, HardConstraint
|
|
35
|
+
from layover.cost.constraints import check as check_constraints
|
|
36
|
+
from layover.cost.delay import DelayEstimate, DelayTable, PunctualityObservation, blend, p_miss
|
|
37
|
+
from layover.interact.assumptions import Assumption, AssumptionLog, Option
|
|
38
|
+
from layover.models import (
|
|
39
|
+
BaggageProfile,
|
|
40
|
+
Connection,
|
|
41
|
+
Protection,
|
|
42
|
+
RiskAnnotation,
|
|
43
|
+
RiskKind,
|
|
44
|
+
Segment,
|
|
45
|
+
TripPlan,
|
|
46
|
+
)
|
|
47
|
+
from layover.money import Band, band_sum
|
|
48
|
+
from layover.prefs.profile import PreferenceProfile
|
|
49
|
+
from layover.prefs.travelers import Party
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CostKind(StrEnum):
|
|
53
|
+
FARE = "fare"
|
|
54
|
+
DISCOMFORT = "discomfort"
|
|
55
|
+
ANCILLARY = "ancillary"
|
|
56
|
+
TRAVEL_TIME = "travel_time"
|
|
57
|
+
INVOLUNTARY_WAIT = "involuntary_wait"
|
|
58
|
+
STOPOVER = "stopover"
|
|
59
|
+
DATE_FLEXIBILITY = "date_flexibility"
|
|
60
|
+
DURATION_DEVIATION = "duration_deviation"
|
|
61
|
+
TIME_OF_DAY = "time_of_day"
|
|
62
|
+
EXPECTED_DELAY = "expected_delay"
|
|
63
|
+
BORDER = "border"
|
|
64
|
+
GROUND_TRANSFER = "ground_transfer"
|
|
65
|
+
LOUNGE = "lounge"
|
|
66
|
+
RISK_PREMIUM = "risk_premium"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class CostLine:
|
|
71
|
+
kind: CostKind
|
|
72
|
+
amount: Band
|
|
73
|
+
note: str
|
|
74
|
+
traveler_id: str | None = None
|
|
75
|
+
|
|
76
|
+
def __str__(self) -> str:
|
|
77
|
+
who = f" [{self.traveler_id}]" if self.traveler_id else ""
|
|
78
|
+
return f"{self.kind:<18} {self.amount!s:>12} {self.note}{who}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class StopWindows:
|
|
83
|
+
"""One planned stop, and the preferences attached to it.
|
|
84
|
+
|
|
85
|
+
Supplied by the intent, which is the only thing that knows what was asked for.
|
|
86
|
+
Callables rather than models so the cost model needs no import of the intent
|
|
87
|
+
layer — and `None` for a window nobody stated, which is the normal case.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
place: str
|
|
91
|
+
arrive: Callable[[datetime], DateCost | None] | None = None
|
|
92
|
+
depart: Callable[[datetime], DateCost | None] | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class StopMoments:
|
|
97
|
+
"""Where a planned stop actually landed in a candidate itinerary."""
|
|
98
|
+
|
|
99
|
+
place: str
|
|
100
|
+
arrive: datetime | None = None
|
|
101
|
+
depart: datetime | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def align(plan: TripPlan, places: Sequence[str]) -> list[StopMoments]:
|
|
105
|
+
"""Match a plan's segments against the journey's planned stops, in order.
|
|
106
|
+
|
|
107
|
+
An itinerary has many departures and arrivals; only the ones at planned stops are
|
|
108
|
+
choices the owner expressed a preference about. The rest are connections, whose
|
|
109
|
+
times the routing decides — and pricing those against the same window charges a
|
|
110
|
+
one-stop itinerary twice for one decision, since the connecting leg also departs
|
|
111
|
+
on a day the window covers.
|
|
112
|
+
|
|
113
|
+
Matched **in order and by place**, never by index: a candidate with two connections
|
|
114
|
+
has more legs than one with none, so position means something different in each.
|
|
115
|
+
Repeated places (out of BUD, home to BUD) are handled by the ordering.
|
|
116
|
+
"""
|
|
117
|
+
segments = plan.segments
|
|
118
|
+
if not segments or not places:
|
|
119
|
+
return []
|
|
120
|
+
moments = [StopMoments(places[0], None, segments[0].depart)]
|
|
121
|
+
at = 0
|
|
122
|
+
for index, segment in enumerate(segments):
|
|
123
|
+
if at + 1 >= len(places):
|
|
124
|
+
break
|
|
125
|
+
if segment.destination != places[at + 1]:
|
|
126
|
+
continue # a connection: the routing's business, not a stated preference
|
|
127
|
+
at += 1
|
|
128
|
+
onward = segments[index + 1].depart if index + 1 < len(segments) else None
|
|
129
|
+
moments.append(StopMoments(places[at], segment.arrive, onward))
|
|
130
|
+
return moments
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class DateCost:
|
|
135
|
+
"""What one departure moment costs, and whether the moment mattered.
|
|
136
|
+
|
|
137
|
+
`timed` is only for the explanation: writing "departs 2026-09-23 13:45" against a
|
|
138
|
+
window that speaks in whole days would claim a precision the preference never had,
|
|
139
|
+
and the whole point of the itemized table is that every number can be defended.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
amount: float
|
|
143
|
+
timed: bool = False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class StopoverRequest:
|
|
148
|
+
"""A stopover that is wanted — from the intent, a wishlist, or an answer.
|
|
149
|
+
|
|
150
|
+
`source` matters for the report: a stopover the owner asked for in advance and
|
|
151
|
+
one they accepted when the routing offered it are the same arithmetic but not
|
|
152
|
+
the same story.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
place: str
|
|
156
|
+
min_nights: int = 1
|
|
157
|
+
max_nights: int = 3
|
|
158
|
+
ideal_nights: int | None = None
|
|
159
|
+
"""The length actually wanted. Two nights in Istanbul is a stopover; one is a
|
|
160
|
+
rushed hotel run and four is a small holiday, and the difference is worth pricing
|
|
161
|
+
(§8.4). Inferred from the bounds when it is not stated."""
|
|
162
|
+
cost_at_tolerance: float | None = None
|
|
163
|
+
tolerance_nights: float | None = None
|
|
164
|
+
net_at_ideal: float = 0.0
|
|
165
|
+
"""What a stay of the ideal length is worth, net, as one signed number (§8.3).
|
|
166
|
+
|
|
167
|
+
Negative means you would pay for it. Net because a hotel rate is guessable and
|
|
168
|
+
what the place is worth to *you* is not, and only the difference ever ranks
|
|
169
|
+
anything. This is the only cost in the model allowed below zero."""
|
|
170
|
+
source: str = "intent"
|
|
171
|
+
"""intent | wishlist | asked"""
|
|
172
|
+
|
|
173
|
+
def bounds(self) -> curve.Bounds:
|
|
174
|
+
return curve.resolve(
|
|
175
|
+
self.min_nights, self.ideal_nights, self.max_nights, spread=self.tolerance_nights
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def cost_of(self, nights: int, *, scale: float) -> float | None:
|
|
179
|
+
"""The whole cost of this stop: what it is worth, and how far off the length is.
|
|
180
|
+
|
|
181
|
+
One function, no per-night rate. The marginal night is the derivative, which
|
|
182
|
+
starts at zero and grows — so the first night past the ideal is nearly free and
|
|
183
|
+
the tenth is not (§8.3).
|
|
184
|
+
"""
|
|
185
|
+
amount = self.cost_at_tolerance if self.cost_at_tolerance is not None else scale
|
|
186
|
+
return curve.cost(
|
|
187
|
+
nights,
|
|
188
|
+
self.bounds(),
|
|
189
|
+
scale=amount,
|
|
190
|
+
tolerance=self.tolerance_nights,
|
|
191
|
+
offset=self.net_at_ideal,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def breakeven(self, *, scale: float) -> tuple[float, float] | None:
|
|
195
|
+
amount = self.cost_at_tolerance if self.cost_at_tolerance is not None else scale
|
|
196
|
+
if not amount:
|
|
197
|
+
return None
|
|
198
|
+
return curve.breakeven(
|
|
199
|
+
self.bounds(),
|
|
200
|
+
scale=amount,
|
|
201
|
+
tolerance=self.tolerance_nights,
|
|
202
|
+
offset=self.net_at_ideal,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True, slots=True)
|
|
207
|
+
class StopoverOpportunity:
|
|
208
|
+
"""A stopover nobody asked for, because nobody knew it was on offer.
|
|
209
|
+
|
|
210
|
+
Real searches work backwards from the routing: a BUD–Dubai fare turns out to
|
|
211
|
+
connect in Santorini, a BUD–Bogotá one in Punta Cana, and *that* is when the
|
|
212
|
+
traveller decides they want two nights there. Treating such a gap as dead
|
|
213
|
+
layover time silently throws away the best thing about the itinerary, and
|
|
214
|
+
treating it as a wanted stopover invents a holiday nobody agreed to.
|
|
215
|
+
|
|
216
|
+
So it is neither: it is a priced question (§10). `net_ceiling` is the honest form
|
|
217
|
+
of that question — not "do you like Santorini?" but "would two nights there cost
|
|
218
|
+
you more than €200 net?", which is answerable, comparable, and contains no
|
|
219
|
+
lodging rate we had to invent on the owner's behalf (§8.3).
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
place: str
|
|
223
|
+
nights: int
|
|
224
|
+
hours: float
|
|
225
|
+
as_wait_cost: float
|
|
226
|
+
"""What the gap costs treated as an involuntary layover."""
|
|
227
|
+
net_ceiling: float
|
|
228
|
+
"""The most the stay may cost you, net, and still beat sitting it out.
|
|
229
|
+
|
|
230
|
+
Equal to what the wait costs, because taking the stop replaces that cost with this
|
|
231
|
+
one. Stated as a ceiling on *net* cost rather than a floor on value, since the
|
|
232
|
+
hotel is inside the owner's number now — asking "is a night worth €35?" required
|
|
233
|
+
us to have assumed a lodging rate, which was never ours to assume (§8.3)."""
|
|
234
|
+
|
|
235
|
+
def question(self) -> str:
|
|
236
|
+
return (
|
|
237
|
+
f"This routing sits {self.hours:.0f}h in {self.place}. Taking it as a "
|
|
238
|
+
f"{self.nights}-night stop beats waiting unless the stay costs you more "
|
|
239
|
+
f"than {self.net_ceiling:.0f} net — hotel minus whatever it is worth to "
|
|
240
|
+
f"be there. Take it?"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(slots=True)
|
|
245
|
+
class PricingContext:
|
|
246
|
+
"""Everything pricing needs that is not the plan itself."""
|
|
247
|
+
|
|
248
|
+
prefs: PreferenceProfile
|
|
249
|
+
"""The trip's default valuations. Soft prices only — hard requirements are
|
|
250
|
+
`constraints`, and what missing them costs is `prefs.constraints` (§8.1)."""
|
|
251
|
+
party: Party
|
|
252
|
+
constraints: list[HardConstraint] = field(default_factory=list)
|
|
253
|
+
"""Checked, never priced: a plan that misses the conference is not expensive."""
|
|
254
|
+
prefs_by_leg: dict[int, PreferenceProfile] = field(default_factory=dict)
|
|
255
|
+
"""Per-leg valuation overrides, keyed by leg index across the whole plan.
|
|
256
|
+
|
|
257
|
+
Not a "mode" — just these numbers with a few changed, because time can be worth
|
|
258
|
+
more on the way to something than on the way home."""
|
|
259
|
+
destinations: frozenset[str] = frozenset()
|
|
260
|
+
"""Where the trip is *going*. The gap there is the point of the whole exercise.
|
|
261
|
+
|
|
262
|
+
Without this, a return itinerary's four nights at the destination are read as a
|
|
263
|
+
92-hour layover and charged as dead time — which is not a rounding error, it is
|
|
264
|
+
the trip itself priced as suffering. Caught the first time the model met a real
|
|
265
|
+
multi-ticket return (AZair, BUD-LIS)."""
|
|
266
|
+
baggage_profile: BaggageProfile = BaggageProfile.CABIN_BAG
|
|
267
|
+
stopovers: dict[str, StopoverRequest] = field(default_factory=dict)
|
|
268
|
+
"""Stopovers already wanted, by place — from the intent or an answered question."""
|
|
269
|
+
stopover_interest: dict[str, float] = field(default_factory=dict)
|
|
270
|
+
"""Per-search additions to the config wishlist (`prefs.stopover.net`): place ->
|
|
271
|
+
net cost of a stay there, negative for wanted. A routing through one of these is
|
|
272
|
+
a wanted stop without asking, because the question was already answered."""
|
|
273
|
+
stopover_candidate_min_hours: float | None = None
|
|
274
|
+
"""Override for `prefs.stopover.candidate_min_hours`; None uses config."""
|
|
275
|
+
offer_stopovers: bool | None = None
|
|
276
|
+
"""Override for `prefs.stopover.offer`; None uses config."""
|
|
277
|
+
date_costs: dict[date, float] = field(default_factory=dict)
|
|
278
|
+
"""Flexibility cost per outbound/inbound departure date (DESIGN §8.4).
|
|
279
|
+
|
|
280
|
+
Day granularity: what the best departure moment on that date would cost."""
|
|
281
|
+
journey: list[StopWindows] = field(default_factory=list)
|
|
282
|
+
"""The planned stops in order, each with whatever windows were stated.
|
|
283
|
+
|
|
284
|
+
There is no outbound and no inbound here. A trip is stops and the legs between
|
|
285
|
+
them; "the return" is a leg home, and an open jaw or a four-city trip is the same
|
|
286
|
+
object with a different list (§8.3). Direction survives only in `Offer`, because a
|
|
287
|
+
round trip is a product airlines sell — not a fact about the journey."""
|
|
288
|
+
departure_cost: Callable[[datetime], DateCost | None] | None = None
|
|
289
|
+
"""Legacy single-window entry point, used when no journey is supplied."""
|
|
290
|
+
stopover_length_scale: float = 0.0
|
|
291
|
+
"""Scale for the stopover-length curve when a spec does not price its own."""
|
|
292
|
+
duration_cost: Callable[[int], float | None] | None = None
|
|
293
|
+
"""Trip length in nights, priced by the intent's own window when it stated one.
|
|
294
|
+
|
|
295
|
+
Departure, arrival and length are three quantities of which any two fix the third,
|
|
296
|
+
so an intent normally states two — and when it states all three, each is priced on
|
|
297
|
+
its own terms rather than one being derived and silently double-counted."""
|
|
298
|
+
"""The same preference at the resolution the itinerary actually has.
|
|
299
|
+
|
|
300
|
+
A window may name times as well as dates — "ideally airborne by 09:00, not before
|
|
301
|
+
06:00" — and then two flights on the same day are not the same choice. Supplied by
|
|
302
|
+
the intent (`TravelIntent.departure_cost`) and consulted first; `date_costs` is
|
|
303
|
+
what remains when a window speaks only in days."""
|
|
304
|
+
preferred_nights: int | None = None
|
|
305
|
+
delay_table: DelayTable = field(default_factory=DelayTable.fallback)
|
|
306
|
+
punctuality: dict[str, PunctualityObservation] = field(default_factory=dict)
|
|
307
|
+
"""Keyed by "CARRIER+NUMBER"; empty before Phase 3 (DESIGN §8.5)."""
|
|
308
|
+
regimes: RegimeTable | None = None
|
|
309
|
+
transit_countries: dict[str, str] = field(default_factory=dict)
|
|
310
|
+
"""Airport -> ISO country. Kept explicit so the cost model needs no airport DB."""
|
|
311
|
+
lounge_airports: frozenset[str] = frozenset()
|
|
312
|
+
"""Airports where the party has lounge access in the terminal actually used."""
|
|
313
|
+
long_haul_hours: float = 6.0
|
|
314
|
+
ancillary_estimate: Band | None = None
|
|
315
|
+
"""Baggage/ancillary adjustment from the fee table (M2); None = nothing known."""
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def checked_bag(self) -> bool:
|
|
319
|
+
return self.baggage_profile is BaggageProfile.CABIN_PLUS_CHECKED
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def offers_stopovers(self) -> bool:
|
|
323
|
+
if self.offer_stopovers is not None:
|
|
324
|
+
return self.offer_stopovers
|
|
325
|
+
return self.prefs.stopover.offer
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def candidate_min_hours(self) -> float:
|
|
329
|
+
if self.stopover_candidate_min_hours is not None:
|
|
330
|
+
return self.stopover_candidate_min_hours
|
|
331
|
+
return self.prefs.stopover.candidate_min_hours
|
|
332
|
+
|
|
333
|
+
def interest_in(self, place: str) -> float | None:
|
|
334
|
+
"""Net cost of a stay in this place, if it has been valued in advance.
|
|
335
|
+
|
|
336
|
+
Per-search entries win over config. Negative means wanted (§8.3).
|
|
337
|
+
"""
|
|
338
|
+
if place in self.stopover_interest:
|
|
339
|
+
return self.stopover_interest[place]
|
|
340
|
+
return self.prefs.stopover.net.get(place)
|
|
341
|
+
|
|
342
|
+
def delay_for(self, segment: Segment) -> DelayEstimate:
|
|
343
|
+
long_haul = segment.duration.total_seconds() / 3600 >= self.long_haul_hours
|
|
344
|
+
prior = self.delay_table.prior_for_hour(
|
|
345
|
+
segment.local_departure_hour, long_haul=long_haul
|
|
346
|
+
)
|
|
347
|
+
key = f"{segment.carrier}{segment.flight_number}"
|
|
348
|
+
return blend(
|
|
349
|
+
prior,
|
|
350
|
+
self.punctuality.get(key),
|
|
351
|
+
pseudo_observations=self.prefs.delay.prior_pseudo_observations,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def country_of(self, airport: str) -> str | None:
|
|
355
|
+
return self.transit_countries.get(airport)
|
|
356
|
+
|
|
357
|
+
def prefs_for_leg(self, leg_index: int) -> PreferenceProfile:
|
|
358
|
+
"""The valuations that apply to one leg, defaulting to the trip's."""
|
|
359
|
+
return self.prefs_by_leg.get(leg_index, self.prefs)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(slots=True)
|
|
363
|
+
class PlanCost:
|
|
364
|
+
"""The priced plan: total, itemization, risks, and what had to be assumed."""
|
|
365
|
+
|
|
366
|
+
lines: list[CostLine]
|
|
367
|
+
money_total: Band
|
|
368
|
+
risks: list[RiskAnnotation]
|
|
369
|
+
infeasible_reasons: list[str]
|
|
370
|
+
verdicts: list[FeasibilityVerdict]
|
|
371
|
+
borders: list[PartyBorder]
|
|
372
|
+
notes: list[str]
|
|
373
|
+
violations: list[ConstraintViolation] = field(default_factory=list)
|
|
374
|
+
"""Hard requirements this plan breaks (§8.1). Distinct from `infeasible_reasons`
|
|
375
|
+
about connections: one is "you would miss the conference", the other is "you
|
|
376
|
+
would miss the flight"."""
|
|
377
|
+
opportunities: list[StopoverOpportunity] = field(default_factory=list)
|
|
378
|
+
"""Stopovers this routing makes possible but nobody has asked for yet (§8.3)."""
|
|
379
|
+
assumptions: AssumptionLog = field(default_factory=AssumptionLog)
|
|
380
|
+
"""What had to be decided without being told (§10)."""
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def generalized_cost(self) -> Band:
|
|
384
|
+
return band_sum([line.amount for line in self.lines])
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def feasible(self) -> bool:
|
|
388
|
+
return not self.infeasible_reasons and not self.violations
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def money_full_one_off(self) -> Band:
|
|
392
|
+
"""Out-of-pocket including un-amortized authorization costs."""
|
|
393
|
+
extra = sum(b.money_cost_full - b.money_cost for b in self.borders)
|
|
394
|
+
return self.money_total + extra
|
|
395
|
+
|
|
396
|
+
def by_kind(self, kind: CostKind) -> Band:
|
|
397
|
+
return band_sum([line.amount for line in self.lines if line.kind is kind])
|
|
398
|
+
|
|
399
|
+
def explain(self) -> str:
|
|
400
|
+
head = (
|
|
401
|
+
f"generalized {self.generalized_cost} "
|
|
402
|
+
f"(money {self.money_total}"
|
|
403
|
+
+ (
|
|
404
|
+
f", one-off {self.money_full_one_off}"
|
|
405
|
+
if self.money_full_one_off.mid != self.money_total.mid
|
|
406
|
+
else ""
|
|
407
|
+
)
|
|
408
|
+
+ ")"
|
|
409
|
+
)
|
|
410
|
+
body = "\n".join(f" {line}" for line in self.lines)
|
|
411
|
+
tail = ""
|
|
412
|
+
for violation in self.violations:
|
|
413
|
+
tail += f"\n CONSTRAINT: {violation}"
|
|
414
|
+
for opportunity in self.opportunities:
|
|
415
|
+
tail += f"\n OPPORTUNITY: {opportunity.question()}"
|
|
416
|
+
if self.infeasible_reasons:
|
|
417
|
+
tail += "\n INFEASIBLE: " + "; ".join(self.infeasible_reasons)
|
|
418
|
+
return f"{head}\n{body}{tail}"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ── individual terms ──────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _is_red_eye(segment: Segment, prefs: PreferenceProfile) -> bool:
|
|
425
|
+
hour = segment.local_departure_hour
|
|
426
|
+
return hour >= prefs.discomfort.red_eye_from_hour or hour < prefs.discomfort.red_eye_to_hour
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _segment_time_cost(segment: Segment, prefs: PreferenceProfile) -> tuple[float, str]:
|
|
430
|
+
"""Time only: hours you don't get back, at what an hour is worth.
|
|
431
|
+
|
|
432
|
+
The unpleasantness of a red-eye is *not* here. It is a fixed discomfort amount
|
|
433
|
+
(§8.2), because a broken night is a broken night whether the flight is two hours
|
|
434
|
+
or twelve — and because how much you mind it should not scale with your hourly
|
|
435
|
+
rate.
|
|
436
|
+
"""
|
|
437
|
+
hours = segment.duration.total_seconds() / 3600
|
|
438
|
+
factor = prefs.time.in_flight_factor
|
|
439
|
+
note = f"{segment} {hours:.1f}h x{factor:g}"
|
|
440
|
+
return hours * factor * prefs.time.wage_rate_per_hour, note
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _overnight_choice(hours: float, prefs: PreferenceProfile) -> tuple[float, str]:
|
|
444
|
+
"""Price a night in transit as the cheaper of terminal or airport hotel."""
|
|
445
|
+
terminal = prefs.discomfort.cost(prefs.discomfort.night_in_terminal)
|
|
446
|
+
hotel = (
|
|
447
|
+
prefs.overnight.airport_hotel_cost
|
|
448
|
+
+ prefs.overnight.hotel_transfer_hours * prefs.time.wage_rate_per_hour
|
|
449
|
+
)
|
|
450
|
+
if hotel < terminal:
|
|
451
|
+
return hotel, f"airport hotel assumed ({hours:.1f}h wait)"
|
|
452
|
+
return terminal, f"night in terminal assumed ({hours:.1f}h wait)"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _spans_night(connection: Connection, prefs: PreferenceProfile) -> bool:
|
|
456
|
+
"""Does this gap eat a night?
|
|
457
|
+
|
|
458
|
+
Requires both length (a 2-hour wait at 23:00 is a wait, not a night) and
|
|
459
|
+
overlap with the overnight window, so a long daytime layover is not charged a
|
|
460
|
+
hotel and a 22:00–07:00 sit is not charged as ordinary dead time.
|
|
461
|
+
"""
|
|
462
|
+
start, end = connection.arrive_segment.arrive, connection.depart_segment.depart
|
|
463
|
+
if (end - start).total_seconds() / 3600 < prefs.overnight.min_night_hours:
|
|
464
|
+
return False
|
|
465
|
+
hours_spanned = int((end - start).total_seconds() // 3600) + 1
|
|
466
|
+
return any(
|
|
467
|
+
(start.hour + offset) % 24 >= prefs.overnight.window_from_hour
|
|
468
|
+
or (start.hour + offset) % 24 < prefs.overnight.window_to_hour
|
|
469
|
+
for offset in range(hours_spanned)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _stay_nights(connection: Connection) -> int:
|
|
474
|
+
return max(
|
|
475
|
+
0,
|
|
476
|
+
(
|
|
477
|
+
connection.depart_segment.depart.date() - connection.arrive_segment.arrive.date()
|
|
478
|
+
).days,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _requested_stopover(connection: Connection, ctx: PricingContext) -> StopoverRequest | None:
|
|
483
|
+
"""Is this gap a wanted stopover, or a layover nobody asked for?
|
|
484
|
+
|
|
485
|
+
Three ways a stay can be wanted: the intent asked for it, the owner's standing
|
|
486
|
+
wishlist covers the place (`stopover_interest` — already an answered question),
|
|
487
|
+
or a question asked mid-search was answered yes, which arrives here as an entry
|
|
488
|
+
in `ctx.stopovers` with `source="asked"`.
|
|
489
|
+
"""
|
|
490
|
+
place = connection.at_airport
|
|
491
|
+
nights = _stay_nights(connection)
|
|
492
|
+
|
|
493
|
+
request = ctx.stopovers.get(place)
|
|
494
|
+
if request is not None:
|
|
495
|
+
if request.min_nights <= nights <= request.max_nights:
|
|
496
|
+
return request
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
# A wishlisted place needs no question: the owner priced it in advance. The
|
|
500
|
+
# length on offer becomes the ideal, since they valued the place and not a
|
|
501
|
+
# particular number of nights.
|
|
502
|
+
net = ctx.interest_in(place)
|
|
503
|
+
if net is not None and nights >= 1:
|
|
504
|
+
return StopoverRequest(
|
|
505
|
+
place=place,
|
|
506
|
+
min_nights=1,
|
|
507
|
+
ideal_nights=nights,
|
|
508
|
+
max_nights=nights,
|
|
509
|
+
net_at_ideal=net,
|
|
510
|
+
source="wishlist",
|
|
511
|
+
)
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _stopover_opportunity(
|
|
516
|
+
connection: Connection, ctx: PricingContext, *, as_wait_cost: float
|
|
517
|
+
) -> StopoverOpportunity | None:
|
|
518
|
+
"""Would this gap be worth offering as a stopover?
|
|
519
|
+
|
|
520
|
+
Only asked of gaps long enough to be a real stay: below
|
|
521
|
+
`stopover_candidate_min_hours` the question is noise. The break-even value is
|
|
522
|
+
computed from what the gap already costs as a layover, which is what makes the
|
|
523
|
+
question answerable — "worth €35 a night to you?" rather than "fancy Greece?".
|
|
524
|
+
"""
|
|
525
|
+
if not ctx.offers_stopovers:
|
|
526
|
+
return None
|
|
527
|
+
hours = connection.gap_minutes / 60
|
|
528
|
+
if hours < ctx.candidate_min_hours:
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
nights = max(1, _stay_nights(connection))
|
|
532
|
+
# Taking the stop replaces the wait cost with the stop's own net cost, so it wins
|
|
533
|
+
# whenever that net is below what waiting already costs.
|
|
534
|
+
return StopoverOpportunity(
|
|
535
|
+
place=connection.at_airport,
|
|
536
|
+
nights=nights,
|
|
537
|
+
hours=hours,
|
|
538
|
+
as_wait_cost=as_wait_cost,
|
|
539
|
+
net_ceiling=as_wait_cost,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _stopover_assumption(opportunity: StopoverOpportunity, plan_id: str) -> Assumption:
|
|
544
|
+
"""The question, with both answers priced (§10)."""
|
|
545
|
+
nights = opportunity.nights
|
|
546
|
+
return Assumption(
|
|
547
|
+
key=f"stopover:{opportunity.place}:{nights}",
|
|
548
|
+
question=opportunity.question(),
|
|
549
|
+
options=[
|
|
550
|
+
Option(
|
|
551
|
+
label="stay in transit (default)",
|
|
552
|
+
value=0,
|
|
553
|
+
pros="nothing to arrange, no extra spending",
|
|
554
|
+
cons=f"{opportunity.hours:.0f}h of the trip spent in an airport, "
|
|
555
|
+
f"costed at {opportunity.as_wait_cost:.0f}",
|
|
556
|
+
),
|
|
557
|
+
Option(
|
|
558
|
+
label=f"{nights} night(s) in {opportunity.place}",
|
|
559
|
+
value=nights,
|
|
560
|
+
pros="turns dead time into somewhere you wanted to be; beats "
|
|
561
|
+
f"waiting unless it costs more than {opportunity.net_ceiling:.0f} net",
|
|
562
|
+
cons="a hotel to pay for, bags to move, and the onward ticket "
|
|
563
|
+
"becomes a separate booking risk",
|
|
564
|
+
),
|
|
565
|
+
],
|
|
566
|
+
default=0,
|
|
567
|
+
blocking=False,
|
|
568
|
+
affects=[plan_id],
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _constraints_broken_by_missing(
|
|
573
|
+
plan: TripPlan,
|
|
574
|
+
connection: Connection,
|
|
575
|
+
ctx: PricingContext,
|
|
576
|
+
) -> list[HardConstraint]:
|
|
577
|
+
"""Which hard requirements fail if *this* connection is missed.
|
|
578
|
+
|
|
579
|
+
The timing question, asked precisely: missing a connection pushes arrival by
|
|
580
|
+
`recovery_hours` (or overnight, if it was the day's last departure), and only the
|
|
581
|
+
constraints that then break are relevant to this connection. A self-transfer three
|
|
582
|
+
days before a conference breaks nothing; the same self-transfer the night before
|
|
583
|
+
breaks everything.
|
|
584
|
+
"""
|
|
585
|
+
if not ctx.constraints:
|
|
586
|
+
return []
|
|
587
|
+
|
|
588
|
+
recovery = timedelta(
|
|
589
|
+
hours=(
|
|
590
|
+
ctx.prefs.delay.recovery_hours_overnight
|
|
591
|
+
if connection.onward_is_last_of_day
|
|
592
|
+
else ctx.prefs.delay.recovery_hours
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
broken: list[HardConstraint] = []
|
|
597
|
+
for constraint in ctx.constraints:
|
|
598
|
+
if constraint.kind is ConstraintKind.DEPART_AFTER:
|
|
599
|
+
continue # a missed connection never makes you leave earlier
|
|
600
|
+
if constraint.kind is ConstraintKind.ARRIVE_BY and constraint.place is not None:
|
|
601
|
+
reached = [s.arrive for s in plan.segments if s.destination == constraint.place]
|
|
602
|
+
if not reached:
|
|
603
|
+
continue
|
|
604
|
+
would_arrive = reached[0] + recovery
|
|
605
|
+
else:
|
|
606
|
+
would_arrive = plan.arrive + recovery
|
|
607
|
+
# Only connections *before* the deadline can threaten it.
|
|
608
|
+
if connection.arrive_segment.arrive > constraint.when:
|
|
609
|
+
continue
|
|
610
|
+
if would_arrive > constraint.effective_deadline:
|
|
611
|
+
broken.append(constraint)
|
|
612
|
+
return broken
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _stake_multiplier(plan: TripPlan, connection: Connection, ctx: PricingContext) -> float:
|
|
616
|
+
"""How much extra cushion this connection deserves, given what it threatens (§8.9).
|
|
617
|
+
|
|
618
|
+
The honest replacement for the old business-mode buffer multiplier: cushion is
|
|
619
|
+
bought where something is actually at risk, not everywhere on a trip that happens
|
|
620
|
+
to contain a deadline.
|
|
621
|
+
"""
|
|
622
|
+
broken = _constraints_broken_by_missing(plan, connection, ctx)
|
|
623
|
+
if not broken:
|
|
624
|
+
return 1.0
|
|
625
|
+
weight = max(c.stake_weight(ctx.prefs.constraints) for c in broken)
|
|
626
|
+
ceiling = ctx.prefs.constraints.stake_buffer_max_multiplier
|
|
627
|
+
return 1.0 + weight * (ceiling - 1.0)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _expected_violation_cost(
|
|
631
|
+
plan: TripPlan,
|
|
632
|
+
connection: Connection,
|
|
633
|
+
verdict: FeasibilityVerdict,
|
|
634
|
+
inbound_delay: DelayEstimate,
|
|
635
|
+
ctx: PricingContext,
|
|
636
|
+
) -> float:
|
|
637
|
+
"""P(this connection is missed) x what missing it would break.
|
|
638
|
+
|
|
639
|
+
"Definitely arrive by 20:00" cannot be checked against a schedule alone: a plan
|
|
640
|
+
that *intends* to land at 14:00 with a 15% chance of arriving tomorrow satisfies
|
|
641
|
+
the timetable and fails the requirement. Stakes turn that probability into money,
|
|
642
|
+
so a fragile plan loses on the objective instead of needing a flag to forbid it.
|
|
643
|
+
"""
|
|
644
|
+
broken = _constraints_broken_by_missing(plan, connection, ctx)
|
|
645
|
+
if not broken:
|
|
646
|
+
return 0.0
|
|
647
|
+
|
|
648
|
+
cushion = verdict.slack_minutes + verdict.breakdown.delay_allowance_minutes
|
|
649
|
+
probability = p_miss(inbound_delay, cushion)
|
|
650
|
+
if probability <= 0:
|
|
651
|
+
return 0.0
|
|
652
|
+
return probability * sum(c.cost_of_missing(ctx.prefs.constraints) for c in broken)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _segments_with_leg(plan: TripPlan) -> list[tuple[int, Segment]]:
|
|
656
|
+
"""Every segment, tagged with its leg index across the whole plan.
|
|
657
|
+
|
|
658
|
+
Leg boundaries are where valuations may change: the way to something and the way
|
|
659
|
+
home are different legs and an hour on each can be worth different amounts.
|
|
660
|
+
"""
|
|
661
|
+
out: list[tuple[int, Segment]] = []
|
|
662
|
+
leg_index = 0
|
|
663
|
+
for offer in plan.offers:
|
|
664
|
+
for leg in offer.legs:
|
|
665
|
+
for segment in leg.segments:
|
|
666
|
+
out.append((leg_index, segment))
|
|
667
|
+
leg_index += 1
|
|
668
|
+
return out
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def price_plan(plan: TripPlan, ctx: PricingContext) -> PlanCost:
|
|
672
|
+
"""Price a plan into a generalized cost with a full itemization."""
|
|
673
|
+
prefs = ctx.prefs
|
|
674
|
+
wage = prefs.time.wage_rate_per_hour
|
|
675
|
+
lines: list[CostLine] = []
|
|
676
|
+
risks: list[RiskAnnotation] = []
|
|
677
|
+
discomfort: list[tuple[float, str]] = []
|
|
678
|
+
expected_misconnect = 0.0
|
|
679
|
+
expected_violation = 0.0
|
|
680
|
+
infeasible: list[str] = []
|
|
681
|
+
verdicts: list[FeasibilityVerdict] = []
|
|
682
|
+
party_borders: list[PartyBorder] = []
|
|
683
|
+
notes: list[str] = []
|
|
684
|
+
opportunities: list[StopoverOpportunity] = []
|
|
685
|
+
assumptions = AssumptionLog()
|
|
686
|
+
|
|
687
|
+
# ── money ────────────────────────────────────────────────────────────────
|
|
688
|
+
fares = plan.money_out_of_pocket
|
|
689
|
+
if fares is None:
|
|
690
|
+
infeasible.append(
|
|
691
|
+
"an offer has no reference rate — uncomparable, excluded from ranking"
|
|
692
|
+
)
|
|
693
|
+
money = Band.zero()
|
|
694
|
+
else:
|
|
695
|
+
money = Band.exact(fares)
|
|
696
|
+
lines.append(
|
|
697
|
+
CostLine(CostKind.FARE, money, f"{len(plan.offers)} ticket(s), normalized")
|
|
698
|
+
)
|
|
699
|
+
if ctx.ancillary_estimate is not None:
|
|
700
|
+
lines.append(
|
|
701
|
+
CostLine(
|
|
702
|
+
CostKind.ANCILLARY,
|
|
703
|
+
ctx.ancillary_estimate,
|
|
704
|
+
f"baggage under {ctx.baggage_profile}",
|
|
705
|
+
)
|
|
706
|
+
)
|
|
707
|
+
money = money + ctx.ancillary_estimate
|
|
708
|
+
|
|
709
|
+
# ── time in the air, at each leg's own valuation ─────────────────────────
|
|
710
|
+
for leg_index, segment in _segments_with_leg(plan):
|
|
711
|
+
leg_prefs = ctx.prefs_for_leg(leg_index)
|
|
712
|
+
cost, note = _segment_time_cost(segment, leg_prefs)
|
|
713
|
+
if leg_prefs is not prefs:
|
|
714
|
+
note += f" [{leg_prefs.label or f'leg {leg_index}'}]"
|
|
715
|
+
lines.append(CostLine(CostKind.TRAVEL_TIME, Band.exact(cost), note))
|
|
716
|
+
|
|
717
|
+
for _, segment in _segments_with_leg(plan):
|
|
718
|
+
if _is_red_eye(segment, prefs):
|
|
719
|
+
discomfort.append(
|
|
720
|
+
(
|
|
721
|
+
prefs.discomfort.cost(prefs.discomfort.red_eye_per_flight),
|
|
722
|
+
f"{segment.carrier}{segment.flight_number} departs "
|
|
723
|
+
f"{segment.local_departure_hour:02d}:xx — a night lost",
|
|
724
|
+
)
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# ── expected delay ───────────────────────────────────────────────────────
|
|
728
|
+
for leg_index, segment in _segments_with_leg(plan):
|
|
729
|
+
leg_prefs = ctx.prefs_for_leg(leg_index)
|
|
730
|
+
wage = leg_prefs.time.wage_rate_per_hour
|
|
731
|
+
estimate = ctx.delay_for(segment)
|
|
732
|
+
cost = estimate.mean_min / 60 * wage * leg_prefs.time.layover_factor
|
|
733
|
+
lines.append(
|
|
734
|
+
CostLine(
|
|
735
|
+
CostKind.EXPECTED_DELAY,
|
|
736
|
+
Band.exact(cost),
|
|
737
|
+
f"{segment.carrier}{segment.flight_number} "
|
|
738
|
+
f"{segment.local_departure_hour:02d}:xx dep, "
|
|
739
|
+
f"{estimate.mean_min:.0f}m expected slip ({estimate.source})",
|
|
740
|
+
)
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
# ── connections: waits, stopovers, borders, feasibility, risk ────────────
|
|
744
|
+
|
|
745
|
+
for connection in plan.connections():
|
|
746
|
+
if connection.at_airport in ctx.destinations:
|
|
747
|
+
# The stay at the destination is a stop like any other (§8.3) — it just
|
|
748
|
+
# happens to be the point of the trip. What it is *not* is a transfer:
|
|
749
|
+
# there is no misconnect to fear (missing your own return flight is a
|
|
750
|
+
# different problem) and no self-transfer hassle in buying the two
|
|
751
|
+
# directions separately, so none of the machinery below applies.
|
|
752
|
+
if not connection.within_offer:
|
|
753
|
+
# The two directions were bought apart. Not a misconnect — nothing
|
|
754
|
+
# connects here but the stay — so it is annotated, not priced: the
|
|
755
|
+
# exposure is that the bookings fail independently, and a cancelled
|
|
756
|
+
# outbound leaves a return that is still yours to deal with. Pricing
|
|
757
|
+
# it would need a cancellation prior nobody has (§8.7).
|
|
758
|
+
risks.append(
|
|
759
|
+
RiskAnnotation(
|
|
760
|
+
kind=RiskKind.SEPARATE_PNR,
|
|
761
|
+
detail=(
|
|
762
|
+
f"{connection.at_airport}: outbound and return bought "
|
|
763
|
+
"separately — one cancellation does not release the other, "
|
|
764
|
+
"and bags are paid for twice"
|
|
765
|
+
),
|
|
766
|
+
)
|
|
767
|
+
)
|
|
768
|
+
stay = ctx.stopovers.get(connection.at_airport)
|
|
769
|
+
if stay is not None:
|
|
770
|
+
nights = max(0, _stay_nights(connection))
|
|
771
|
+
net = stay.cost_of(nights, scale=ctx.stopover_length_scale)
|
|
772
|
+
if net:
|
|
773
|
+
lines.append(
|
|
774
|
+
CostLine(
|
|
775
|
+
CostKind.STOPOVER,
|
|
776
|
+
Band.exact(net),
|
|
777
|
+
f"{connection.at_airport} {nights}n "
|
|
778
|
+
f"vs ideal {stay.bounds().ideal:g}n",
|
|
779
|
+
)
|
|
780
|
+
)
|
|
781
|
+
continue
|
|
782
|
+
stay_hours = connection.gap_minutes / 60
|
|
783
|
+
inbound_delay = ctx.delay_for(connection.arrive_segment)
|
|
784
|
+
country = ctx.country_of(connection.at_airport)
|
|
785
|
+
|
|
786
|
+
border: PartyBorder | None = None
|
|
787
|
+
border_minutes = 0.0
|
|
788
|
+
entry_required = False
|
|
789
|
+
if country and ctx.regimes is not None:
|
|
790
|
+
border = borders_mod.resolve_party(
|
|
791
|
+
ctx.party,
|
|
792
|
+
country,
|
|
793
|
+
table=ctx.regimes,
|
|
794
|
+
departure=connection.arrive_segment.arrive.date(),
|
|
795
|
+
wage_rate_per_hour=wage,
|
|
796
|
+
)
|
|
797
|
+
party_borders.append(border)
|
|
798
|
+
border_minutes = border.added_time_min
|
|
799
|
+
entry_required = border.regime is Regime.ENTRY_REQUIRED
|
|
800
|
+
if border_minutes:
|
|
801
|
+
lines.append(
|
|
802
|
+
CostLine(
|
|
803
|
+
CostKind.BORDER,
|
|
804
|
+
Band.exact(border_minutes / 60 * wage),
|
|
805
|
+
f"{border.country} {border.regime}: {border_minutes:.0f}m "
|
|
806
|
+
f"(party maximum)",
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
for per in border.per_traveler:
|
|
810
|
+
if per.money_cost:
|
|
811
|
+
lines.append(
|
|
812
|
+
CostLine(
|
|
813
|
+
CostKind.BORDER,
|
|
814
|
+
Band.exact(per.money_cost),
|
|
815
|
+
per.note,
|
|
816
|
+
traveler_id=per.traveler_id,
|
|
817
|
+
)
|
|
818
|
+
)
|
|
819
|
+
if per.authorization_status.value == "obtainable":
|
|
820
|
+
risks.append(
|
|
821
|
+
RiskAnnotation(
|
|
822
|
+
kind=RiskKind.AUTHORIZATION_PENDING,
|
|
823
|
+
detail=per.note,
|
|
824
|
+
traveler_id=per.traveler_id,
|
|
825
|
+
)
|
|
826
|
+
)
|
|
827
|
+
if not border.feasible:
|
|
828
|
+
infeasible.extend(border.blocking_notes)
|
|
829
|
+
if border.regime is Regime.UNKNOWN:
|
|
830
|
+
risks.append(
|
|
831
|
+
RiskAnnotation(
|
|
832
|
+
kind=RiskKind.UNKNOWN_TRANSIT_REGIME,
|
|
833
|
+
detail=(
|
|
834
|
+
f"{country}: transit regime not in the table — ask, don't assume"
|
|
835
|
+
),
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
request = _requested_stopover(connection, ctx)
|
|
840
|
+
|
|
841
|
+
# Feasibility comes before pricing: an infeasible connection is not an
|
|
842
|
+
# expensive plan, it is not a plan.
|
|
843
|
+
stake_multiplier = _stake_multiplier(plan, connection, ctx)
|
|
844
|
+
verdict = assess(
|
|
845
|
+
connection,
|
|
846
|
+
prefs.feasibility,
|
|
847
|
+
checked_bag=ctx.checked_bag,
|
|
848
|
+
inbound_delay=inbound_delay,
|
|
849
|
+
border_minutes=border_minutes,
|
|
850
|
+
entry_required=entry_required,
|
|
851
|
+
stopover_requested=request is not None,
|
|
852
|
+
stake_multiplier=stake_multiplier,
|
|
853
|
+
)
|
|
854
|
+
verdicts.append(verdict)
|
|
855
|
+
if not verdict.feasible:
|
|
856
|
+
infeasible.append(f"{connection}: {verdict.reason}")
|
|
857
|
+
|
|
858
|
+
if request is not None:
|
|
859
|
+
nights = max(
|
|
860
|
+
1,
|
|
861
|
+
(
|
|
862
|
+
connection.depart_segment.depart.date()
|
|
863
|
+
- connection.arrive_segment.arrive.date()
|
|
864
|
+
).days,
|
|
865
|
+
)
|
|
866
|
+
# One line, one function: what the stay is worth plus how far its length
|
|
867
|
+
# is from the one that was wanted (§8.3). No per-night rate anywhere.
|
|
868
|
+
net = request.cost_of(nights, scale=ctx.stopover_length_scale)
|
|
869
|
+
wanted = request.bounds().ideal
|
|
870
|
+
note = f"{connection.at_airport} {nights}n"
|
|
871
|
+
if nights != wanted:
|
|
872
|
+
note += f" vs ideal {wanted:g}n"
|
|
873
|
+
if request.net_at_ideal:
|
|
874
|
+
worth = -request.net_at_ideal
|
|
875
|
+
note += f" (worth {worth:.0f} at best)" if worth > 0 else ""
|
|
876
|
+
if net:
|
|
877
|
+
lines.append(CostLine(CostKind.STOPOVER, Band.exact(net), note))
|
|
878
|
+
# Requested stopover hours leave the time account entirely (§8.3).
|
|
879
|
+
else:
|
|
880
|
+
wait_factor = prefs.time.layover_factor
|
|
881
|
+
note = f"{connection.at_airport} {stay_hours:.1f}h wait"
|
|
882
|
+
if connection.gap_minutes < prefs.discomfort.tight_connection_minutes:
|
|
883
|
+
# The stress of running for a gate is a fixed unpleasantness, not a
|
|
884
|
+
# multiple of 45 minutes — and it should not grow because your hourly
|
|
885
|
+
# rate did.
|
|
886
|
+
discomfort.append(
|
|
887
|
+
(
|
|
888
|
+
prefs.discomfort.cost(prefs.discomfort.tight_connection),
|
|
889
|
+
f"tight connection at {connection.at_airport} "
|
|
890
|
+
f"({connection.gap_minutes:.0f}m)",
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
if _spans_night(connection, prefs):
|
|
895
|
+
# Per night, per traveller: two people stuck for two nights is four
|
|
896
|
+
# bad nights, not one.
|
|
897
|
+
nights_stuck = max(1, _stay_nights(connection))
|
|
898
|
+
per_night, overnight_note = _overnight_choice(stay_hours, prefs)
|
|
899
|
+
wait_cost = per_night * nights_stuck * ctx.party.size
|
|
900
|
+
lines.append(
|
|
901
|
+
CostLine(
|
|
902
|
+
CostKind.INVOLUNTARY_WAIT,
|
|
903
|
+
Band.exact(wait_cost),
|
|
904
|
+
f"{connection.at_airport}: {overnight_note}, "
|
|
905
|
+
f"{nights_stuck} night(s) x{ctx.party.size} traveller(s)",
|
|
906
|
+
)
|
|
907
|
+
)
|
|
908
|
+
opportunity = _stopover_opportunity(connection, ctx, as_wait_cost=wait_cost)
|
|
909
|
+
if opportunity is not None:
|
|
910
|
+
opportunities.append(opportunity)
|
|
911
|
+
assumptions.add(_stopover_assumption(opportunity, plan.plan_id))
|
|
912
|
+
else:
|
|
913
|
+
dead_time = stay_hours * wait_factor * wage
|
|
914
|
+
total_wait_cost = 0.0
|
|
915
|
+
lounge_money = 0.0
|
|
916
|
+
lounge_notes: list[str] = []
|
|
917
|
+
for traveler in ctx.party.travelers:
|
|
918
|
+
decision = lounge_mod.decide(
|
|
919
|
+
traveler,
|
|
920
|
+
wait_hours=stay_hours,
|
|
921
|
+
dead_time_cost=dead_time,
|
|
922
|
+
has_lounge_here=connection.at_airport in ctx.lounge_airports,
|
|
923
|
+
prefs=prefs.lounge,
|
|
924
|
+
policy=prefs.lounge.use,
|
|
925
|
+
)
|
|
926
|
+
total_wait_cost += dead_time * decision.wait_factor
|
|
927
|
+
lounge_money += decision.cost
|
|
928
|
+
if decision.used:
|
|
929
|
+
lounge_notes.append(f"{traveler.id}: {decision.note}")
|
|
930
|
+
lines.append(
|
|
931
|
+
CostLine(
|
|
932
|
+
CostKind.INVOLUNTARY_WAIT,
|
|
933
|
+
Band.exact(total_wait_cost),
|
|
934
|
+
f"{note} x{ctx.party.size} traveller(s)",
|
|
935
|
+
)
|
|
936
|
+
)
|
|
937
|
+
if lounge_money or lounge_notes:
|
|
938
|
+
lines.append(
|
|
939
|
+
CostLine(
|
|
940
|
+
CostKind.LOUNGE,
|
|
941
|
+
Band.exact(lounge_money),
|
|
942
|
+
"; ".join(lounge_notes) or "lounge entry",
|
|
943
|
+
)
|
|
944
|
+
)
|
|
945
|
+
opportunity = _stopover_opportunity(
|
|
946
|
+
connection, ctx, as_wait_cost=total_wait_cost + lounge_money
|
|
947
|
+
)
|
|
948
|
+
if opportunity is not None:
|
|
949
|
+
opportunities.append(opportunity)
|
|
950
|
+
assumptions.add(_stopover_assumption(opportunity, plan.plan_id))
|
|
951
|
+
|
|
952
|
+
# Ground transfer for an airport change — free of hassle only when the
|
|
953
|
+
# stopover was wanted, because then you were leaving the airport anyway.
|
|
954
|
+
if connection.airport_change:
|
|
955
|
+
transfer_money = prefs.ground.default_metro_transfer_cost * ctx.party.size
|
|
956
|
+
transfer_time = (
|
|
957
|
+
prefs.ground.default_metro_transfer_hours
|
|
958
|
+
* prefs.time.ground_transfer_factor
|
|
959
|
+
* wage
|
|
960
|
+
)
|
|
961
|
+
if request is not None:
|
|
962
|
+
lines.append(
|
|
963
|
+
CostLine(
|
|
964
|
+
CostKind.GROUND_TRANSFER,
|
|
965
|
+
Band.exact(transfer_money),
|
|
966
|
+
f"{connection.arrive_segment.destination}->"
|
|
967
|
+
f"{connection.depart_segment.origin} ground ticket "
|
|
968
|
+
f"(stopover requested: part of the trip, no hassle premium)",
|
|
969
|
+
)
|
|
970
|
+
)
|
|
971
|
+
else:
|
|
972
|
+
lines.append(
|
|
973
|
+
CostLine(
|
|
974
|
+
CostKind.GROUND_TRANSFER,
|
|
975
|
+
Band.exact(transfer_money + transfer_time),
|
|
976
|
+
f"{connection.arrive_segment.destination}->"
|
|
977
|
+
f"{connection.depart_segment.origin} transfer + time",
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
discomfort.append(
|
|
981
|
+
(
|
|
982
|
+
prefs.discomfort.cost(prefs.discomfort.airport_change),
|
|
983
|
+
f"airport change {connection.arrive_segment.destination}->"
|
|
984
|
+
f"{connection.depart_segment.origin}",
|
|
985
|
+
)
|
|
986
|
+
)
|
|
987
|
+
risks.append(
|
|
988
|
+
RiskAnnotation(
|
|
989
|
+
kind=RiskKind.AIRPORT_CHANGE,
|
|
990
|
+
detail=f"{connection.arrive_segment.destination} to "
|
|
991
|
+
f"{connection.depart_segment.origin} on separate tickets",
|
|
992
|
+
)
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
# ── unprotected connections: unpleasantness, then arithmetic ──────────
|
|
996
|
+
if connection.is_protected:
|
|
997
|
+
# A protected connection can still be missed, and if it stands before a
|
|
998
|
+
# hard constraint that matters — the airline rebooks you, but not
|
|
999
|
+
# necessarily in time for the conference.
|
|
1000
|
+
expected_violation += _expected_violation_cost(
|
|
1001
|
+
plan, connection, verdict, inbound_delay, ctx
|
|
1002
|
+
)
|
|
1003
|
+
continue
|
|
1004
|
+
|
|
1005
|
+
if connection.protection is Protection.GUARANTEED_SELF_TRANSFER:
|
|
1006
|
+
discomfort.append(
|
|
1007
|
+
(
|
|
1008
|
+
prefs.discomfort.cost(prefs.discomfort.third_party_guarantee),
|
|
1009
|
+
f"{connection.at_airport}: guaranteed self-transfer",
|
|
1010
|
+
)
|
|
1011
|
+
)
|
|
1012
|
+
risks.append(
|
|
1013
|
+
RiskAnnotation(
|
|
1014
|
+
kind=RiskKind.THIRD_PARTY_GUARANTEE,
|
|
1015
|
+
detail=f"{connection.at_airport}: separate tickets, aggregator guarantee",
|
|
1016
|
+
)
|
|
1017
|
+
)
|
|
1018
|
+
else:
|
|
1019
|
+
discomfort.append(
|
|
1020
|
+
(
|
|
1021
|
+
prefs.discomfort.cost(
|
|
1022
|
+
prefs.discomfort.self_transfer + prefs.discomfort.separate_ticket
|
|
1023
|
+
),
|
|
1024
|
+
f"{connection.at_airport}: self-transfer on separate tickets",
|
|
1025
|
+
)
|
|
1026
|
+
)
|
|
1027
|
+
risks.append(
|
|
1028
|
+
RiskAnnotation(
|
|
1029
|
+
kind=RiskKind.SEPARATE_PNR,
|
|
1030
|
+
detail=f"{connection.at_airport}: self-transfer, no protection",
|
|
1031
|
+
)
|
|
1032
|
+
)
|
|
1033
|
+
if entry_required:
|
|
1034
|
+
discomfort.append(
|
|
1035
|
+
(
|
|
1036
|
+
prefs.discomfort.cost(prefs.discomfort.border_entry_exit),
|
|
1037
|
+
f"{connection.at_airport}: border inside a self-transfer",
|
|
1038
|
+
)
|
|
1039
|
+
)
|
|
1040
|
+
risks.append(
|
|
1041
|
+
RiskAnnotation(
|
|
1042
|
+
kind=RiskKind.ENTRY_EXIT_TRANSIT,
|
|
1043
|
+
detail=f"{connection.at_airport}: border in the middle of a self-transfer",
|
|
1044
|
+
)
|
|
1045
|
+
)
|
|
1046
|
+
if ctx.checked_bag:
|
|
1047
|
+
discomfort.append(
|
|
1048
|
+
(
|
|
1049
|
+
prefs.discomfort.cost(prefs.discomfort.bag_recheck),
|
|
1050
|
+
f"{connection.at_airport}: claim and re-check the bag",
|
|
1051
|
+
)
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
# Total absorbing time: slack beyond the buffer plus the allowance the buffer
|
|
1055
|
+
# already contains (§8.9) — see p_miss on why bare slack is wrong.
|
|
1056
|
+
cushion = verdict.slack_minutes + verdict.breakdown.delay_allowance_minutes
|
|
1057
|
+
probability = p_miss(inbound_delay, cushion)
|
|
1058
|
+
replacement = prefs.delay.misconnect_replacement_cost * ctx.party.size
|
|
1059
|
+
consequence = replacement
|
|
1060
|
+
if connection.onward_is_last_of_day:
|
|
1061
|
+
overnight_cost, _ = _overnight_choice(8.0, prefs)
|
|
1062
|
+
consequence += overnight_cost * ctx.party.size + 24 * 0.5 * wage
|
|
1063
|
+
expected_misconnect += probability * consequence
|
|
1064
|
+
expected_violation += _expected_violation_cost(
|
|
1065
|
+
plan, connection, verdict, inbound_delay, ctx
|
|
1066
|
+
)
|
|
1067
|
+
notes.append(
|
|
1068
|
+
f"{connection.at_airport}: P(miss)={probability:.0%} on "
|
|
1069
|
+
f"{cushion:.0f}m cushion, consequence ~{consequence:.0f}"
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
# ── flexibility ──────────────────────────────────────────────────────────
|
|
1073
|
+
moments = align(plan, [stop.place for stop in ctx.journey])
|
|
1074
|
+
for stop, actual in zip(ctx.journey, moments, strict=False):
|
|
1075
|
+
for verb, when, ask in (
|
|
1076
|
+
("into", actual.arrive, stop.arrive),
|
|
1077
|
+
("out of", actual.depart, stop.depart),
|
|
1078
|
+
):
|
|
1079
|
+
if when is None or ask is None:
|
|
1080
|
+
continue
|
|
1081
|
+
priced = ask(when)
|
|
1082
|
+
if priced is None or not priced.amount:
|
|
1083
|
+
continue
|
|
1084
|
+
stamp = f"{when:%Y-%m-%d %H:%M}" if priced.timed else f"{when.date()}"
|
|
1085
|
+
lines.append(
|
|
1086
|
+
CostLine(
|
|
1087
|
+
CostKind.DATE_FLEXIBILITY,
|
|
1088
|
+
Band.exact(priced.amount),
|
|
1089
|
+
f"{verb} {stop.place} {stamp}",
|
|
1090
|
+
)
|
|
1091
|
+
)
|
|
1092
|
+
if not ctx.journey and plan.segments:
|
|
1093
|
+
# No journey stated: the older single-window path, outbound departure only.
|
|
1094
|
+
first = plan.segments[0].depart
|
|
1095
|
+
priced = ctx.departure_cost(first) if ctx.departure_cost else None
|
|
1096
|
+
cost = priced.amount if priced else ctx.date_costs.get(first.date())
|
|
1097
|
+
if cost:
|
|
1098
|
+
stamp = f"{first:%Y-%m-%d %H:%M}" if priced and priced.timed else f"{first.date()}"
|
|
1099
|
+
lines.append(
|
|
1100
|
+
CostLine(CostKind.DATE_FLEXIBILITY, Band.exact(cost), f"leaves {stamp}")
|
|
1101
|
+
)
|
|
1102
|
+
# Trip length is only defined for a plan that comes home. A one-way — or the
|
|
1103
|
+
# outbound half of a round trip, which is what a Google Flights row is — has no
|
|
1104
|
+
# duration to deviate from, and charging one would punish every candidate
|
|
1105
|
+
# identically while looking like a real cost.
|
|
1106
|
+
returns_home = plan.segments[-1].destination == plan.segments[0].origin
|
|
1107
|
+
if returns_home and (ctx.duration_cost or ctx.preferred_nights is not None):
|
|
1108
|
+
nights = (plan.arrive.date() - plan.depart.date()).days
|
|
1109
|
+
cost = ctx.duration_cost(nights) if ctx.duration_cost else None
|
|
1110
|
+
note = f"{nights}n vs the stated length"
|
|
1111
|
+
if cost is None and ctx.preferred_nights is not None:
|
|
1112
|
+
# No window, only a preferred length: the standing per-night rate, which
|
|
1113
|
+
# is asymmetric because an extra day away and a day short of the trip you
|
|
1114
|
+
# planned are different kinds of loss.
|
|
1115
|
+
cost = prefs.flexibility.duration_cost(nights, ctx.preferred_nights)
|
|
1116
|
+
direction = "away longer" if nights > ctx.preferred_nights else "shorter"
|
|
1117
|
+
note = f"{nights}n vs preferred {ctx.preferred_nights}n ({direction})"
|
|
1118
|
+
if cost:
|
|
1119
|
+
lines.append(CostLine(CostKind.DURATION_DEVIATION, Band.exact(cost), note))
|
|
1120
|
+
|
|
1121
|
+
# ── hard constraints: checked, not priced (§8.1) ─────────────────────────
|
|
1122
|
+
violations = check_constraints(plan, ctx.constraints)
|
|
1123
|
+
|
|
1124
|
+
# ── discomfort: what the trip costs in unpleasantness (§8.2) ─────────────
|
|
1125
|
+
if discomfort:
|
|
1126
|
+
total_discomfort = sum(amount for amount, _ in discomfort)
|
|
1127
|
+
lines.append(
|
|
1128
|
+
CostLine(
|
|
1129
|
+
CostKind.DISCOMFORT,
|
|
1130
|
+
Band.exact(total_discomfort * ctx.party.size),
|
|
1131
|
+
"; ".join(note for _, note in discomfort)
|
|
1132
|
+
+ (f" (x{ctx.party.size} travellers)" if ctx.party.size > 1 else "")
|
|
1133
|
+
+ (
|
|
1134
|
+
f" [comfort scale {prefs.discomfort.scale:g}]"
|
|
1135
|
+
if prefs.discomfort.scale != 1.0
|
|
1136
|
+
else ""
|
|
1137
|
+
),
|
|
1138
|
+
)
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
# ── risk: probabilities times consequences, nothing else (§8.7) ──────────
|
|
1142
|
+
# No hassle terms and no floor: unpleasantness is priced above as discomfort, and
|
|
1143
|
+
# the stakes of a hard constraint are what make a risky plan lose.
|
|
1144
|
+
if expected_misconnect or expected_violation:
|
|
1145
|
+
detail = f"expected misconnection {expected_misconnect:.0f}"
|
|
1146
|
+
if expected_violation:
|
|
1147
|
+
detail += f" + expected constraint violation {expected_violation:.0f}"
|
|
1148
|
+
lines.append(
|
|
1149
|
+
CostLine(
|
|
1150
|
+
CostKind.RISK_PREMIUM,
|
|
1151
|
+
Band.exact(expected_misconnect + expected_violation),
|
|
1152
|
+
detail,
|
|
1153
|
+
)
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
return PlanCost(
|
|
1157
|
+
lines=lines,
|
|
1158
|
+
money_total=money,
|
|
1159
|
+
risks=risks,
|
|
1160
|
+
infeasible_reasons=infeasible,
|
|
1161
|
+
verdicts=verdicts,
|
|
1162
|
+
borders=party_borders,
|
|
1163
|
+
notes=notes,
|
|
1164
|
+
opportunities=opportunities,
|
|
1165
|
+
assumptions=assumptions,
|
|
1166
|
+
violations=violations,
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def breakeven_wage_rate(
|
|
1171
|
+
plan_a: TripPlan,
|
|
1172
|
+
plan_b: TripPlan,
|
|
1173
|
+
ctx: PricingContext,
|
|
1174
|
+
*,
|
|
1175
|
+
low: float = 0.0,
|
|
1176
|
+
high: float = 500.0,
|
|
1177
|
+
tolerance: float = 0.05,
|
|
1178
|
+
) -> float | None:
|
|
1179
|
+
"""The hourly rate at which two plans swap places (DESIGN §8.8).
|
|
1180
|
+
|
|
1181
|
+
Because `wage_rate_per_hour` is a guess, every report states this: "#1 and #2
|
|
1182
|
+
trade places if your time is worth less than €4.50/h" tells the owner whether
|
|
1183
|
+
a ranking is a finding or an artifact. Returns None when no crossing exists in
|
|
1184
|
+
the range — the ordering does not depend on the rate at all.
|
|
1185
|
+
"""
|
|
1186
|
+
|
|
1187
|
+
def delta(rate: float) -> float:
|
|
1188
|
+
probe = replace(
|
|
1189
|
+
ctx, prefs=ctx.prefs.with_overrides({"time": {"wage_rate_per_hour": rate}})
|
|
1190
|
+
)
|
|
1191
|
+
return (
|
|
1192
|
+
price_plan(plan_a, probe).generalized_cost.mid
|
|
1193
|
+
- price_plan(plan_b, probe).generalized_cost.mid
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
low_delta, high_delta = delta(low), delta(high)
|
|
1197
|
+
if (low_delta > 0) == (high_delta > 0):
|
|
1198
|
+
return None
|
|
1199
|
+
for _ in range(60):
|
|
1200
|
+
mid = (low + high) / 2
|
|
1201
|
+
if high - low < tolerance:
|
|
1202
|
+
return round(mid, 2)
|
|
1203
|
+
if (delta(mid) > 0) == (low_delta > 0):
|
|
1204
|
+
low = mid
|
|
1205
|
+
else:
|
|
1206
|
+
high = mid
|
|
1207
|
+
return round((low + high) / 2, 2)
|