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 @@
|
|
|
1
|
+
"""Interaction layer: questions as data (DESIGN 10)."""
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Assumptions as data, not prompts (DESIGN §10).
|
|
2
|
+
|
|
3
|
+
Some inputs cannot be known in advance and should not be silently defaulted: is
|
|
4
|
+
this ESTA still valid, is a 4-hour self-transfer acceptable today, is a night in
|
|
5
|
+
Santorini worth having. The solver emits `Assumption` objects; a front end decides
|
|
6
|
+
how to ask them — a CLI prompt, an MCP round-trip through Claude, or nothing at all
|
|
7
|
+
in an unattended run, where the default is used and *recorded as assumed*.
|
|
8
|
+
|
|
9
|
+
Every option carries its pros and cons in plain language, because that is what
|
|
10
|
+
makes a question answerable by a human at the moment of deciding — and the same
|
|
11
|
+
text is what the report prints next to the plan the answer produced.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Source(StrEnum):
|
|
21
|
+
CONFIG = "config"
|
|
22
|
+
"""Came from the owner's config: a settled preference."""
|
|
23
|
+
|
|
24
|
+
ASKED = "asked"
|
|
25
|
+
"""A human answered this, in this session."""
|
|
26
|
+
|
|
27
|
+
ASSUMED = "assumed"
|
|
28
|
+
"""Nobody was there to ask; the default was used and is reported as such."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class Option:
|
|
33
|
+
label: str
|
|
34
|
+
value: object
|
|
35
|
+
pros: str = ""
|
|
36
|
+
cons: str = ""
|
|
37
|
+
|
|
38
|
+
def describe(self) -> str:
|
|
39
|
+
bits = [self.label]
|
|
40
|
+
if self.pros:
|
|
41
|
+
bits.append(f"+ {self.pros}")
|
|
42
|
+
if self.cons:
|
|
43
|
+
bits.append(f"- {self.cons}")
|
|
44
|
+
return " ".join(bits)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class Assumption:
|
|
49
|
+
key: str
|
|
50
|
+
question: str
|
|
51
|
+
options: list[Option]
|
|
52
|
+
default: object
|
|
53
|
+
source: Source = Source.ASSUMED
|
|
54
|
+
blocking: bool = False
|
|
55
|
+
"""Blocking questions can make a plan infeasible or unbookable — held
|
|
56
|
+
authorizations, unknown transit regimes, trip mode, airport-change consent.
|
|
57
|
+
Non-blocking ones are comfort and money trade-offs the default handles."""
|
|
58
|
+
affects: list[str] = field(default_factory=list)
|
|
59
|
+
"""Plan ids whose cost depends on the answer."""
|
|
60
|
+
answer: object | None = None
|
|
61
|
+
remember: bool = False
|
|
62
|
+
"""Whether the answer should be promoted into config (`--remember`)."""
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def value(self) -> object:
|
|
66
|
+
return self.default if self.answer is None else self.answer
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def answered(self) -> bool:
|
|
70
|
+
return self.answer is not None
|
|
71
|
+
|
|
72
|
+
def describe(self) -> str:
|
|
73
|
+
head = f"[{self.source}] {self.question}"
|
|
74
|
+
body = "\n".join(f" {o.describe()}" for o in self.options)
|
|
75
|
+
return f"{head}\n{body}" if body else head
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class AssumptionLog:
|
|
80
|
+
"""Everything the system had to decide without being told, in one place.
|
|
81
|
+
|
|
82
|
+
The report prints this verbatim: a plan whose ranking rests on an assumed
|
|
83
|
+
answer must say so, or the number looks more solid than it is.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
entries: list[Assumption] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
def add(self, assumption: Assumption) -> Assumption:
|
|
89
|
+
existing = self.get(assumption.key)
|
|
90
|
+
if existing is not None:
|
|
91
|
+
return existing
|
|
92
|
+
self.entries.append(assumption)
|
|
93
|
+
return assumption
|
|
94
|
+
|
|
95
|
+
def get(self, key: str) -> Assumption | None:
|
|
96
|
+
for entry in self.entries:
|
|
97
|
+
if entry.key == key:
|
|
98
|
+
return entry
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
def answer(self, key: str, value: object, *, remember: bool = False) -> None:
|
|
102
|
+
entry = self.get(key)
|
|
103
|
+
if entry is None:
|
|
104
|
+
raise KeyError(f"no assumption {key!r} to answer")
|
|
105
|
+
entry.answer = value
|
|
106
|
+
entry.source = Source.ASKED
|
|
107
|
+
entry.remember = remember
|
|
108
|
+
|
|
109
|
+
def value_of(self, key: str, fallback: object = None) -> object:
|
|
110
|
+
entry = self.get(key)
|
|
111
|
+
return fallback if entry is None else entry.value
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def blocking_unanswered(self) -> list[Assumption]:
|
|
115
|
+
return [e for e in self.entries if e.blocking and not e.answered]
|
|
116
|
+
|
|
117
|
+
def describe(self) -> str:
|
|
118
|
+
if not self.entries:
|
|
119
|
+
return "no assumptions — everything came from config or the itinerary"
|
|
120
|
+
return "\n".join(e.describe() for e in self.entries)
|
layover/models.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Canonical models (DESIGN §5).
|
|
2
|
+
|
|
3
|
+
Everything an oracle returns is normalized into these before the solver or the
|
|
4
|
+
cost model sees it. The models deliberately carry *protection structure* — how
|
|
5
|
+
many tickets, who owns the connection risk, whether bags are through-checked —
|
|
6
|
+
because none of that can be reconstructed later from a price and a duration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import itertools
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field, model_validator
|
|
16
|
+
|
|
17
|
+
from layover.money import Money
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransportMode(StrEnum):
|
|
21
|
+
AIR = "air"
|
|
22
|
+
RAIL = "rail"
|
|
23
|
+
BUS = "bus"
|
|
24
|
+
FERRY = "ferry"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Cabin(StrEnum):
|
|
28
|
+
ECONOMY = "economy"
|
|
29
|
+
PREMIUM = "premium"
|
|
30
|
+
BUSINESS = "business"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaggageProfile(StrEnum):
|
|
34
|
+
PERSONAL_ITEM_ONLY = "personal_item_only"
|
|
35
|
+
CABIN_BAG = "cabin_bag"
|
|
36
|
+
CABIN_PLUS_CHECKED = "cabin_plus_checked"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Protection(StrEnum):
|
|
40
|
+
"""Who carries the risk if a connection inside one offer fails."""
|
|
41
|
+
|
|
42
|
+
SINGLE_TICKET = "single_ticket"
|
|
43
|
+
"""One PNR: the airline rebooks you. Its own MCT applies."""
|
|
44
|
+
|
|
45
|
+
GUARANTEED_SELF_TRANSFER = "guaranteed_self_transfer"
|
|
46
|
+
"""Separate tickets with an aggregator guarantee (Kiwi). Covered, but by the
|
|
47
|
+
aggregator rather than the airline — the middle risk rate applies."""
|
|
48
|
+
|
|
49
|
+
SELF_TRANSFER = "self_transfer"
|
|
50
|
+
"""Separate tickets, nobody's problem but yours."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TripShape(StrEnum):
|
|
54
|
+
ONE_WAY = "one_way"
|
|
55
|
+
RETURN = "return"
|
|
56
|
+
OPEN_JAW = "open_jaw"
|
|
57
|
+
MULTI_CITY = "multi_city"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TriState(StrEnum):
|
|
61
|
+
YES = "yes"
|
|
62
|
+
NO = "no"
|
|
63
|
+
UNKNOWN = "unknown"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Segment(BaseModel):
|
|
67
|
+
"""One flight (or ground leg, later)."""
|
|
68
|
+
|
|
69
|
+
carrier: str
|
|
70
|
+
flight_number: str
|
|
71
|
+
origin: str = Field(min_length=3, max_length=4)
|
|
72
|
+
destination: str = Field(min_length=3, max_length=4)
|
|
73
|
+
depart: datetime
|
|
74
|
+
arrive: datetime
|
|
75
|
+
mode: TransportMode = TransportMode.AIR
|
|
76
|
+
cabin: Cabin = Cabin.ECONOMY
|
|
77
|
+
equipment: str | None = None
|
|
78
|
+
last_departure_of_day: bool | None = None
|
|
79
|
+
"""Whether this is the day's final departure on the route. Missing it means an
|
|
80
|
+
overnight and a lost day, not a rebooking — so adapters must report it when
|
|
81
|
+
they can (ORACLES §3)."""
|
|
82
|
+
|
|
83
|
+
@model_validator(mode="after")
|
|
84
|
+
def _check_times(self) -> Segment:
|
|
85
|
+
if self.depart.tzinfo is None or self.arrive.tzinfo is None:
|
|
86
|
+
raise ValueError("segment times must be timezone-aware")
|
|
87
|
+
if self.arrive <= self.depart:
|
|
88
|
+
raise ValueError(f"{self.flight_number}: arrive {self.arrive} not after depart")
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def duration(self) -> timedelta:
|
|
93
|
+
return self.arrive - self.depart
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def local_departure_hour(self) -> int:
|
|
97
|
+
return self.depart.hour
|
|
98
|
+
|
|
99
|
+
def __str__(self) -> str:
|
|
100
|
+
return (
|
|
101
|
+
f"{self.carrier}{self.flight_number} {self.origin}-{self.destination} "
|
|
102
|
+
f"{self.depart:%Y-%m-%d %H:%M}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Leg(BaseModel):
|
|
107
|
+
"""Ordered segments sold as one bound (e.g. the outbound of a return)."""
|
|
108
|
+
|
|
109
|
+
segments: list[Segment] = Field(min_length=1)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def origin(self) -> str:
|
|
113
|
+
return self.segments[0].origin
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def destination(self) -> str:
|
|
117
|
+
return self.segments[-1].destination
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def depart(self) -> datetime:
|
|
121
|
+
return self.segments[0].depart
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def arrive(self) -> datetime:
|
|
125
|
+
return self.segments[-1].arrive
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def elapsed(self) -> timedelta:
|
|
129
|
+
return self.arrive - self.depart
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class POSContext(BaseModel):
|
|
133
|
+
"""Point of sale a price was observed under (DESIGN §9)."""
|
|
134
|
+
|
|
135
|
+
site_market: str | None = None
|
|
136
|
+
currency: str = "EUR"
|
|
137
|
+
locale: str | None = None
|
|
138
|
+
ip_country: str | None = None
|
|
139
|
+
"""None = native IP."""
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def name(self) -> str:
|
|
143
|
+
return self.site_market or self.ip_country or "native"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class BaggageIncluded(BaseModel):
|
|
147
|
+
personal_item: TriState = TriState.UNKNOWN
|
|
148
|
+
cabin_bag: TriState = TriState.UNKNOWN
|
|
149
|
+
checked: TriState = TriState.UNKNOWN
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class OfferKind(StrEnum):
|
|
153
|
+
THROUGH = "through"
|
|
154
|
+
LEG_PROBE = "leg_probe"
|
|
155
|
+
OW_PROBE = "ow_probe"
|
|
156
|
+
POS_PROBE = "pos_probe"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Offer(BaseModel):
|
|
160
|
+
"""One purchasable ticket."""
|
|
161
|
+
|
|
162
|
+
legs: list[Leg] = Field(min_length=1)
|
|
163
|
+
price: Money
|
|
164
|
+
price_normalized: float | None = None
|
|
165
|
+
"""None means no reference rate was available — the offer is uncomparable and
|
|
166
|
+
excluded from ranking rather than converted at a guessed rate (DESIGN §9)."""
|
|
167
|
+
protection: Protection = Protection.SINGLE_TICKET
|
|
168
|
+
baggage_included: BaggageIncluded = Field(default_factory=BaggageIncluded)
|
|
169
|
+
pos: POSContext = Field(default_factory=POSContext)
|
|
170
|
+
oracle_id: str = "unknown"
|
|
171
|
+
observed_at: datetime | None = None
|
|
172
|
+
deep_link: str | None = None
|
|
173
|
+
fare_notes: list[str] = Field(default_factory=list)
|
|
174
|
+
offer_kind: OfferKind = OfferKind.THROUGH
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def segments(self) -> list[Segment]:
|
|
178
|
+
return [s for leg in self.legs for s in leg.segments]
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def identity_key(self) -> tuple:
|
|
182
|
+
"""Same key from two oracles means the same product (DESIGN §5)."""
|
|
183
|
+
return (
|
|
184
|
+
tuple(
|
|
185
|
+
(s.carrier, s.flight_number, s.depart.date().isoformat()) for s in self.segments
|
|
186
|
+
),
|
|
187
|
+
self.segments[0].cabin,
|
|
188
|
+
self.pos.name,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Connection(BaseModel):
|
|
193
|
+
"""A gap between two consecutive segments of a plan.
|
|
194
|
+
|
|
195
|
+
Whether this is a 50-minute non-event or the thing that wrecks the trip
|
|
196
|
+
depends on who owns it, which is why the flags live here rather than being
|
|
197
|
+
inferred at pricing time.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
arrive_segment: Segment
|
|
201
|
+
depart_segment: Segment
|
|
202
|
+
protection: Protection
|
|
203
|
+
within_offer: bool
|
|
204
|
+
"""True when both segments are on the same ticket."""
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def gap(self) -> timedelta:
|
|
208
|
+
return self.depart_segment.depart - self.arrive_segment.arrive
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def gap_minutes(self) -> float:
|
|
212
|
+
return self.gap.total_seconds() / 60
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def at_airport(self) -> str:
|
|
216
|
+
return self.arrive_segment.destination
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def airport_change(self) -> bool:
|
|
220
|
+
return self.arrive_segment.destination != self.depart_segment.origin
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def is_protected(self) -> bool:
|
|
224
|
+
return self.protection is Protection.SINGLE_TICKET
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def onward_is_last_of_day(self) -> bool:
|
|
228
|
+
return bool(self.depart_segment.last_departure_of_day)
|
|
229
|
+
|
|
230
|
+
def __str__(self) -> str:
|
|
231
|
+
where = (
|
|
232
|
+
f"{self.arrive_segment.destination}->{self.depart_segment.origin}"
|
|
233
|
+
if self.airport_change
|
|
234
|
+
else self.at_airport
|
|
235
|
+
)
|
|
236
|
+
return f"{where} {self.gap_minutes / 60:.1f}h ({self.protection})"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TripPlan(BaseModel):
|
|
240
|
+
"""An ordered composition of 1..n offers covering the intent.
|
|
241
|
+
|
|
242
|
+
Cost fields are not stored on the model: pricing is a pure function of a plan
|
|
243
|
+
plus a context (preferences, party, reference data), so the same plan can be
|
|
244
|
+
priced under two trip modes without mutating anything.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
offers: list[Offer] = Field(min_length=1)
|
|
248
|
+
plan_id: str = "plan"
|
|
249
|
+
label: str = ""
|
|
250
|
+
"""How this plan was arrived at — "2 one-ways" and so on.
|
|
251
|
+
|
|
252
|
+
A probe's answer is only useful if the report can say which shape of purchase it
|
|
253
|
+
is: two itineraries with the same fare are not the same offer when one of them is
|
|
254
|
+
two bookings (DESIGN §6)."""
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def segments(self) -> list[Segment]:
|
|
258
|
+
return [s for offer in self.offers for s in offer.segments]
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def money_out_of_pocket(self) -> float | None:
|
|
262
|
+
"""Sum of normalized fares, or None if any offer could not be normalized."""
|
|
263
|
+
total = 0.0
|
|
264
|
+
for offer in self.offers:
|
|
265
|
+
if offer.price_normalized is None:
|
|
266
|
+
return None
|
|
267
|
+
total += offer.price_normalized
|
|
268
|
+
return total
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def depart(self) -> datetime:
|
|
272
|
+
return self.segments[0].depart
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def arrive(self) -> datetime:
|
|
276
|
+
return self.segments[-1].arrive
|
|
277
|
+
|
|
278
|
+
def connections(self) -> list[Connection]:
|
|
279
|
+
"""All gaps in the plan, tagged with who owns the risk.
|
|
280
|
+
|
|
281
|
+
Connections inside one offer inherit that offer's protection. Connections
|
|
282
|
+
*between* offers are bare self-transfers by construction: two tickets
|
|
283
|
+
bought separately have no relationship at all.
|
|
284
|
+
"""
|
|
285
|
+
out: list[Connection] = []
|
|
286
|
+
for offer in self.offers:
|
|
287
|
+
segs = offer.segments
|
|
288
|
+
for a, b in itertools.pairwise(segs):
|
|
289
|
+
out.append(
|
|
290
|
+
Connection(
|
|
291
|
+
arrive_segment=a,
|
|
292
|
+
depart_segment=b,
|
|
293
|
+
protection=offer.protection,
|
|
294
|
+
within_offer=True,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
for prev, nxt in itertools.pairwise(self.offers):
|
|
298
|
+
out.append(
|
|
299
|
+
Connection(
|
|
300
|
+
arrive_segment=prev.segments[-1],
|
|
301
|
+
depart_segment=nxt.segments[0],
|
|
302
|
+
protection=Protection.SELF_TRANSFER,
|
|
303
|
+
within_offer=False,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
out.sort(key=lambda c: c.arrive_segment.arrive)
|
|
307
|
+
return out
|
|
308
|
+
|
|
309
|
+
def stays(self) -> list[Stay]:
|
|
310
|
+
"""Gaps long enough to be a stay somewhere rather than a connection.
|
|
311
|
+
|
|
312
|
+
Whether a stay is a wanted stopover or an involuntary layover is not a
|
|
313
|
+
property of the itinerary — it depends on what the intent asked for, so
|
|
314
|
+
that judgement belongs to the cost model (DESIGN §8.3), not here.
|
|
315
|
+
"""
|
|
316
|
+
return [
|
|
317
|
+
Stay(
|
|
318
|
+
place=c.at_airport,
|
|
319
|
+
arrive=c.arrive_segment.arrive,
|
|
320
|
+
depart=c.depart_segment.depart,
|
|
321
|
+
)
|
|
322
|
+
for c in self.connections()
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class Stay(BaseModel):
|
|
327
|
+
"""Time on the ground between two flights, at one place."""
|
|
328
|
+
|
|
329
|
+
place: str
|
|
330
|
+
arrive: datetime
|
|
331
|
+
depart: datetime
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def hours(self) -> float:
|
|
335
|
+
return (self.depart - self.arrive).total_seconds() / 3600
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def nights(self) -> int:
|
|
339
|
+
"""Calendar nights, which is what hotels and stopover rules count."""
|
|
340
|
+
return max(0, (self.depart.date() - self.arrive.date()).days)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class RiskKind(StrEnum):
|
|
344
|
+
SELF_TRANSFER = "self_transfer"
|
|
345
|
+
SEPARATE_PNR = "separate_pnr"
|
|
346
|
+
THIRD_PARTY_GUARANTEE = "third_party_guarantee"
|
|
347
|
+
AIRPORT_CHANGE = "airport_change"
|
|
348
|
+
OVERNIGHT_SELF_TRANSFER = "overnight_self_transfer"
|
|
349
|
+
ENTRY_EXIT_TRANSIT = "entry_exit_transit"
|
|
350
|
+
LCC_BAG_GAUGE = "lcc_bag_gauge"
|
|
351
|
+
SKIPLAG = "skiplag"
|
|
352
|
+
AUTHORIZATION_PENDING = "authorization_pending"
|
|
353
|
+
UNKNOWN_TRANSIT_REGIME = "unknown_transit_regime"
|
|
354
|
+
VISA_CHECK = "visa_check"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class RiskAnnotation(BaseModel):
|
|
358
|
+
kind: RiskKind
|
|
359
|
+
detail: str
|
|
360
|
+
traveler_id: str | None = None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
TripPlan.model_rebuild()
|
layover/money.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Money and cost bands.
|
|
2
|
+
|
|
3
|
+
Every computed cost in layover is a *band*, not a number: ancillary fees are
|
|
4
|
+
ranges, preference terms the user never set are ranges, and a delay prior is an
|
|
5
|
+
expectation. Ranking uses the midpoint; the report shows the interval; two plans
|
|
6
|
+
whose intervals overlap are "too close to call" rather than ordered (DESIGN §8.8).
|
|
7
|
+
|
|
8
|
+
Values are floats in the session's normalization currency. Cents are not the
|
|
9
|
+
point — the fee tables and priors feeding this are accurate to a few euros at
|
|
10
|
+
best, and pretending otherwise with Decimal would be false precision. Actual
|
|
11
|
+
observed fares keep their own currency in `Money` until normalized.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Money(BaseModel):
|
|
22
|
+
"""An observed price in its original currency, exactly as the oracle said it."""
|
|
23
|
+
|
|
24
|
+
amount: float
|
|
25
|
+
currency: str = Field(min_length=3, max_length=3)
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
return f"{self.amount:,.2f} {self.currency}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class Band:
|
|
33
|
+
"""A cost interval in the normalization currency: low <= mid <= high."""
|
|
34
|
+
|
|
35
|
+
low: float
|
|
36
|
+
mid: float
|
|
37
|
+
high: float
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if not (self.low <= self.mid <= self.high):
|
|
41
|
+
raise ValueError(f"band must satisfy low <= mid <= high, got {self!r}")
|
|
42
|
+
|
|
43
|
+
# ── constructors ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def exact(cls, value: float) -> Band:
|
|
47
|
+
"""A cost we know."""
|
|
48
|
+
return cls(value, value, value)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def between(cls, low: float, high: float, *, mid: float | None = None) -> Band:
|
|
52
|
+
"""A cost we only know within a range — e.g. an unpublished bag fee."""
|
|
53
|
+
if high < low:
|
|
54
|
+
raise ValueError(f"high {high} below low {low}")
|
|
55
|
+
return cls(low, mid if mid is not None else (low + high) / 2, high)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def zero(cls) -> Band:
|
|
59
|
+
return cls(0.0, 0.0, 0.0)
|
|
60
|
+
|
|
61
|
+
# ── interval arithmetic ───────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def __add__(self, other: Band | float) -> Band:
|
|
64
|
+
if isinstance(other, int | float):
|
|
65
|
+
return Band(self.low + other, self.mid + other, self.high + other)
|
|
66
|
+
return Band(self.low + other.low, self.mid + other.mid, self.high + other.high)
|
|
67
|
+
|
|
68
|
+
__radd__ = __add__
|
|
69
|
+
|
|
70
|
+
def __mul__(self, factor: float) -> Band:
|
|
71
|
+
if factor < 0:
|
|
72
|
+
# Negative scaling flips the interval; nothing in the cost model needs
|
|
73
|
+
# it, and silently returning an inverted band would corrupt comparisons.
|
|
74
|
+
raise ValueError("bands scale by non-negative factors only")
|
|
75
|
+
return Band(self.low * factor, self.mid * factor, self.high * factor)
|
|
76
|
+
|
|
77
|
+
__rmul__ = __mul__
|
|
78
|
+
|
|
79
|
+
def __neg__(self) -> Band:
|
|
80
|
+
"""Negation for credits (stopover value offsetting lodging)."""
|
|
81
|
+
return Band(-self.high, -self.mid, -self.low)
|
|
82
|
+
|
|
83
|
+
def __sub__(self, other: Band | float) -> Band:
|
|
84
|
+
if isinstance(other, int | float):
|
|
85
|
+
return self + (-other)
|
|
86
|
+
return self + (-other)
|
|
87
|
+
|
|
88
|
+
# ── comparison ────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_exact(self) -> bool:
|
|
92
|
+
return self.low == self.high
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def width(self) -> float:
|
|
96
|
+
return self.high - self.low
|
|
97
|
+
|
|
98
|
+
def overlaps(self, other: Band) -> bool:
|
|
99
|
+
"""True when the two costs cannot be honestly ordered."""
|
|
100
|
+
return self.low <= other.high and other.low <= self.high
|
|
101
|
+
|
|
102
|
+
def __str__(self) -> str:
|
|
103
|
+
if self.is_exact:
|
|
104
|
+
return f"{self.mid:,.0f}"
|
|
105
|
+
return f"{self.low:,.0f}..{self.high:,.0f}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def band_sum(bands: list[Band]) -> Band:
|
|
109
|
+
total = Band.zero()
|
|
110
|
+
for b in bands:
|
|
111
|
+
total = total + b
|
|
112
|
+
return total
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Oracle abstraction: capability descriptors, typed failures, scheduling."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API oracles: HTTP sources that need no browser."""
|