Skip to content

coverage_reservations

coverage_reservations #

Per-user location reservations — spread a crowd across a survey area.

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 — the one closest to each requester when their location is known — stably, and releases a user's location once a capture lands within threshold of it, so the next user is sent somewhere still uncovered.

A handout is a lease: the location is held for the requesting user for at most lease (30 minutes by default), and that user is handed the same place on every request meanwhile. 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)

Because a lapsed location flows back into the batch even after the batch was refilled, the number of live locations can briefly exceed a batch.

Locations persist in a Scope as JSON: the batch as cg.Location codecs, each held lease as its location plus a tz-faithful ISO-8601 expiry. The batch is held between requests because recomputing it every call is wasteful.

Example
coverage = cgsh.CoverageReservations(
    state.scope("coverage"),
    distance=cgsh.distance.location(sigma_m=10),
    threshold=1.0,
)
if coverage.exhausted:
    coverage.refill(selected_locations)
where = coverage.reserve(profile.user_id, at=profile.location, fulfilled_by=captured_locations)

CoverageReservations #

Hands each user a distinct location on a lease, recycling it when it lapses.

Construction claims scope: it is stamped with this class's persisted format version, and any state written in another shape — including by an unrelated consumer — is cleared and rebuilt rather than misread (ensure_format).

Parameters:

Name Type Description Default
scope Scope

Where the batch and reservations persist, isolated to this scheduler's use (e.g. state.scope("coverage")).

required
distance Callable[[Location, Location], float]

A distance between two cg.Locations — the same metric used to select the batch (cgsh.distance.location(sigma_m=...)). It both picks the location closest to a requester and decides when a capture covers a reservation.

required
threshold float

A reservation is released once a capture lies within this distance of it, in the units distance returns (e.g. 1.0 is one sigma_m). Defaults to 1.0.

1.0
lease timedelta

How long a handout is held before, absent a covering capture, the location returns to the batch for reallocation. Defaults to 30 minutes.

timedelta(minutes=30)
Source code in capturegraph-lib/capturegraph/scheduling/organize/coverage_reservations.py
class CoverageReservations:
    """Hands each user a distinct location on a lease, recycling it when it lapses.

    Construction claims ``scope``: it is stamped with this class's persisted
    format version, and any state written in another shape — including by an
    unrelated consumer — is cleared and rebuilt rather than misread
    ([ensure_format][capturegraph.scheduling.organize.scope.ensure_format]).

    Args:
        scope: Where the batch and reservations persist, isolated to this
            scheduler's use (e.g. ``state.scope("coverage")``).
        distance: A distance between two ``cg.Location``s — the same metric used
            to select the batch (``cgsh.distance.location(sigma_m=...)``). It both
            picks the location closest to a requester and decides when a capture
            covers a reservation.
        threshold: A reservation is released once a capture lies within this
            distance of it, in the units ``distance`` returns (e.g. ``1.0`` is one
            ``sigma_m``). Defaults to ``1.0``.
        lease: How long a handout is held before, absent a covering capture, the
            location returns to the batch for reallocation. Defaults to 30
            minutes.
    """

    def __init__(
        self,
        scope: Scope,
        *,
        distance: Callable[[cg.Location, cg.Location], float],
        threshold: float = 1.0,
        lease: timedelta = timedelta(minutes=30),
    ) -> None:
        """Claim ``scope``, resetting it if its stamped format has changed."""
        ensure_format(scope, _FORMAT)
        self._scope = scope
        self._held = scope.scope("held")
        self._distance = distance
        self._threshold = threshold
        self._lease = lease

    @property
    def exhausted(self) -> bool:
        """Whether the batch has no locations left to hand out."""
        return not self._scope.get("batch")

    def refill(self, locations: Iterable[cg.Location]) -> None:
        """Replace the batch with ``locations``, kept in the order given.

        Hand-out order is decided per request by proximity to the asker
        (see [reserve][]), so the batch itself needs no ordering.
        """
        self._scope["batch"] = [cg.Location.encode(location) for location in locations]

    def reserve(
        self,
        user_id: str,
        *,
        at: cg.Location | None = None,
        fulfilled_by: Sequence[cg.Location] = (),
        now: datetime | None = None,
    ) -> cg.Location | None:
        """The location this user holds, leasing one from the batch if none.

        Args:
            user_id: The user asking for a location.
            at: The requester's current location, if known. The batch location
                closest to it is leased; without it, the batch is leased in
                insertion order (FIFO).
            fulfilled_by: Captured locations; a reservation within ``threshold``
                of any of these is released first, freeing it for reuse.
            now: The reference time; defaults to ``datetime.now()``. Pass it
                explicitly to make lease expiry deterministic under test.

        Returns:
            The user's reserved location, or ``None`` if the batch is exhausted
            and the user holds nothing.
        """
        now = now if now is not None else datetime.now()
        self._release_covered(fulfilled_by)
        self._reclaim_lapsed(now)

        if user_id in self._held:
            return cg.Location.decode(_as_lease(self._held[user_id])["location"])

        batch = _as_batch(self._scope.get("batch", []))
        if not batch:
            return None

        index = self._closest_index(batch, at) if at is not None else 0
        chosen = batch[index]
        self._scope["batch"] = batch[:index] + batch[index + 1 :]
        self._held[user_id] = self._lease_entry(chosen, now)
        return cg.Location.decode(chosen)

    def release(self, user_id: str) -> None:
        """Release a user's reservation without returning its location to the batch."""
        if user_id in self._held:
            del self._held[user_id]

    def _lease_entry(self, encoded: JSONValue, now: datetime) -> dict[str, JSONValue]:
        """A held-lease record: the encoded location and when its hold expires."""
        return {"location": encoded, "expires": (now + self._lease).isoformat()}

    def _release_covered(self, captures: Sequence[cg.Location]) -> None:
        """Retire reservations a capture has already covered (the spot is done)."""
        if not captures:
            return
        for user_id, entry in self._held.items():
            location = cg.Location.decode(_as_lease(entry)["location"])
            if any(self._distance(capture, location) < self._threshold for capture in captures):
                del self._held[user_id]

    def _reclaim_lapsed(self, now: datetime) -> None:
        """Return locations whose lease has lapsed to the batch for reallocation."""
        reclaimed: list[JSONValue] = []
        for user_id, entry in self._held.items():
            lease = _as_lease(entry)
            expires = lease["expires"]
            if not isinstance(expires, str):
                raise TypeError(
                    f"expected an ISO-8601 expiry in scope, got {type(expires).__name__}"
                )
            if datetime.fromisoformat(expires) <= now:
                reclaimed.append(lease["location"])
                del self._held[user_id]
        if reclaimed:
            self._scope["batch"] = _as_batch(self._scope.get("batch", [])) + reclaimed

    def _closest_index(self, batch: list[JSONValue], at: cg.Location) -> int:
        """Index of the batch entry nearest ``at`` under ``self._distance``."""
        return min(
            range(len(batch)),
            key=lambda index: self._distance(at, cg.Location.decode(batch[index])),
        )

exhausted property #

Whether the batch has no locations left to hand out.

__init__(scope, *, distance, threshold=1.0, lease=timedelta(minutes=30)) #

Claim scope, resetting it if its stamped format has changed.

Source code in capturegraph-lib/capturegraph/scheduling/organize/coverage_reservations.py
def __init__(
    self,
    scope: Scope,
    *,
    distance: Callable[[cg.Location, cg.Location], float],
    threshold: float = 1.0,
    lease: timedelta = timedelta(minutes=30),
) -> None:
    """Claim ``scope``, resetting it if its stamped format has changed."""
    ensure_format(scope, _FORMAT)
    self._scope = scope
    self._held = scope.scope("held")
    self._distance = distance
    self._threshold = threshold
    self._lease = lease

refill(locations) #

Replace the batch with locations, kept in the order given.

Hand-out order is decided per request by proximity to the asker (see [reserve][]), so the batch itself needs no ordering.

Source code in capturegraph-lib/capturegraph/scheduling/organize/coverage_reservations.py
def refill(self, locations: Iterable[cg.Location]) -> None:
    """Replace the batch with ``locations``, kept in the order given.

    Hand-out order is decided per request by proximity to the asker
    (see [reserve][]), so the batch itself needs no ordering.
    """
    self._scope["batch"] = [cg.Location.encode(location) for location in locations]

release(user_id) #

Release a user's reservation without returning its location to the batch.

Source code in capturegraph-lib/capturegraph/scheduling/organize/coverage_reservations.py
def release(self, user_id: str) -> None:
    """Release a user's reservation without returning its location to the batch."""
    if user_id in self._held:
        del self._held[user_id]

reserve(user_id, *, at=None, fulfilled_by=(), now=None) #

The location this user holds, leasing one from the batch if none.

Parameters:

Name Type Description Default
user_id str

The user asking for a location.

required
at Location | None

The requester's current location, if known. The batch location closest to it is leased; without it, the batch is leased in insertion order (FIFO).

None
fulfilled_by Sequence[Location]

Captured locations; a reservation within threshold of any of these is released first, freeing it for reuse.

()
now datetime | None

The reference time; defaults to datetime.now(). Pass it explicitly to make lease expiry deterministic under test.

None

Returns:

Type Description
Location | None

The user's reserved location, or None if the batch is exhausted

Location | None

and the user holds nothing.

Source code in capturegraph-lib/capturegraph/scheduling/organize/coverage_reservations.py
def reserve(
    self,
    user_id: str,
    *,
    at: cg.Location | None = None,
    fulfilled_by: Sequence[cg.Location] = (),
    now: datetime | None = None,
) -> cg.Location | None:
    """The location this user holds, leasing one from the batch if none.

    Args:
        user_id: The user asking for a location.
        at: The requester's current location, if known. The batch location
            closest to it is leased; without it, the batch is leased in
            insertion order (FIFO).
        fulfilled_by: Captured locations; a reservation within ``threshold``
            of any of these is released first, freeing it for reuse.
        now: The reference time; defaults to ``datetime.now()``. Pass it
            explicitly to make lease expiry deterministic under test.

    Returns:
        The user's reserved location, or ``None`` if the batch is exhausted
        and the user holds nothing.
    """
    now = now if now is not None else datetime.now()
    self._release_covered(fulfilled_by)
    self._reclaim_lapsed(now)

    if user_id in self._held:
        return cg.Location.decode(_as_lease(self._held[user_id])["location"])

    batch = _as_batch(self._scope.get("batch", []))
    if not batch:
        return None

    index = self._closest_index(batch, at) if at is not None else 0
    chosen = batch[index]
    self._scope["batch"] = batch[:index] + batch[index + 1 :]
    self._held[user_id] = self._lease_entry(chosen, now)
    return cg.Location.decode(chosen)