Reservations#
Reservations
spreads a crowd across candidate capture times: it hands each user a distinct
time, keeps that choice stable (a user who polls repeatedly is handed the same
time), and self-expires it (a reservation is released once its time is past,
or once any capture lands within grace of it), so an abandoned or fulfilled
slot frees up for someone else.
A reservation is just a datetime. Choosing which times are worth offering —
Void & Cluster selection over solar angle, weather, and so on —
happens before reserve and hands it a plain list of candidates.
Basic usage#
from datetime import timedelta
import capturegraph.scheduling as cgsh
book = cgsh.Reservations(
state.scope("schedule"),
grace=timedelta(minutes=30),
)
when = book.reserve(
user_id,
among=candidate_times,
captured=last_capture,
)
reserve returns the datetime this user holds — a member of among, or
None when among is empty. The same user is handed the same time on every
call until it expires.
Key features#
| Feature | Description |
|---|---|
| Distinct times | Each user gets a different candidate (falls back to the earliest once all are taken, without recording a hold). |
| Stable | A user polling repeatedly keeps their time, re-snapped onto the freshest candidates. |
| Self-expiring | A time more than grace in the past is released; captured= (the target's most recent capture, by any user) releases every hold within grace of it — that moment's conditions were just sampled, so its holders are steered to fresh times. |
The store persists in a
Scope — pass
state.scope("schedule") so the book's format resets never touch your
interceptor's other keys. Each instant is held as a tz-faithful ISO-8601
string, and the candidate list is re-supplied every call, so a held
reservation survives the list being regenerated between requests.
Complete example#
This interceptor lives in a target's __capture_target__.py on the server — the
server calls it whenever a client asks for its next notification time. See
Server Targets for how interceptors are declared.
from datetime import datetime, timedelta
import capturegraph as cg
import capturegraph.scheduling as cgsh
from server import authoring as cgserver
def _solar_times(location, target, selections):
"""Void & Cluster candidate times, preferring unseen solar conditions."""
previous = cg.Array(
[
{"date": when, "solar_angle": cgsh.forecast.solar_position(location, when)}
for when in sorted(target.Bridge.keys())
]
)
candidates = cgsh.forecast.times(
resolution=timedelta(minutes=5),
span=timedelta(hours=24),
)
potential = cg.Array(
[
{"date": when, "solar_angle": cgsh.forecast.solar_position(location, when)}
for when in candidates
]
)
# Daylight only: drop candidates where the sun is below the horizon.
potential = cg.Array([s for s in potential if s["solar_angle"].altitude > 0])
selected = cgsh.select_sessions(
potential_sessions=potential,
previous_sessions=previous,
distance_fn=cgsh.distance.combine(solar_angle=cgsh.distance.solar(sigma_deg=2.0)),
energy_fn=cgsh.energy.gaussian(sigma=1),
energy_mode="sum",
selections=selections,
)
return [candidate["date"] for candidate in selected]
@cgserver.intercept(Bridge, lambda s: s._metadata._next_notification)
def notification(profile, target, state) -> cg.Time:
location = target._metadata._location
if cg.is_missing(location):
return cg.Time(datetime.now() + timedelta(hours=1))
times = _solar_times(location, target, selections=3)
reservations = cgsh.Reservations(
state.scope("schedule"),
grace=timedelta(minutes=30),
)
chosen = reservations.reserve(profile.user_id, among=times)
if chosen is None:
return cg.Time(datetime.now() + timedelta(hours=1))
return cg.Time(chosen)
Note
reserve returns None when among is empty, so guard before wrapping it
in cg.Time.
Coverage reservations#
For spatial campaigns,
CoverageReservations
is the spatial sibling of Reservations: where that hands out times released
by the clock, this hands out places released by proximity. A void-and-cluster
pass computes a batch of well-spread target locations once (it is expensive);
CoverageReservations hands them out one per user, stably, and every location
is in exactly one of three states:
reserve() capture within threshold
in batch ─────────────▶ held ─────────────────────────────▶ retired
▲ │ (the spot is covered)
│ lease lapses │
└─────────────────────┘ (nobody captured it)
A handout is a lease (lease=, 30 minutes by default): the user is handed
the same place on every request while it holds. A capture within threshold
retires the location for good — the spot is covered, and the next user is sent
somewhere still uncovered. A lease that lapses without a capture returns the
location to the batch so someone else can be sent there.
import capturegraph as cg
import capturegraph.scheduling as cgsh
DISTANCE = cgsh.distance.location(sigma_m=10.0)
@cgserver.intercept(GPSCoverage, lambda s: s._metadata._location)
def capture_location(profile, target, state) -> cg.Location:
coverage = cgsh.CoverageReservations(
state.scope("coverage"),
distance=DISTANCE,
threshold=1.0,
)
if coverage.exhausted:
candidates = cg.Array(
list(cgsh.forecast.locations_area(bounds=BOUNDS, resolution_meters=1.0))
)
coverage.refill(
cgsh.select_sessions(
potential_sessions=candidates,
previous_sessions=cg.Array(previous_locations),
distance_fn=DISTANCE,
energy_fn=lambda d: -d, # maximize spread
energy_mode="max",
selections=8,
)
)
chosen = coverage.reserve(
profile.user_id, at=profile.location, fulfilled_by=previous_locations
)
return chosen if chosen is not None else BOUNDS[0]
distance is the same metric used to select the batch — a distance between two
bare cg.Locations. refill(locations) replaces the batch, kept in the order
given; exhausted reports whether any locations remain; and
reserve(user_id, at=..., fulfilled_by=...) hands out the batch location
closest to the requester's location at, falling back to insertion order
(FIFO) when no location is shared — first retiring any reservation within
threshold of a captured location in fulfilled_by.
See also#
- Void & Cluster Algorithm — Computing optimal capture times
- Distance Functions — Metrics for importance sampling