# `BB.Loop`
[🔗](https://github.com/beam-bots/bb/blob/main/lib/bb/loop.ex#L5)

Timing and health accounting for periodic components.

Components that run a periodic loop - controllers, policy runners, hardware
bus managers - each need the same three things: a tick that doesn't drift, a
measured time delta to hand to whatever algorithm they're driving, and some
way to tell an operator the loop isn't keeping up. `BB.Loop` is a struct you
embed in your component's state that provides all three.

It is deliberately *not* a behaviour or a process. Your component stays a
plain `BB.Controller` (or `GenServer`, or whatever); the loop is a value it
threads through its own callbacks.

## Clock sources

A loop is clocked one of two ways, chosen at `new/2`:

- `{:rate, hertz}` - the loop schedules its own `:tick` messages. Use this
  when output must be produced on a fixed cadence regardless of input.
- `:external` - something else decides when to step, typically the arrival of
  a message. The loop does no scheduling and only does the delta and health
  accounting. Use this when a loop's natural clock is its input; a control
  loop fed by a sensor is almost always better clocked by that sensor than by
  an independent timer.

## Rate-clocked usage

    def init(opts) do
      bb = Keyword.fetch!(opts, :bb)
      loop = BB.Loop.new(bb, clock: {:rate, ~u(100 hertz)})
      {:ok, %{bb: bb, loop: BB.Loop.arm(loop)}}
    end

    def handle_info(:tick, state) do
      {dt, skipped, loop} = BB.Loop.tick(state.loop)
      {:noreply, step(%{state | loop: loop}, dt, skipped)}
    end

    def terminate(_reason, state) do
      BB.Loop.cancel(state.loop)
      :ok
    end

`tick/1` re-arms the timer itself, so there is one call per tick rather than a
`tick`/`schedule` pair.

## Externally-clocked usage

Call `observe/2` with the monotonic timestamp of whatever triggered the step -
for a `BB.Message`, its `:monotonic_time`, so that the delta reflects the
sensor's own sampling interval rather than when the message was dequeued:

    def handle_info({:bb, _topic, %BB.Message{} = message}, state) do
      {dt, _skipped, loop} = BB.Loop.observe(state.loop, message.monotonic_time)
      {:noreply, step(%{state | loop: loop}, dt, 0)}
    end

`arm/1` and `cancel/1` are no-ops on an externally-clocked loop, so components
supporting both clocks don't need to branch on which they were given.

## The first delta, and deltas that don't advance

`dt` is `nil` on a loop's first tick, because there is no previous tick to
measure from. It is also `nil` when an `observe/2` timestamp doesn't advance
the clock, which is what a duplicate or reordered message looks like; the loop
keeps its previous timestamp in that case so the *next* message still measures
a correct interval.

Both cases mean "there is no valid interval here". Match on it and skip the
step - handing a `nil` or negative `dt` to an integrator or a derivative term
is exactly the kind of thing this module exists to prevent:

    defp step(state, nil, _skipped), do: state
    defp step(state, dt, _skipped), do: # ... real work

## Missed deadlines

A rate-clocked loop schedules against an absolute monotonic deadline that
accumulates in nanoseconds, so a slow handler doesn't push the schedule later
and rounding doesn't compound.

When a handler overruns, whole missed periods are **skipped**, not queued.
This is the important part: re-arming to a deadline that has already passed
makes `Process.send_after/4` fire immediately, and a loop that has fallen ten
periods behind would otherwise deliver ten back-to-back ticks with a `dt` of
effectively zero. For anything with an integral or derivative term that is far
worse than missing the ticks outright.

The number of periods dropped is returned from `tick/1` and reported as the
`:skipped` telemetry measurement. It is the loop's overrun metric: a loop that
is consistently skipping is configured faster than the machine can actually
run it.

## Achievable rates

`Process.send_after/4` has millisecond resolution, so a period below 1ms
cannot be represented and rates much above 1kHz will skip most of their
periods. Well below that ceiling the BEAM's scheduling tail dominates: on a
general-purpose kernel, tick latency has a floor of tens of milliseconds
regardless of the period asked for, so a loop nominally at 500Hz may deliver
half that. This is not a reason to avoid high rates, but it is a reason to
watch `:skipped` rather than trust the configured rate.

## Telemetry

Each tick with a valid `dt` emits `[:bb, :loop, :tick]`:

- Measurements: `%{dt: float_seconds, skipped: integer, deadline_error: integer_ns}`
- Metadata: `%{robot: module, path: [atom], clock: {:rate, float} | :external}`

`:deadline_error` is how late the tick was against its scheduled deadline. It
and `:skipped` are always `0` for externally-clocked loops, which have no
deadline of their own. No event is emitted when `dt` is `nil`, since there is
no interval to report.

## What this does not cover

`BB.Loop` answers "is this loop meeting its own deadline?". It knows nothing
about a component's inputs, so input freshness is not its job - a component
that must stop acting on a stale sensor reading owns that timeout itself, and
should report it separately via `BB.Diagnostic`.

# `bb`

```elixir
@type bb() :: %{robot: module(), path: [atom()]}
```

A `%{robot: module, path: [atom]}` map, as injected into component options.

# `clock`

```elixir
@type clock() :: {:rate, float()} | :external
```

How a loop is clocked. Normalised to `{:rate, float}` in hertz, or `:external`.

# `t`

```elixir
@type t() :: %BB.Loop{
  bb: bb(),
  clock: clock(),
  deadline_ns: integer(),
  last_ns: integer() | nil,
  period_ns: pos_integer() | nil,
  skipped: non_neg_integer(),
  tick_ref: reference() | nil,
  ticks: non_neg_integer()
}
```

# `arm`

```elixir
@spec arm(t()) :: t()
```

Schedule the loop's first tick.

Call once from `init/1`. Subsequent ticks are armed by `tick/1`. A no-op on an
externally-clocked loop.

# `cancel`

```elixir
@spec cancel(t()) :: t()
```

Cancel any pending tick.

Call from `terminate/2`, and before replacing a running loop with one at a
different rate. A no-op on an externally-clocked loop, or one that was never
armed.

If the timer had already fired, the delivered `:tick` is discarded too.
Leaving it queued would make the next `arm/1` deliver a tick immediately with
a delta of roughly zero - the exact thing this module exists to keep out of a
control loop - and would leave a second timer in flight that a later
`cancel/1` could not clean up.

# `new`

```elixir
@spec new(
  bb(),
  keyword()
) :: t()
```

Build a loop for a component.

`bb` is the `%{robot: _, path: _}` map injected into component options.

## Options

- `:clock` (required) - `{:rate, hertz}` or `:external`. A rate may be given
  as a `Localize.Unit` in any frequency unit (`~u(100 hertz)`) or as a plain
  positive number of hertz.

Building a loop does not start it; call `arm/1` once the component is ready to
receive ticks.

## Examples

    BB.Loop.new(bb, clock: {:rate, ~u(100 hertz)})
    BB.Loop.new(bb, clock: {:rate, 100})
    BB.Loop.new(bb, clock: :external)

# `observe`

```elixir
@spec observe(t(), integer()) :: {float() | nil, 0, t()}
```

Record a step on an externally-clocked loop at the given monotonic timestamp.

`at_ns` should be the monotonic nanosecond timestamp of whatever triggered the
step - for a `BB.Message`, its `:monotonic_time` field.

Returns `{dt, 0, loop}`. `dt` is `nil` for the first observation, and for any
timestamp that doesn't advance past the previous one; see the module docs.

# `tick`

```elixir
@spec tick(t()) :: {float() | nil, non_neg_integer(), t()}
```

Record a tick on a rate-clocked loop and schedule the next one.

Returns `{dt, skipped, loop}` where `dt` is the seconds elapsed since the
previous tick (`nil` on the first), and `skipped` is the number of whole
periods dropped because the loop had fallen behind.

Call this at the top of your `:tick` handler. The next deadline is absolute,
so arming before doing the tick's work is correct - the work's duration
doesn't move the schedule, and an overrun is reported by the following tick.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
