Skip to content

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#