# Functional DDCR API — Real Passband to Baseband

![Functional DDCR spectral demo](../assets/ddc_fn_demo.png)

## What you're seeing

Three panels share the same x-axis — normalised frequency in cycles/sample
(−0.5 to +0.5). A red dashed marker labels the strongest spectral line in
each panel.

**Top panel — input.** 16384 samples of a **real** float32 signal: a tone at
`fn = 0.18` (relative to the input sample rate `fs_in`) plus broadband noise.
Because the input is real, the spectrum is symmetric — the tone shows at both
±0.18.

**Middle panel — baseband output.** The same signal run through `ddcr_execute`
with the NCO tuned so the carrier lands at **DC**. The output is **complex64**
and decimated 4× (`rate = 0.25`), so its x-axis is normalised to `fs_out`. The
tone sits at `fn = 0.000`.

**Bottom panel — retuned output.** The LO is moved 0.04 (in `fs_in`) below the
carrier via `ddcr_set_norm_freq`. The tone shifts off DC by
`0.04 / rate = 0.16` in `fs_out` units, landing at `fn = +0.160` — exactly as
predicted, and phase-continuously (no filter-history reset).

## The two faces of the DDC

`doppler.ddc` exposes the same C down-converter two ways:

|                                   | Object API                     | Functional API (this demo)               |
| --------------------------------- | ------------------------------ | ---------------------------------------- |
| Import                            | `from doppler.ddc import DDCR` | `from doppler.ddc import ddcr_create, …` |
| State                             | wrapped in a Python type       | opaque **PyCapsule**, passed explicitly  |
| Output                            | allocated per call             | written into a **caller-owned** buffer   |
| Use when                          | you want a simple object       | you manage your own arrays / want zero   |
| per-call allocation in a hot loop |                                |                                          |

## How it works

A DDCR takes a real passband signal, mixes it with a fine NCO (running at
`fs_in / 2`), low-pass filters, and **decimates** to complex baseband. To park
a real tone at carrier `f_carrier` (normalised to `fs_in`) at DC:

```python
norm_freq = -(2 * f_carrier + 0.5)
```

The functional lifecycle is explicit — the capsule is yours to keep, reuse,
and release:

```python
import numpy as np
from doppler.ddc import (
    ddcr_create, ddcr_execute, ddcr_set_norm_freq,
    ddcr_reset, ddcr_destroy,
)

state = ddcr_create(norm_freq=-(2 * 0.18 + 0.5), rate=0.25)

out = np.empty(4096, dtype=np.complex64)        # reused every block
for block in stream:                            # block: ndarray[float32]
    y = ddcr_execute(state, block, out)         # zero-copy view out[:n_out]
    ...

ddcr_set_norm_freq(state, -(2 * 0.14 + 0.5))    # retune, phase-continuous
ddcr_reset(state)                               # zero all history
ddcr_destroy(state)                             # release C resources
```

After `ddcr_destroy`, any further call on the same capsule raises
`RuntimeError`; live views of earlier output stay valid because they reference
the caller's buffer, not the state.

## Streaming semantics — the capsule is mutable C state

`state` is an **opaque handle** — a PyCapsule wrapping a single C pointer — to a
struct that is **mutated in place** on every call. The actual DDCR state (the
LO phase, the halfband taps, the polyphase resampler banks, the history
buffers) lives entirely in C and **never crosses the Python/C boundary**: each
call passes the ~8-byte handle, not the kilobytes of state, so nothing is
serialized, marshaled, or copied per call. That is also what makes block-by-block
processing **phase-continuous** — the same capsule carries its history forward
from one `ddcr_execute` to the next, so feeding a signal as two halves through
the *same* state is bit-identical to processing it in one shot:

```python
# one shot ----------------------------------------------------------------
s = ddcr_create(lo, 0.25)
y_whole = ddcr_execute(s, x, out).copy()

# same input, same state, two blocks --------------------------------------
s = ddcr_create(lo, 0.25)
y0 = ddcr_execute(s, x[:4096], out).copy()   # state advances in place
y1 = ddcr_execute(s, x[4096:], out).copy()   # picks up exactly where y0 left off
# np.concatenate([y0, y1]) == y_whole   (max|Δ| == 0)
```

Run those same two halves through *fresh* states instead and the seam shows a
phase jump and a filter transient (`max|Δ| ≈ 0.78`) — the carried history is
exactly the in-place state. `ddcr_set_norm_freq` and `ddcr_reset` likewise
mutate the same capsule (no new handle is returned).

Consequences:

- **One capsule = one stream.** Don't share a capsule across threads
    concurrently; give each stream its own.
- **Deterministic lifetime.** `ddcr_destroy` frees the C resources when *you*
    say so, not when the GC happens to run.
- The **only** value handed back is the output view (`out[:n_out]`);
    everything else lives in — and mutates — the capsule.

## Performance — zero-copy, zero steady-state allocation

The functional face is **not faster than the `DDCR` object** — it's the same C
core, and on a 4096-sample block all three paths land within ~2 %
(≈16 µs/block on this machine). The benefit is *where the output goes* and
*what gets allocated*, not raw throughput:

| Path                                        | per-call allocation     | output lands in                                |
| ------------------------------------------- | ----------------------- | ---------------------------------------------- |
| `ddcr_execute(state, x, out)`, `out` reused | **none** (steady state) | a buffer **you own**                           |
| `ddcr_execute(state, x, np.empty(...))`     | one output array        | a fresh array each call                        |
| `DDCR.execute(x)` (object)                  | none                    | one **internal** buffer, overwritten next call |

So the functional API buys you:

- **Zero-copy into caller memory.** Write results straight into a slice of a
    larger array, a memory-mapped region, or the next pipeline stage's input
    buffer — no copy to move the result into place afterward.
- **Multiple live outputs.** The object's `execute` returns a view into one
    internal buffer that the next call overwrites; the functional API can target
    a different `out` per call, so several outputs stay valid at once.
- **No per-call allocation** in the steady state when you reuse one `out` —
    small here (the allocator recycles the 32 KiB block cheaply), but it removes
    allocator traffic and GC pressure entirely, which matters under many parallel
    streams or tight real-time budgets.

## Parallelism — `ddcr_execute` releases the GIL

The C kernel runs with the **GIL released** (`Py_BEGIN_ALLOW_THREADS` around the
`ddcr_execute` call). It's safe precisely because of the one-capsule-per-stream
contract: the kernel touches only that stream's state and the caller's
buffers — no Python objects, no shared mutable state. So a **thread-per-shard**
worker — each thread owning its own capsule and `out` buffer — scales across
cores instead of serialising on the GIL:

![Functional DDCR thread-per-shard scaling](../assets/ddc_fn_scaling.png)

| threads | speedup | efficiency |
| ------: | ------: | ---------: |
|       1 |   1.00× |      100 % |
|       2 |   1.73× |       86 % |
|       4 |   3.16× |       79 % |
|       8 |   5.41× |       68 % |
|      12 |   7.41× |       62 % |
|      16 |   8.08× |       51 % |

(8192-sample blocks, representative run on a 20-core box; regenerate with
`make gallery`. **Before** releasing the GIL the same test was flat at ~1×
regardless of thread count.) Scaling is near-linear through a handful of cores
and then **memory-bandwidth bound** — the kernel streams large buffers, so past
~8–12 threads more cores stop helping. The takeaway for sizing: one replica can
use *several* cores via threads before you reach for process-per-shard, which
changes your replica-count math.

This pairs directly with the streaming model above: shard by stream, give each
worker thread a disjoint set of capsules, and there is **no shared state to
lock** — the parallelism is embarrassingly so.

In short: same speed, but you own the buffer and the lifetime — exactly what a
zero-copy streaming pipeline wants.

## Run it

```sh
python examples/python/ddc_fn_demo.py
```

Source: [`examples/python/ddc_fn_demo.py`](https://github.com/doppler-dsp/doppler/blob/main/examples/python/ddc_fn_demo.py)
