Metadata-Version: 2.4
Name: nugap
Version: 0.6.0
Summary: Vinnicombe nu-gap metric and a pipeline for comparing time-course data across two conditions
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.23
Requires-Dist: scipy>=1.9
Requires-Dist: pandas>=1.5
Provides-Extra: control
Requires-Dist: control>=0.9; extra == "control"
Provides-Extra: viz
Requires-Dist: matplotlib>=3.5; extra == "viz"
Requires-Dist: networkx>=2.6; extra == "viz"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Provides-Extra: gpu
Requires-Dist: cupy>=10; extra == "gpu"
Dynamic: license-file

# nugap

**Detect condition-specific changes in *dynamics* using the Vinnicombe ν-gap metric.**

[!\[PyPI](https://img.shields.io/pypi/v/nugap.svg)](https://pypi.org/project/nugap/)
[!\[License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[!\[Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/comparativechrono/nugap/blob/main/tutorial_nugap.ipynb)

`nugap` is a lightweight Python implementation of the **Vinnicombe ν-gap** — a
bounded (0–1) distance between linear dynamical systems from robust control
theory — together with the model identification and statistics needed to ask a
practical question of two-condition time-course data:

> \*\*Which variables, or which relationships between them, change their \*dynamical
> behaviour\* between conditions?\*\*

(for example wild type vs mutant, or untreated vs treated). Comparisons of
expression *level* answer a different question and are blind to changes in
timescale, gain or phase. The ν-gap is defined directly on dynamical models, so
it is not: two systems close in ν-gap behave the same way under feedback, and a
large ν-gap marks a genuine change in dynamics.

## Installation

```bash
pip install nugap
```

For optional **GPU** acceleration of the pairwise-network sweep, also install a
CUDA build of CuPy:

```bash
pip install nugap\[gpu]          # then a matching wheel, e.g. cupy-cuda12x
```

Requires Python ≥ 3.10. The core and pipelines depend only on **NumPy**, **SciPy**
and **pandas**; the plotting helpers additionally use **Matplotlib** and
**NetworkX**.

## Tutorial

A guided, runnable tutorial covers the metric, model fitting, and the two-condition
network comparison end to end:

* **Open in Colab:** https://colab.research.google.com/github/comparativechrono/nugap/blob/main/tutorial\_nugap.ipynb
* **View on GitHub:** [`tutorial\_nugap.ipynb`](https://github.com/comparativechrono/nugap/blob/main/tutorial_nugap.ipynb)

## Quick start

The ν-gap between two systems (coefficients in descending powers of `s`):

```python
from nugap import tf, nu\_gap

P1 = tf(\[1], \[1, 1])     # 1/(s+1)
P2 = tf(\[1], \[1, 3])     # 1/(s+3)
nu\_gap(P1, P2)           # -> 0.447   (0 = identical, 1 = maximally different)
```

Comparing a whole interaction network between two conditions, with replicate-based
significance:

```python
from nugap import compare\_network

# data\_A, data\_B: dict {variable\_name: array of shape (replicates, timepoints)}
# t: the common time vector
edges = compare\_network(data\_A, data\_B, t, order=1, min\_r2=0.5)

edges.query("q\_global < 0.1")   # relationships rewired between conditions (FDR < 10%)
```

## What it provides

* **`nu\_gap`** — the Vinnicombe ν-gap for SISO systems in continuous or discrete
time, with an optional frequency-band restriction (`band=`) and a switchable
winding-number test (`check\_winding=`) for oscillatory data.
* **Model identification** — `fit\_first\_order`, `fit\_model`, `fit\_arx`,
`fit\_prony`, with simulation-based fit quality and an optional DC-gain floor
(`min\_dc\_gain=`); plus the `dc\_gain` helper.
* **`compare\_conditions`** — per-variable comparison of dynamics between two
conditions, with a fit-quality reliability flag.
* **`compare\_network`** — pairwise interaction-network comparison: a low-order
model is fitted to every ordered pair of variables in each condition, the ν-gap
is taken between conditions, and significance comes from a replicate-derived
empirical null with Benjamini–Hochberg FDR control.
* **`compare\_network\_batch`** — a vectorized, optionally GPU-accelerated drop-in
for `compare\_network` (first-order models): same inputs, columns and statistics,
but the per-edge fits and ν-gaps run in batches on **NumPy** (CPU) or **CuPy**
(GPU), scaling to genome-size `N\*(N-1)` edge sweeps. Reproduces `compare\_network`
to floating-point precision.
* **Plotting** — `nugap.viz` (volcano plot, hub network, hub bar plot).

## Vectorized \& GPU acceleration

For genome-scale sweeps (`N\*(N-1)` ordered edges), **`compare\_network\_batch`** is a
drop-in, vectorized replacement for `compare\_network`. The per-edge first-order
fits and ν-gaps are computed in batches with NumPy (CPU) or CuPy (GPU); it
reproduces `compare\_network`'s output to floating-point precision and scales to
millions of edges.

```python
from nugap import compare\_network\_batch

# identical call and columns to compare\_network; runs on CPU by default
edges = compare\_network\_batch(data\_A, data\_B, t, order=1, min\_r2=0.5)

# use a GPU if one is available (falls back to CPU otherwise)
edges = compare\_network\_batch(data\_A, data\_B, t, device="auto")
```

* **First-order only** (`order=1`, `n\_zeros=0`/`None`); use `compare\_network` for higher orders.
* Requires the **same replicate count** for every variable.
* `device` ∈ {`"cpu"`, `"cuda"`, `"auto"`}; `dtype` ∈ {`"float64"` (exact, default), `"float32"` (lighter/faster on GPU, \~10⁻⁶)}; `block` sets the number of edges scored per batch (raise for throughput, lower to fit RAM/VRAM).
* `band=` and `check\_winding=` are forwarded to the metric, so the frequency-local (DyDE-style) variant — `band=(2\*pi/Pmax, 2\*pi/Pmin)`, `check\_winding=False` — is available here too.

Lower-level batched primitives are exposed for custom pipelines:
`fit\_first\_order\_batch`, `nu\_gap\_first\_order\_batch`, `sup\_chordal\_batch`,
`full\_contour\_points`, `band\_contour\_points`, plus the backend helpers
`get\_array\_module` and `gpu\_available`.

## How it works

For each variable or pairwise interaction, `nugap` fits a low-order linear
input–output model under each condition, then measures the ν-gap between the
fitted models. Because models are compared on mean-centred trajectories, the
metric reflects changes in the *relationship* — timescale, gain or phase — rather
than in absolute level. With biological replicates, the spread of within-condition
ν-gaps provides an empirical null and a per-edge noise floor, against which
between-condition changes are tested and FDR-controlled.

The models are single-input single-output, so an edge captures a pairwise
input–output relationship, not proven causation.

## Correctness

The ν-gap implementation is verified by several independent routes — exact
closed-form values, an algebraic invariance of the chordal metric, an independent
reference implementation, and the Vinnicombe robust-stability theorem — and is
cross-checked against MATLAB's `gapmetric` (Robust Control Toolbox), which it
reproduces to within \~10⁻⁶. The scripts are in [`validation/`](validation/), with
a fast subset run on every commit. The vectorized batch routines
(`compare\_network\_batch` and the `\*\_batch` primitives) are validated against the
scalar functions to floating-point precision (`tests/test\_batch.py`).

## Citing

If you use `nugap` in your work, please cite the software:

```
Hearn, T. J. nugap: condition-specific changes in dynamical relationships via the
Vinnicombe ν-gap. Software archive: Zenodo, DOI: 10.5281/zenodo.20693443.

```

## License

Released under the **MIT License**. Copyright © 2026 Tim Hearn. See
[`LICENSE`](LICENSE).

