# Adding a New C Extension Module

This guide walks through every step required to add a new DSP module to
doppler using [just-makeit](https://just-buildit.github.io/just-makeit/).
The scaffold handles boilerplate; you fill in the DSP logic.

For the layout rules that govern each generated file, see
[Module Layout](module-layout.md).

______________________________________________________________________

## Prerequisites

Install just-makeit (one-liner, adds `just-makeit` to PATH):

```sh
. <(curl -fsSL https://just-buildit.github.io/just-makeit/install.sh)
```

The scaffold writes into the doppler source tree, so run all commands from
the repo root.

______________________________________________________________________

## Step 1 — Scaffold the module

Use `just-makeit module` to create the empty subpackage, then add types
and functions with `object` and `function`:

```sh
# Create the module subpackage (no objects yet)
just-makeit module <name>

# Add a stateful DSP type (step/steps generated by default)
just-makeit object <component> --module <name>

# Add a module-level free function (no type object)
just-makeit function <fn_name> --module <name> \
    --param "x:float _Complex[]" --return-type "float _Complex"
```

**`object`** is for stateful DSP types with a create/destroy lifecycle.
The default scaffold generates `step()` / `steps()` — single-sample and
block-processing methods. Keeping them is preferred for consistency;
suppress only when the pattern genuinely doesn't fit:

- `--no-step` — no meaningful per-sample operation (e.g. a block-only FFT
    or FIR whose natural API is `execute`)
- `--no-state` — stateless object; parameters are passed per call

**`function`** is for module-level operations with no persistent state —
window functions, unit conversions, design helpers, anything that takes
inputs and returns a result without a lifecycle. `just-makeit function`
appends a C stub to `<name>_core.c`, injects a declaration into
`<name>_core.h`, and regenerates `<name>_ext.c` with a Python wrapper.
A module can have any mix of objects and functions.

After `just-makeit module <name>` + `just-makeit object <component> --module <name>`
the scaffold has created:

```text
native/inc/<name>/
├── <name>_core.h              # module-level function declarations
└── <component>_core.h         # per-object C header

native/src/<name>/
├── <name>_core.c              # module-level function implementations
├── <name>_ext.c               # Python binding (auto-regenerated)
├── <component>_core.c         # algorithm skeleton (fill in step())
└── CMakeLists.txt             # auto-managed

native/tests/
└── test_<component>_core.c    # C test skeleton

native/benchmarks/
└── bench_<component>_core.c   # C benchmark skeleton

src/doppler/<name>/
├── __init__.py                # re-export stub
└── <name>.pyi                 # type stub skeleton
```

`tests/` and `benchmarks/` under `src/doppler/<name>/` are not scaffolded —
you create them in Steps 7 and 8.

______________________________________________________________________

## Step 2 — Implement the C core

Open `native/src/<name>/<component>_core.c`. The primary thing to implement
is `step()` — the scaffold generates `steps()` as an inline loop over
`step()`, so it comes for free. Any other methods you declared via
`just-makeit method` also need their bodies filled in.

```c
/* native/src/<name>/<component>_core.c */
#include "<name>/<component>_core.h"

float _Complex
dp_<component>_step(dp_<component>_t *s, float _Complex x)
{
    /* DSP logic for one sample */
    return x * s->gain;
}
```

The lifecycle functions (`create`, `destroy`, `reset`) are scaffolded and
only need changes if your object allocates extra memory or has non-trivial
initialization beyond what the generated struct assignment already does.

Keep this file algorithm-only. No Python headers, no NumPy, no
`Py_*` calls — those belong exclusively in `<name>_ext.c`.

______________________________________________________________________

## Step 3 — Add extra methods or properties *(optional)*

The scaffold already wires `step()` and `steps()` into the Python binding.
Only run these commands if you need additional methods (e.g. dtype-specific
execute paths) or properties beyond what the scaffold generated.

```sh
just-makeit method <component> execute_cf32 --module <name> \
    --arg-type "float _Complex[]" \
    --out-type "float _Complex"
```

The tool appends a C stub to `<component>_core.c` and regenerates
`<name>_ext.c` with the argument-parsing boilerplate.

For read-only C struct fields exposed as Python properties:

```sh
just-makeit property <component> n --module <name> --type size_t --field
```

______________________________________________________________________

## Step 4 — Write C tests

Open `native/tests/<name>/test_<name>.c` (generated skeleton) and add
test cases using the [Unity](https://github.com/ThrowTheSwitch/Unity) test
framework that the project ships:

```c
void test_execute_passthrough(void) {
    dp_mytype_t *s = dp_mytype_create(256);
    TEST_ASSERT_NOT_NULL(s);

    float _Complex in[256] = { [0] = 1.0f + 0.0f * _Complex_I };
    float _Complex out[256];
    dp_mytype_execute_cf32(s, in, 256, out);

    TEST_ASSERT_FLOAT_WITHIN(1e-5f, 1.0f, crealf(out[0]));
    dp_mytype_destroy(s);
}
```

Run the C suite:

```sh
make test
```

All C tests must pass before moving to the Python layer.

______________________________________________________________________

## Step 5 — Write `<module>.pyi`

Open `src/doppler/<name>/<name>.pyi` (generated skeleton) and flesh out
every public type following the [module layout rules](module-layout.md).
Docstrings use [numpy-style format](https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard):

```python
# mymod/mymod.pyi — type stubs for the mymod C extension.
import numpy as np
from numpy.typing import NDArray

class <component>:
    """One-line summary.

    Parameters
    ----------
    n:
        Block size.  Must be a power of two.

    Examples
    --------
    Construct and inspect an instance:

    >>> import numpy as np
    >>> from doppler.mymod import <component>
    >>> obj = <component>(256)
    >>> obj.n
    256

    Process a block of CF32 samples:

    >>> x = np.ones(256, dtype=np.complex64)
    >>> y = obj.execute_cf32(x)
    >>> y.dtype
    dtype('complex64')
    >>> y.shape
    (256,)

    """

    n: int
    """Block size passed to the constructor."""

    def execute_cf32(
        self, x: NDArray[np.complex64]
    ) -> NDArray[np.complex64]:
        """Process one block of CF32 samples."""
        ...
```

Verify the examples pass:

```sh
python -m doctest -v src/doppler/<name>/<name>.pyi
```

Fix any failures before continuing.

______________________________________________________________________

## Step 6 — Write `__init__.py`

Open `src/doppler/<name>/__init__.py` and update the re-export:

```python
from .<name> import <component>

__all__ = ["<component>"]
```

`__all__` **is** the public API. It controls what `from doppler.<name> import *` exposes, what IDEs surface in autocompletion, and what users can
reasonably rely on. Make sure every symbol a user should be able to reach
is listed — if it isn't here, it isn't public. Conversely, every name in
`__all__` must have a corresponding stub in `<name>.pyi`; the two lists
must stay in sync.

Nothing else belongs here. See [Module Layout](module-layout.md).

______________________________________________________________________

## Step 7 — Write Python tests

Open `src/doppler/<name>/tests/test_<name>.py` and write `pytest` tests
that exercise the Python API end-to-end through the C extension.

At minimum, cover these categories:

- **Construction** — valid arguments create the object; invalid arguments
    raise the expected exception (`TypeError`, `ValueError`, etc.)
- **Output shape and dtype** — every execute path returns an array of the
    correct shape and dtype for each supported input type
- **Correctness** — known input produces known output; verify numerically
    against a reference (e.g. `np.allclose`)
- **DSP design requirements** — DSP algorithms carry quantitative targets
    (filter attenuation, SFDR, passband ripple, decimation accuracy, etc.) that
    must be verified, not assumed. Test these thoroughly over repeatable
    pseudo-random inputs and/or swept parameter ranges so regressions are caught
    automatically. Use a fixed `np.random.default_rng(seed)` for
    reproducibility.
- **`step` / `steps` consistency** — a block processed via `steps()` matches
    the same samples processed one-at-a-time via `step()`
- **Properties** — read-only properties return the values passed at construction

```python
import numpy as np
import pytest
from doppler.<name> import <component>


def test_construction_invalid():
    with pytest.raises(ValueError):
        <component>(-1)


def test_output_shape_and_dtype():
    obj = <component>(256)
    x = np.ones(256, dtype=np.complex64)
    y = obj.execute_cf32(x)
    assert y.shape == (256,)
    assert y.dtype == np.complex64


def test_correctness():
    obj = <component>(256)
    x = np.ones(256, dtype=np.complex64)
    y = obj.execute_cf32(x)
    expected = ...  # compute reference result
    assert np.allclose(y, expected, atol=1e-5)


@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
def test_dsp_design_requirements(seed):
    # Example: verify stopband attenuation meets the design target.
    # Use a fixed-seed RNG so failures are reproducible.
    rng = np.random.default_rng(seed)
    obj = <component>(256)
    x = (rng.standard_normal(4096) +
         1j * rng.standard_normal(4096)).astype(np.complex64)
    y = obj.execute_cf32(x)
    # Measure stopband power and assert it meets the spec (example: -60 dB).
    stopband = np.abs(np.fft.fft(y)[128:384]) ** 2
    attenuation_db = 10 * np.log10(stopband.mean())
    assert attenuation_db < -60.0


def test_step_steps_consistency():
    obj_a = <component>(256)
    obj_b = <component>(256)
    x = np.random.randn(256).astype(np.float32) + \
        1j * np.random.randn(256).astype(np.float32)
    via_steps = obj_a.steps(x.astype(np.complex64))
    via_step  = np.array([obj_b.step(s) for s in x.astype(np.complex64)])
    assert np.allclose(via_steps, via_step, atol=1e-6)
```

Run the Python suite:

```sh
make python-test
```

______________________________________________________________________

## Step 8 — Write the Python benchmark

Create `src/doppler/<name>/benchmarks/bench_<name>.py`. Benchmarks use
[pytest-benchmark](https://pytest-benchmark.readthedocs.io/) so results are
collected into versioned JSON snapshots in `benchmarks/history/` and tracked
in git for regression detection.

For a full description of both the Python and C benchmark pipelines, history
file format, and how to compare snapshots, see [Benchmarking](benchmarking.md).

```python
"""bench_<name>.py — throughput benchmarks for doppler.<name>."""
import numpy as np
import pytest
from doppler.<name> import <component>

BLOCK = 1_048_576  # samples per benchmark round


@pytest.fixture(scope="module")
def obj():
    return <component>(BLOCK)


@pytest.fixture(scope="module")
def x_cf32():
    return np.ones(BLOCK, dtype=np.complex64)


def test_<name>_execute_cf32(benchmark, obj, x_cf32):
    benchmark(obj.execute_cf32, x_cf32)
```

pytest-benchmark handles warmup and repetition automatically. Name each
test `test_<name>_<method>` so results are identifiable in the JSON history.

CI commits a snapshot automatically on every push to `main` and on every
release tag — no manual step required. Run locally when you want an
immediate result during development:

```sh
make bench-python                   # saves benchmarks/history/<tag>.json
make bench-python BENCH_TAG=v1.2.3  # version-tagged snapshot (matches CI on tag push)
```

Compare two snapshots:

```sh
uv run pytest-benchmark compare benchmarks/history/2026-05-01-abc1234.json \
                                 benchmarks/history/2026-05-15-def5678.json
```

______________________________________________________________________

## Step 9 — Rebuild and verify

just-makeit handles all wiring automatically: `native/src/<name>/CMakeLists.txt`,
the root `CMakeLists.txt` `add_subdirectory`, and `src/doppler/__init__.py`.
Nothing to edit manually — just rebuild:

```sh
make pyext        # compile the new .so
make python-test  # full pytest suite
```

______________________________________________________________________

## Checklist

Before opening a PR:

- [ ] `make test` — all C tests pass
- [ ] `python -m doctest -v src/doppler/<name>/<name>.pyi` — all examples pass
- [ ] `make python-test` — all Python tests pass
- [ ] `make bench-python` — Python benchmarks run and a JSON snapshot is saved
- [ ] `make bench-c` — C benchmarks run and a `-c.json` snapshot is saved
- [ ] `__init__.py` contains only re-exports and `__all__`
- [ ] No Python wrapper classes — C extension types are the public API
- [ ] `<name>.pyi` has stubs for every exported symbol
- [ ] `make docs-build` — docs build clean

______________________________________________________________________

## See also

- [Module Layout](module-layout.md) — file layout rules and rationale
- [Benchmarking](benchmarking.md) — C and Python benchmark pipelines, history files, comparisons
- [just-makeit docs](https://just-buildit.github.io/just-makeit/) — full
    command reference
- [Build from Source](../install/source.md) — cmake flags and make targets
