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,213 @@
|
|
|
1
|
+
"""Hard constraints, kept separate from soft preferences (DESIGN §8.1).
|
|
2
|
+
|
|
3
|
+
The old model had one switch per trip — business or holiday — and real trips are
|
|
4
|
+
not like that. A conference in Bogotá starts at a fixed hour, which is as hard as a
|
|
5
|
+
constraint gets; but the way *there* was wide open, which is how a few days in Punta
|
|
6
|
+
Cana ended up in the itinerary. One trip, strict at one end and exploratory at the
|
|
7
|
+
other.
|
|
8
|
+
|
|
9
|
+
So the two kinds of requirement are modelled separately and never mixed:
|
|
10
|
+
|
|
11
|
+
* **Hard constraints** (this module) are facts about the world: be in Bogotá before
|
|
12
|
+
the conference opens, be home before Monday, do not depart before the last exam.
|
|
13
|
+
They are checked, not priced. A plan that violates one is not an expensive plan —
|
|
14
|
+
it is not a plan.
|
|
15
|
+
* **Soft preferences** (`prefs/profile.py`) are prices: what an hour, a night, or a
|
|
16
|
+
day of slippage is worth. They can differ per leg, because time on the way to
|
|
17
|
+
something can be worth more than time on the way home.
|
|
18
|
+
|
|
19
|
+
A constraint also carries **stakes** (`criticality`). That is what makes "definitely
|
|
20
|
+
arrive by 20:00" mean something: a schedule check alone passes any plan that intends
|
|
21
|
+
to be on time, however likely it is to slip. Stakes turn that probability into money,
|
|
22
|
+
which is what the old business/holiday switch was crudely approximating.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import datetime, timedelta
|
|
29
|
+
from enum import StrEnum
|
|
30
|
+
|
|
31
|
+
from layover.models import TripPlan
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConstraintKind(StrEnum):
|
|
35
|
+
ARRIVE_BY = "arrive_by"
|
|
36
|
+
"""Be at this place, on the ground, before this moment. The conference."""
|
|
37
|
+
|
|
38
|
+
DEPART_AFTER = "depart_after"
|
|
39
|
+
"""Cannot leave before this: a exam, a handover, a delivery."""
|
|
40
|
+
|
|
41
|
+
HOME_BY = "home_by"
|
|
42
|
+
"""Be back at the origin before this. Monday morning, usually."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Criticality(StrEnum):
|
|
46
|
+
"""How bad it is to miss this. The whole point of the class.
|
|
47
|
+
|
|
48
|
+
A schedule check alone treats "arrive by 20:00" as satisfied by any plan that
|
|
49
|
+
*intends* to arrive at 14:00 — including one with a 15% chance of arriving
|
|
50
|
+
tomorrow. Stakes are what make "definitely" mean something: they turn a
|
|
51
|
+
probability of missing into a cost, so a risky plan loses on the objective
|
|
52
|
+
instead of needing a mode flag to forbid it.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
INCONVENIENT = "inconvenient"
|
|
56
|
+
"""A day of leave, a rebooked dinner. Annoying, survivable."""
|
|
57
|
+
|
|
58
|
+
SERIOUS = "serious"
|
|
59
|
+
"""A missed day of a paid conference, a non-refundable booking."""
|
|
60
|
+
|
|
61
|
+
UNMISSABLE = "unmissable"
|
|
62
|
+
"""Your own talk, a wedding, the last connection to a cruise."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class HardConstraint:
|
|
67
|
+
"""A requirement that is checked, never priced — plus the stakes of missing it.
|
|
68
|
+
|
|
69
|
+
`buffer_hours` is the part people forget: arriving 40 minutes before a conference
|
|
70
|
+
opens satisfies the letter of the constraint and fails the point of it. The
|
|
71
|
+
default of 12 hours means "the evening before".
|
|
72
|
+
|
|
73
|
+
`criticality` (or an explicit `violation_cost`) is what replaces the old
|
|
74
|
+
business/holiday distinction: cushion, risk appetite and the rejection of clever
|
|
75
|
+
itineraries all follow from how much missing this would cost.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
kind: ConstraintKind
|
|
79
|
+
when: datetime
|
|
80
|
+
place: str | None = None
|
|
81
|
+
"""None means "wherever the plan ends" (HOME_BY) or "anywhere" (DEPART_AFTER)."""
|
|
82
|
+
buffer_hours: float = 12.0
|
|
83
|
+
label: str = ""
|
|
84
|
+
"""Human name — "IETF 129 opening session" beats "constraint 2" in a report."""
|
|
85
|
+
criticality: Criticality = Criticality.SERIOUS
|
|
86
|
+
violation_cost: float | None = None
|
|
87
|
+
"""Overrides the criticality price when the real number is known."""
|
|
88
|
+
|
|
89
|
+
def cost_of_missing(self, prices: object) -> float:
|
|
90
|
+
"""What missing this costs, in the normalization currency.
|
|
91
|
+
|
|
92
|
+
`prices` is a `ConstraintPrefs`; it is typed loosely to keep this module free
|
|
93
|
+
of a preferences import (constraints are facts, preferences are valuations).
|
|
94
|
+
"""
|
|
95
|
+
if self.violation_cost is not None:
|
|
96
|
+
return self.violation_cost
|
|
97
|
+
return float(getattr(prices, self.criticality.value))
|
|
98
|
+
|
|
99
|
+
def stake_weight(self, prices: object) -> float:
|
|
100
|
+
"""0..1 — how close to unmissable this is, for buffer inflation (§8.9)."""
|
|
101
|
+
top = float(getattr(prices, Criticality.UNMISSABLE.value))
|
|
102
|
+
if top <= 0:
|
|
103
|
+
return 0.0
|
|
104
|
+
return min(1.0, self.cost_of_missing(prices) / top)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def effective_deadline(self) -> datetime:
|
|
108
|
+
if self.kind is ConstraintKind.DEPART_AFTER:
|
|
109
|
+
return self.when + timedelta(hours=self.buffer_hours)
|
|
110
|
+
return self.when - timedelta(hours=self.buffer_hours)
|
|
111
|
+
|
|
112
|
+
def describe(self) -> str:
|
|
113
|
+
name = self.label or self.place or ""
|
|
114
|
+
where = f" at {self.place}" if self.place else ""
|
|
115
|
+
buffer = f" (+{self.buffer_hours:g}h buffer)" if self.buffer_hours else ""
|
|
116
|
+
return f"{self.kind}{where} {self.when:%Y-%m-%d %H:%M}{buffer}" + (
|
|
117
|
+
f" — {name}" if self.label else ""
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class ConstraintViolation:
|
|
123
|
+
constraint: HardConstraint
|
|
124
|
+
detail: str
|
|
125
|
+
|
|
126
|
+
def __str__(self) -> str:
|
|
127
|
+
return f"{self.constraint.describe()}: {self.detail}"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _arrival_at(plan: TripPlan, place: str) -> datetime | None:
|
|
131
|
+
"""When the plan first puts the traveller on the ground at `place`."""
|
|
132
|
+
for segment in plan.segments:
|
|
133
|
+
if segment.destination == place:
|
|
134
|
+
return segment.arrive
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check(plan: TripPlan, constraints: list[HardConstraint]) -> list[ConstraintViolation]:
|
|
139
|
+
"""Every way this plan fails a hard requirement.
|
|
140
|
+
|
|
141
|
+
All violations are returned rather than the first, because "this plan misses the
|
|
142
|
+
conference *and* gets you home a day late" is more useful than either half.
|
|
143
|
+
"""
|
|
144
|
+
violations: list[ConstraintViolation] = []
|
|
145
|
+
|
|
146
|
+
for constraint in constraints:
|
|
147
|
+
deadline = constraint.effective_deadline
|
|
148
|
+
|
|
149
|
+
if constraint.kind is ConstraintKind.ARRIVE_BY:
|
|
150
|
+
if constraint.place is None:
|
|
151
|
+
arrival = plan.arrive
|
|
152
|
+
else:
|
|
153
|
+
arrival = _arrival_at(plan, constraint.place)
|
|
154
|
+
if arrival is None:
|
|
155
|
+
violations.append(
|
|
156
|
+
ConstraintViolation(
|
|
157
|
+
constraint, f"the plan never reaches {constraint.place}"
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
continue
|
|
161
|
+
if arrival > deadline:
|
|
162
|
+
late = (arrival - deadline).total_seconds() / 3600
|
|
163
|
+
violations.append(
|
|
164
|
+
ConstraintViolation(
|
|
165
|
+
constraint,
|
|
166
|
+
f"arrives {arrival:%Y-%m-%d %H:%M}, {late:.1f}h past the "
|
|
167
|
+
f"{constraint.buffer_hours:g}h buffer",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
elif constraint.kind is ConstraintKind.DEPART_AFTER:
|
|
172
|
+
if plan.depart < deadline:
|
|
173
|
+
early = (deadline - plan.depart).total_seconds() / 3600
|
|
174
|
+
violations.append(
|
|
175
|
+
ConstraintViolation(
|
|
176
|
+
constraint,
|
|
177
|
+
f"departs {plan.depart:%Y-%m-%d %H:%M}, {early:.1f}h too early",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
elif constraint.kind is ConstraintKind.HOME_BY and plan.arrive > deadline:
|
|
182
|
+
late = (plan.arrive - deadline).total_seconds() / 3600
|
|
183
|
+
violations.append(
|
|
184
|
+
ConstraintViolation(
|
|
185
|
+
constraint,
|
|
186
|
+
f"home {plan.arrive:%Y-%m-%d %H:%M}, {late:.1f}h late",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return violations
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def slack_before(plan: TripPlan, constraint: HardConstraint) -> float | None:
|
|
194
|
+
"""Hours of room a plan has against one constraint.
|
|
195
|
+
|
|
196
|
+
This is what makes discovered stopovers possible on a work trip: a conference
|
|
197
|
+
that opens Thursday morning leaves days of slack on the way out, and slack is
|
|
198
|
+
exactly what a stopover spends. Returns None when the constraint does not apply
|
|
199
|
+
to this plan.
|
|
200
|
+
"""
|
|
201
|
+
deadline = constraint.effective_deadline
|
|
202
|
+
if constraint.kind is ConstraintKind.ARRIVE_BY:
|
|
203
|
+
arrival = (
|
|
204
|
+
plan.arrive if constraint.place is None else _arrival_at(plan, constraint.place)
|
|
205
|
+
)
|
|
206
|
+
if arrival is None:
|
|
207
|
+
return None
|
|
208
|
+
return (deadline - arrival).total_seconds() / 3600
|
|
209
|
+
if constraint.kind is ConstraintKind.DEPART_AFTER:
|
|
210
|
+
return (plan.depart - deadline).total_seconds() / 3600
|
|
211
|
+
if constraint.kind is ConstraintKind.HOME_BY:
|
|
212
|
+
return (deadline - plan.arrive).total_seconds() / 3600
|
|
213
|
+
return None
|
layover/cost/curve.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""One curve, used by everything that has a min, an ideal and a max (§8.4).
|
|
2
|
+
|
|
3
|
+
Departure, arrival and length are the same shape of preference. You state the
|
|
4
|
+
earliest that is possible at all, the value you actually want, and the latest — and
|
|
5
|
+
everything between is priced by how far it is from the ideal, in units of a tolerance
|
|
6
|
+
breadth:
|
|
7
|
+
|
|
8
|
+
cost = scale × ((value − ideal) / tolerance)²
|
|
9
|
+
|
|
10
|
+
Quadratic rather than linear because that is how the preference behaves: the first
|
|
11
|
+
step off target barely registers, the last one before the hard edge is expensive. The
|
|
12
|
+
tolerance defaults, per side, to the distance from the ideal to that bound — so the
|
|
13
|
+
edges cost exactly `scale`, and where the ideal sits inside the window *is* the
|
|
14
|
+
statement about which side is tighter. No asymmetry syntax required.
|
|
15
|
+
|
|
16
|
+
Everything here is in one unit. Callers convert first: days or hours for moments,
|
|
17
|
+
nights for lengths. That is the whole reason this module knows nothing about dates.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Bounds:
|
|
27
|
+
"""A window's three values, after the missing ones have been filled in."""
|
|
28
|
+
|
|
29
|
+
low: float
|
|
30
|
+
ideal: float
|
|
31
|
+
high: float
|
|
32
|
+
inferred: tuple[str, ...] = ()
|
|
33
|
+
"""Which of the three were not stated — the report says so rather than pretending."""
|
|
34
|
+
|
|
35
|
+
def contains(self, value: float) -> bool:
|
|
36
|
+
return self.low <= value <= self.high
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve(
|
|
40
|
+
low: float | None,
|
|
41
|
+
ideal: float | None,
|
|
42
|
+
high: float | None,
|
|
43
|
+
*,
|
|
44
|
+
spread: float | None = None,
|
|
45
|
+
) -> Bounds:
|
|
46
|
+
"""Fill in whichever of min/ideal/max were left out.
|
|
47
|
+
|
|
48
|
+
Two of the three is usually what someone actually knows — "no earlier than the
|
|
49
|
+
22nd, ideally the 24th", or "between five and nine nights" — and requiring the
|
|
50
|
+
third would only invite a made-up number. The rules:
|
|
51
|
+
|
|
52
|
+
* **min and max, no ideal.** The middle. Nothing was said to prefer either end.
|
|
53
|
+
* **one bound and an ideal.** Mirror the stated side. "Ideally Tuesday, no later
|
|
54
|
+
than Friday" says three days late is the limit, and says nothing about early;
|
|
55
|
+
treating early as unbounded would let the search wander off, and treating it as
|
|
56
|
+
forbidden would rule out Monday for no reason. Symmetry is the reading that
|
|
57
|
+
invents least.
|
|
58
|
+
* **an ideal alone.** A `spread` if the caller has one (`plus_minus_days`), else a
|
|
59
|
+
window of exactly one value — which is a legitimate thing to want.
|
|
60
|
+
|
|
61
|
+
Nothing at all is an error; a window has to be about something.
|
|
62
|
+
"""
|
|
63
|
+
inferred: list[str] = []
|
|
64
|
+
if ideal is None:
|
|
65
|
+
if low is not None and high is not None:
|
|
66
|
+
ideal = (low + high) / 2
|
|
67
|
+
inferred.append("ideal")
|
|
68
|
+
elif low is not None or high is not None:
|
|
69
|
+
ideal = low if low is not None else high
|
|
70
|
+
inferred.append("ideal")
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError("a window needs at least one of min, ideal or max")
|
|
73
|
+
|
|
74
|
+
if low is None and high is None and spread is not None:
|
|
75
|
+
low, high = ideal - spread, ideal + spread
|
|
76
|
+
if low is None:
|
|
77
|
+
low = ideal - (high - ideal) if high is not None else ideal
|
|
78
|
+
inferred.append("min")
|
|
79
|
+
if high is None:
|
|
80
|
+
high = ideal + (ideal - low)
|
|
81
|
+
inferred.append("max")
|
|
82
|
+
|
|
83
|
+
if low > high:
|
|
84
|
+
raise ValueError(f"window is empty: min {low} is after max {high}")
|
|
85
|
+
if not low <= ideal <= high:
|
|
86
|
+
raise ValueError(f"ideal {ideal} lies outside min {low}..max {high}")
|
|
87
|
+
return Bounds(low, ideal, high, tuple(inferred))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def tolerance_for(bounds: Bounds, *, earlier: bool, stated: float | None = None) -> float:
|
|
91
|
+
"""The breadth of the curve on one side, in the caller's unit."""
|
|
92
|
+
if stated is not None:
|
|
93
|
+
return stated
|
|
94
|
+
return (bounds.ideal - bounds.low) if earlier else (bounds.high - bounds.ideal)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cost(
|
|
98
|
+
value: float,
|
|
99
|
+
bounds: Bounds,
|
|
100
|
+
*,
|
|
101
|
+
scale: float,
|
|
102
|
+
tolerance: float | None = None,
|
|
103
|
+
offset: float = 0.0,
|
|
104
|
+
) -> float | None:
|
|
105
|
+
"""What this value costs. None means outside the window — not merely expensive.
|
|
106
|
+
|
|
107
|
+
Outside the bounds there is no window at all, which every caller must read as
|
|
108
|
+
"this does not exist" rather than "this is dear". Hard edges stay hard (§8.1).
|
|
109
|
+
|
|
110
|
+
`offset` moves the whole curve vertically, which is how a stop that is *worth
|
|
111
|
+
having* is expressed: a fortnight in Punta Cana at −800 rises through zero and
|
|
112
|
+
eventually goes positive, because the square swamps any offset in the end. That
|
|
113
|
+
is what bounds a desirable stay without needing a hard maximum — and it is only
|
|
114
|
+
meaningful for lengths. Moments are relative (you must depart at some point, so
|
|
115
|
+
only differences matter); lengths are absolute, since not stopping at all is a
|
|
116
|
+
real alternative (§8.3).
|
|
117
|
+
"""
|
|
118
|
+
if not bounds.contains(value):
|
|
119
|
+
return None
|
|
120
|
+
delta = value - bounds.ideal
|
|
121
|
+
if not delta:
|
|
122
|
+
return offset
|
|
123
|
+
breadth = tolerance_for(bounds, earlier=delta < 0, stated=tolerance)
|
|
124
|
+
if not breadth:
|
|
125
|
+
# The bound and the ideal coincide on this side, so anything off-target is
|
|
126
|
+
# already at the limit of what was allowed.
|
|
127
|
+
return offset + scale
|
|
128
|
+
return offset + scale * (delta / breadth) ** 2
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def breakeven(
|
|
132
|
+
bounds: Bounds, *, scale: float, tolerance: float | None = None, offset: float = 0.0
|
|
133
|
+
) -> tuple[float, float] | None:
|
|
134
|
+
"""Where an offset curve crosses zero — the range over which a stop pays for itself.
|
|
135
|
+
|
|
136
|
+
The answerable form of the question §10 asks: not "do you like Santorini?" but
|
|
137
|
+
"three to eleven nights pays for itself; past eleven it stops". None when the curve
|
|
138
|
+
never crosses, which means either the stop is never worth it or always is.
|
|
139
|
+
"""
|
|
140
|
+
if offset >= 0:
|
|
141
|
+
return None
|
|
142
|
+
low = tolerance_for(bounds, earlier=True, stated=tolerance)
|
|
143
|
+
high = tolerance_for(bounds, earlier=False, stated=tolerance)
|
|
144
|
+
reach = (-offset / scale) ** 0.5 if scale > 0 else float("inf")
|
|
145
|
+
return bounds.ideal - low * reach, bounds.ideal + high * reach
|
layover/cost/delay.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Delay priors and punctuality blending (DESIGN §8.5).
|
|
2
|
+
|
|
3
|
+
Delay is systematic, not noise: the first rotation leaves on time because the
|
|
4
|
+
aircraft slept there, slip accumulates through the day, and evening departures are
|
|
5
|
+
worst with no recovery flight left. A coarse prior by local departure hour is
|
|
6
|
+
enough to make an 07:20 departure beat a 19:05 one at equal fare.
|
|
7
|
+
|
|
8
|
+
For shortlisted flights the prior is *refined* by observed punctuality, never
|
|
9
|
+
replaced by it: four recent flights should nudge an estimate, sixty should decide
|
|
10
|
+
it. That is Bayesian shrinkage with the prior worth k pseudo-observations.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tomllib
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class DelayEstimate:
|
|
22
|
+
"""Expected slip and the tail probability that decides connections."""
|
|
23
|
+
|
|
24
|
+
mean_min: float
|
|
25
|
+
p_over_60: float
|
|
26
|
+
cancelled_pct: float = 1.5
|
|
27
|
+
samples: int = 0
|
|
28
|
+
source: str = "prior"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_observed(self) -> bool:
|
|
32
|
+
return self.samples > 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class PunctualityObservation:
|
|
37
|
+
"""What a punctuality oracle reported for one flight (ORACLES §8)."""
|
|
38
|
+
|
|
39
|
+
mean_delay_min: float
|
|
40
|
+
p_over_60: float
|
|
41
|
+
samples: int
|
|
42
|
+
cancelled_pct: float = 0.0
|
|
43
|
+
window_days: int = 90
|
|
44
|
+
same_season: bool = True
|
|
45
|
+
source: str = "unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DelayTable:
|
|
49
|
+
"""Hour-bucket prior, loaded from `reference/delay_profiles.toml`."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
buckets: dict[tuple[int, int], DelayEstimate],
|
|
54
|
+
*,
|
|
55
|
+
long_haul_mean_multiplier: float = 1.1,
|
|
56
|
+
long_haul_p_multiplier: float = 0.8,
|
|
57
|
+
cancellation_prior_pct: float = 1.5,
|
|
58
|
+
as_of: str = "unknown",
|
|
59
|
+
source: str = "unknown",
|
|
60
|
+
) -> None:
|
|
61
|
+
if not buckets:
|
|
62
|
+
raise ValueError("delay table needs at least one hour bucket")
|
|
63
|
+
self._buckets = buckets
|
|
64
|
+
self.long_haul_mean_multiplier = long_haul_mean_multiplier
|
|
65
|
+
self.long_haul_p_multiplier = long_haul_p_multiplier
|
|
66
|
+
self.cancellation_prior_pct = cancellation_prior_pct
|
|
67
|
+
self.as_of = as_of
|
|
68
|
+
self.source = source
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_toml(cls, path: Path) -> DelayTable:
|
|
72
|
+
raw = tomllib.loads(path.read_text())
|
|
73
|
+
buckets: dict[tuple[int, int], DelayEstimate] = {}
|
|
74
|
+
cancel = float((raw.get("cancellation") or {}).get("prior_pct", 1.5))
|
|
75
|
+
for span, body in (raw.get("by_local_departure_hour") or {}).items():
|
|
76
|
+
start, _, end = span.partition("-")
|
|
77
|
+
buckets[(int(start), int(end))] = DelayEstimate(
|
|
78
|
+
mean_min=float(body["mean_min"]),
|
|
79
|
+
p_over_60=float(body["p_over_60"]),
|
|
80
|
+
cancelled_pct=cancel,
|
|
81
|
+
)
|
|
82
|
+
long_haul = raw.get("long_haul") or {}
|
|
83
|
+
return cls(
|
|
84
|
+
buckets,
|
|
85
|
+
long_haul_mean_multiplier=float(long_haul.get("mean_min_multiplier", 1.1)),
|
|
86
|
+
long_haul_p_multiplier=float(long_haul.get("p_over_60_multiplier", 0.8)),
|
|
87
|
+
cancellation_prior_pct=cancel,
|
|
88
|
+
as_of=str(raw.get("as_of", "unknown")),
|
|
89
|
+
source=str(raw.get("source", "unknown")),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def fallback(cls) -> DelayTable:
|
|
94
|
+
"""Built-in prior, so the cost model works before reference data is wired."""
|
|
95
|
+
return cls(
|
|
96
|
+
{
|
|
97
|
+
(0, 5): DelayEstimate(18, 0.10),
|
|
98
|
+
(5, 9): DelayEstimate(8, 0.04),
|
|
99
|
+
(9, 13): DelayEstimate(14, 0.07),
|
|
100
|
+
(13, 18): DelayEstimate(20, 0.11),
|
|
101
|
+
(18, 24): DelayEstimate(28, 0.16),
|
|
102
|
+
},
|
|
103
|
+
as_of="built-in",
|
|
104
|
+
source="built-in fallback prior",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def prior_for_hour(self, hour: int, *, long_haul: bool = False) -> DelayEstimate:
|
|
108
|
+
hour %= 24
|
|
109
|
+
for (start, end), bucket in sorted(self._buckets.items()):
|
|
110
|
+
if start <= hour < end:
|
|
111
|
+
estimate = bucket
|
|
112
|
+
break
|
|
113
|
+
else: # pragma: no cover — buckets are expected to cover the clock
|
|
114
|
+
estimate = max(self._buckets.items())[1]
|
|
115
|
+
if not long_haul:
|
|
116
|
+
return estimate
|
|
117
|
+
return DelayEstimate(
|
|
118
|
+
mean_min=estimate.mean_min * self.long_haul_mean_multiplier,
|
|
119
|
+
p_over_60=min(1.0, estimate.p_over_60 * self.long_haul_p_multiplier),
|
|
120
|
+
cancelled_pct=estimate.cancelled_pct,
|
|
121
|
+
source="prior (long-haul adjusted)",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def blend(
|
|
126
|
+
prior: DelayEstimate,
|
|
127
|
+
observed: PunctualityObservation | None,
|
|
128
|
+
*,
|
|
129
|
+
pseudo_observations: int = 10,
|
|
130
|
+
) -> DelayEstimate:
|
|
131
|
+
"""Shrink observed punctuality toward the prior.
|
|
132
|
+
|
|
133
|
+
With k=10, four samples move the estimate ~29% of the way to what was
|
|
134
|
+
observed and sixty samples move it ~86%. Small samples nudge; large samples
|
|
135
|
+
decide. An out-of-season window is discounted by halving its effective weight,
|
|
136
|
+
because a January sample says little about August.
|
|
137
|
+
"""
|
|
138
|
+
if observed is None or observed.samples <= 0:
|
|
139
|
+
return prior
|
|
140
|
+
|
|
141
|
+
k = max(1, pseudo_observations)
|
|
142
|
+
weight = observed.samples * (1.0 if observed.same_season else 0.5)
|
|
143
|
+
total = k + weight
|
|
144
|
+
|
|
145
|
+
return DelayEstimate(
|
|
146
|
+
mean_min=(k * prior.mean_min + weight * observed.mean_delay_min) / total,
|
|
147
|
+
p_over_60=(k * prior.p_over_60 + weight * observed.p_over_60) / total,
|
|
148
|
+
cancelled_pct=(k * prior.cancelled_pct + weight * observed.cancelled_pct) / total,
|
|
149
|
+
samples=observed.samples,
|
|
150
|
+
source=f"blended: {observed.source} ({observed.samples} samples, k={k})",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def p_miss(estimate: DelayEstimate, cushion_minutes: float) -> float:
|
|
155
|
+
"""Probability an unprotected connection is missed, given its total cushion.
|
|
156
|
+
|
|
157
|
+
`cushion_minutes` is *all* the absorbing time the connection has: the slack
|
|
158
|
+
beyond the required buffer plus the delay allowance already built into that
|
|
159
|
+
buffer (§8.9). Callers must not pass bare slack — the buffer already contains
|
|
160
|
+
an allowance, and double-counting it the other way would make every connection
|
|
161
|
+
look doomed.
|
|
162
|
+
|
|
163
|
+
`p_over_60` anchors the curve: P(delay > t) = p_over_60 ** (t / 60). Crude on
|
|
164
|
+
purpose — a delay distribution is not knowable from two numbers — but it has
|
|
165
|
+
the properties that matter: monotonically decreasing in cushion, never zero,
|
|
166
|
+
and exactly the observed hourly tail at 60 minutes.
|
|
167
|
+
"""
|
|
168
|
+
if cushion_minutes <= 0:
|
|
169
|
+
# No absorbing time at all: the connection needs an on-time arrival, which
|
|
170
|
+
# even good flights cannot promise.
|
|
171
|
+
return 1.0
|
|
172
|
+
anchor = min(max(estimate.p_over_60, 1e-4), 0.99)
|
|
173
|
+
return float(min(1.0, anchor ** (cushion_minutes / 60.0)))
|