nugap 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nugap/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """nugap: the Vinnicombe nu-gap metric and a pipeline for comparing
2
+ time-course data across two conditions.
3
+
4
+ Quick start
5
+ -----------
6
+ from nugap import tf, nu_gap
7
+ d = nu_gap(tf([1],[1,1]), tf([1],[1,1.2])) # ~0.07
8
+
9
+ from nugap import compare_conditions
10
+ df = compare_conditions(data_A, data_B, t) # ranked table of changes
11
+ """
12
+
13
+ from .systems import LTI, tf, from_zpk, from_control, to_continuous
14
+ from .metric import nu_gap, chordal_distance, winding_condition
15
+ from .fitting import fit_model, fit_prony, fit_arx, fit_arx_fast, fit_first_order, FitResult
16
+ from .pipeline import compare_conditions, compare_variable
17
+ from .replicates import compare_conditions_replicates, compare_variable_replicates
18
+ from .network import compare_network
19
+
20
+ __all__ = [
21
+ "LTI", "tf", "from_zpk", "from_control", "to_continuous",
22
+ "nu_gap", "chordal_distance", "winding_condition",
23
+ "fit_model", "fit_prony", "fit_arx", "fit_arx_fast", "fit_first_order", "FitResult",
24
+ "compare_conditions", "compare_variable",
25
+ "compare_conditions_replicates", "compare_variable_replicates",
26
+ "compare_network",
27
+ ]
28
+
29
+ __version__ = "0.1.0"
nugap/fitting.py ADDED
@@ -0,0 +1,267 @@
1
+ """Fit discrete-time LTI models to time-course data.
2
+
3
+ This is the part of the pipeline that turns a measured trajectory into an
4
+ ``LTI`` system that the nu-gap metric can compare. It is deliberately
5
+ separated from the metric so you can swap in your own identification routine
6
+ (e.g. to mirror exactly what MATLAB's System Identification Toolbox did).
7
+
8
+ Two cases are handled:
9
+
10
+ * **Input/output data** (you applied a known stimulus ``u`` and recorded the
11
+ response ``y``): an ARX model is fitted by linear least squares, giving a
12
+ discrete transfer function B(z)/A(z).
13
+
14
+ * **Output-only data** (you only have the response ``y``, e.g. a signal that
15
+ rises and relaxes after a perturbation): Prony's method fits ``y`` as the
16
+ impulse response of a discrete LTI system, recovering its poles (modes) and
17
+ a numerator.
18
+
19
+ Sampled data -> discrete-time models is the natural and numerically clean
20
+ choice; the nu-gap metric evaluates these on the unit circle.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ import numpy as np
27
+
28
+ from .systems import LTI
29
+
30
+
31
+ @dataclass
32
+ class FitResult:
33
+ model: LTI
34
+ order: int
35
+ r2: float # fit quality of the simulated/predicted output
36
+ method: str
37
+ sample_time: float
38
+
39
+
40
+ def _sample_time(t) -> float:
41
+ t = np.asarray(t, dtype=float)
42
+ dts = np.diff(t)
43
+ dt = float(np.median(dts))
44
+ if dt <= 0:
45
+ raise ValueError("time vector must be increasing")
46
+ return dt
47
+
48
+
49
+ def _simulate(num, den, u, y0len):
50
+ """Simulate a discrete tf B/A driven by u (impulse if u is None)."""
51
+ import warnings
52
+ from scipy.signal import dlsim, TransferFunction, BadCoefficients
53
+
54
+ if u is None:
55
+ u = np.zeros(y0len)
56
+ u[0] = 1.0
57
+ with warnings.catch_warnings():
58
+ warnings.simplefilter("ignore", BadCoefficients)
59
+ sysd = TransferFunction(num, den, dt=1.0)
60
+ _, yout = dlsim(sysd, u)
61
+ return np.asarray(yout).ravel()
62
+
63
+
64
+ def _r2(y, yhat):
65
+ y = np.asarray(y, dtype=float)
66
+ n = min(len(y), len(yhat))
67
+ y, yhat = y[:n], yhat[:n]
68
+ ss_res = np.sum((y - yhat) ** 2)
69
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
70
+ return float(1.0 - ss_res / ss_tot) if ss_tot > 0 else 0.0
71
+
72
+
73
+ def fit_prony(t, y, order: int):
74
+ """Fit y as the impulse response of a discrete LTI of given order.
75
+
76
+ Returns (num, den) discrete polynomial coefficients (highest power first).
77
+ """
78
+ y = np.asarray(y, dtype=float).ravel()
79
+ N = len(y)
80
+ na = order
81
+ if N <= 2 * na:
82
+ raise ValueError("not enough samples for the requested order")
83
+
84
+ # Linear prediction: y[k] = -sum_{j=1..na} a_j y[k-j]
85
+ rows = []
86
+ rhs = []
87
+ for k in range(na, N):
88
+ rows.append([-y[k - j] for j in range(1, na + 1)])
89
+ rhs.append(y[k])
90
+ A = np.asarray(rows)
91
+ b = np.asarray(rhs)
92
+ a, *_ = np.linalg.lstsq(A, b, rcond=None)
93
+ den = np.concatenate([[1.0], a]) # [1, a1, ..., a_na]
94
+
95
+ # Numerator from the convolution relation treating y as impulse response.
96
+ nb = na
97
+ num = np.zeros(nb + 1)
98
+ for k in range(nb + 1):
99
+ s = 0.0
100
+ for j in range(k + 1):
101
+ if j < len(den):
102
+ s += den[j] * y[k - j]
103
+ num[k] = s
104
+ return num, den
105
+
106
+
107
+ def fit_arx(t, y, u, na: int, nb: int | None = None, nk: int = 1):
108
+ """Fit an ARX model A(z) y = B(z) u by least squares.
109
+
110
+ Returns (num, den) of the discrete transfer function B(z)/A(z).
111
+ """
112
+ y = np.asarray(y, dtype=float).ravel()
113
+ u = np.asarray(u, dtype=float).ravel()
114
+ N = len(y)
115
+ if nb is None:
116
+ nb = na
117
+ start = max(na, nb + nk)
118
+ rows, rhs = [], []
119
+ for k in range(start, N):
120
+ row = [-y[k - j] for j in range(1, na + 1)]
121
+ row += [u[k - nk - i] for i in range(0, nb + 1)]
122
+ rows.append(row)
123
+ rhs.append(y[k])
124
+ M = np.asarray(rows)
125
+ b = np.asarray(rhs)
126
+ theta, *_ = np.linalg.lstsq(M, b, rcond=None)
127
+ a = theta[:na]
128
+ bb = theta[na:]
129
+ den = np.concatenate([[1.0], a])
130
+ num = np.concatenate([np.zeros(nk), bb]) # account for input delay
131
+ return num, den
132
+
133
+
134
+ def _cap_orders(orders, N: int):
135
+ """Limit candidate orders so we never overfit short trajectories.
136
+
137
+ A model of order p has ~2p+1 free parameters; with N samples we keep
138
+ roughly 5 samples per parameter, so p_max ~ N//5 (at least 1).
139
+ """
140
+ p_max = max(1, N // 5)
141
+ capped = [o for o in orders if o <= p_max]
142
+ return capped or [1]
143
+
144
+
145
+ def fit_arx_fast(t, y, u, na: int, nb: int = 0, nk: int = 1, dt=None):
146
+ """Fast ARX fit of arbitrary order with a recursive simulation R^2.
147
+
148
+ Fits A(z) y = B(z) u with ``na`` poles, ``nb+1`` numerator taps and input
149
+ delay ``nk`` by linear least squares, then simulates the model from u
150
+ (warm-started on the first samples) to score the fit. Returns (LTI, r2).
151
+
152
+ Designed to be called millions of times for pairwise identification, so it
153
+ avoids scipy per call. ``na=1, nb=0`` reproduces the first-order model
154
+ K/(tau s + 1) (MATLAB ``tfest(data, 1, 0)``); ``na=2, nb=0`` is the
155
+ canonical two-pole, no-zero second-order system.
156
+ """
157
+ y = np.asarray(y, dtype=float).ravel()
158
+ u = np.asarray(u, dtype=float).ravel()
159
+ if dt is None:
160
+ dt = _sample_time(t)
161
+ na, nb, nk = int(na), int(nb), int(nk)
162
+ N = len(y)
163
+ start = max(na, nb + nk)
164
+ n_params = na + nb + 1
165
+ if N - start < n_params:
166
+ raise ValueError(
167
+ f"not enough samples ({N}) for order na={na}, nb={nb}")
168
+
169
+ rows, rhs = [], []
170
+ for k in range(start, N):
171
+ row = [-y[k - i] for i in range(1, na + 1)]
172
+ row += [u[k - nk - i] for i in range(0, nb + 1)]
173
+ rows.append(row)
174
+ rhs.append(y[k])
175
+ theta, *_ = np.linalg.lstsq(np.asarray(rows), np.asarray(rhs), rcond=None)
176
+ a = theta[:na]
177
+ b = theta[na:]
178
+
179
+ den = np.concatenate([[1.0], a])
180
+ num = np.concatenate([np.zeros(nk), b]) # input delay
181
+
182
+ # simulate the model from u, warm-started with measured y on [0, start)
183
+ yhat = np.empty(N)
184
+ yhat[:start] = y[:start]
185
+ for k in range(start, N):
186
+ val = 0.0
187
+ for i in range(1, na + 1):
188
+ val -= a[i - 1] * yhat[k - i]
189
+ for i in range(0, nb + 1):
190
+ val += b[i] * u[k - nk - i]
191
+ yhat[k] = val
192
+
193
+ ys, yh = y[start:], yhat[start:]
194
+ ss_tot = np.sum((ys - np.mean(ys)) ** 2)
195
+ r2 = float(1.0 - np.sum((ys - yh) ** 2) / ss_tot) if ss_tot > 0 else 0.0
196
+ return LTI(num, den, dt=dt), r2
197
+
198
+
199
+ def fit_first_order(t, y, u, dt=None):
200
+ """First-order ARX fit (one pole, no zero). Thin wrapper around
201
+ ``fit_arx_fast`` with na=1, nb=0, nk=1. Returns (LTI, r2)."""
202
+ return fit_arx_fast(t, y, u, na=1, nb=0, nk=1, dt=dt)
203
+
204
+
205
+ def fit_model(
206
+ t,
207
+ y,
208
+ u=None,
209
+ orders=range(1, 5),
210
+ method: str = "auto",
211
+ ) -> FitResult:
212
+ """Fit a discrete LTI model, selecting the order by AIC.
213
+
214
+ Parameters
215
+ ----------
216
+ t : array time stamps (used only to get the sample time)
217
+ y : array measured response
218
+ u : array or None stimulus, if known
219
+ orders : iterable of int candidate model orders to try. Automatically
220
+ capped to ~N//5 so short trajectories are not overfitted.
221
+ method : 'auto' | 'prony' | 'arx'
222
+ 'auto' uses ARX when u is given, Prony otherwise.
223
+
224
+ Returns
225
+ -------
226
+ FitResult
227
+ """
228
+ y = np.asarray(y, dtype=float).ravel()
229
+ dt = _sample_time(t)
230
+ N = len(y)
231
+ orders = _cap_orders(list(orders), N)
232
+
233
+ if method == "auto":
234
+ method = "arx" if u is not None else "prony"
235
+
236
+ best = None
237
+ for order in orders:
238
+ try:
239
+ if method == "prony":
240
+ num, den = fit_prony(t, y, order)
241
+ yhat = _simulate(num, den, None, N)
242
+ nparams = 2 * order + 1
243
+ elif method == "arx":
244
+ num, den = fit_arx(t, y, u, na=order)
245
+ yhat = _simulate(num, den, np.asarray(u, float).ravel(), N)
246
+ nparams = 2 * order + 1
247
+ else:
248
+ raise ValueError(f"unknown method {method!r}")
249
+ except Exception:
250
+ continue
251
+
252
+ n = min(len(y), len(yhat))
253
+ sse = np.sum((y[:n] - yhat[:n]) ** 2)
254
+ if sse <= 0 or not np.isfinite(sse):
255
+ continue
256
+ aic = N * np.log(sse / N) + 2 * nparams
257
+ r2 = _r2(y, yhat)
258
+ cand = (aic, order, num, den, r2)
259
+ if best is None or aic < best[0]:
260
+ best = cand
261
+
262
+ if best is None:
263
+ raise RuntimeError("model fitting failed for all candidate orders")
264
+
265
+ _, order, num, den, r2 = best
266
+ model = LTI(num, den, dt=dt)
267
+ return FitResult(model=model, order=order, r2=r2, method=method, sample_time=dt)
nugap/metric.py ADDED
@@ -0,0 +1,179 @@
1
+ """The Vinnicombe nu-gap metric for SISO systems.
2
+
3
+ Reference: G. Vinnicombe, "Frequency domain uncertainty and the graph
4
+ topology", IEEE Trans. Automatic Control 38 (1993) 1371-1383, and
5
+ G. Vinnicombe, "Uncertain Systems and Feedback" (2001).
6
+
7
+ For two systems P1, P2 the nu-gap is
8
+
9
+ delta_nu(P1, P2) = sup_w kappa(P1, P2)(w) if the winding-number
10
+ condition holds,
11
+ = 1 otherwise,
12
+
13
+ where the *chordal distance* between two scalar transfer functions at a
14
+ frequency point is
15
+
16
+ |P1 - P2|
17
+ kappa = ------------------------------------ , in [0, 1].
18
+ sqrt(1+|P1|^2) * sqrt(1+|P2|^2)
19
+
20
+ The winding-number condition (SISO) is
21
+
22
+ wno( 1 + conj(P2) * P1 ) + eta(P1) - eta(P2) - eta0(P2) = 0
23
+
24
+ with eta = number of unstable poles and eta0 = number of boundary poles
25
+ (imaginary axis for continuous systems, unit circle for discrete). If the
26
+ condition fails the systems are "topologically far" and delta_nu = 1.
27
+
28
+ The result is symmetric: delta_nu(P1, P2) == delta_nu(P2, P1), and lies in
29
+ [0, 1]. 0 means identical dynamics; near 1 means very different.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import warnings
35
+ import numpy as np
36
+
37
+ from .systems import LTI
38
+
39
+
40
+ def chordal_distance(P1: np.ndarray, P2: np.ndarray) -> np.ndarray:
41
+ """Point-wise chordal distance between two frequency responses."""
42
+ P1 = np.asarray(P1)
43
+ P2 = np.asarray(P2)
44
+ return np.abs(P1 - P2) / (np.sqrt(1.0 + np.abs(P1) ** 2) * np.sqrt(1.0 + np.abs(P2) ** 2))
45
+
46
+
47
+ def _contour(sys1: LTI, sys2: LTI, n: int):
48
+ """Build the integration contour and the matching complex points.
49
+
50
+ Returns (param, points, closed) where ``points`` are the complex
51
+ arguments at which to evaluate the transfer functions.
52
+
53
+ Continuous: a dense, symmetric grid of frequencies covering the system
54
+ dynamics by several decades; points = j*w.
55
+ Discrete: theta in (-pi, pi]; points = exp(j*theta).
56
+ """
57
+ if sys1.is_discrete() or sys2.is_discrete():
58
+ # unit circle
59
+ theta = np.linspace(-np.pi, np.pi, n, endpoint=True)
60
+ return theta, np.exp(1j * theta), True
61
+ # continuous: choose range from the pole/zero magnitudes
62
+ feats = np.concatenate([
63
+ np.abs(sys1.poles), np.abs(sys2.poles),
64
+ np.abs(sys1.zeros), np.abs(sys2.zeros),
65
+ ])
66
+ feats = feats[feats > 0]
67
+ if feats.size:
68
+ lo = np.log10(feats.min()) - 3
69
+ hi = np.log10(feats.max()) + 3
70
+ else:
71
+ lo, hi = -3.0, 3.0
72
+ w_pos = np.logspace(lo, hi, n // 2)
73
+ w = np.concatenate([-w_pos[::-1], w_pos])
74
+ return w, 1j * w, False
75
+
76
+
77
+ def _winding_number(g_vals: np.ndarray, closed: bool) -> int:
78
+ """Net counter-clockwise encirclements of the origin by g along the
79
+ contour, from the sampled values g_vals (ordered along the contour).
80
+
81
+ For the discrete unit circle the contour is already closed. For the
82
+ continuous imaginary axis we sweep a very wide symmetric frequency band;
83
+ because here g(+/-inf) -> a real positive constant, the semicircle at
84
+ infinity adds no net phase, so the imaginary-axis sweep suffices.
85
+ """
86
+ ang = np.unwrap(np.angle(g_vals))
87
+ total = ang[-1] - ang[0]
88
+ return int(np.round(total / (2.0 * np.pi)))
89
+
90
+
91
+ def _g_on_contour(sys1: LTI, sys2: LTI, points: np.ndarray) -> np.ndarray:
92
+ """g = 1 + conj(P2) * P1 evaluated on the contour.
93
+
94
+ On the imaginary axis / unit circle the para-conjugate P2~ equals the
95
+ complex conjugate of P2, so this is exactly Vinnicombe's g restricted to
96
+ the contour.
97
+ """
98
+ P1 = sys1.freqresp(points)
99
+ P2 = sys2.freqresp(points)
100
+ return 1.0 + np.conjugate(P2) * P1
101
+
102
+
103
+ def winding_condition(sys1: LTI, sys2: LTI, n: int = 8192, tol: float = 1e-7):
104
+ """Evaluate the integer winding-number condition.
105
+
106
+ Returns (ok: bool, value: int, touches_origin: bool).
107
+ """
108
+ _, points, closed = _contour(sys1, sys2, n)
109
+ g = _g_on_contour(sys1, sys2, points)
110
+
111
+ # If g touches the origin on the contour, the chordal distance reaches 1
112
+ # somewhere and the systems sit on the boundary -> treat as far.
113
+ touches = bool(np.min(np.abs(g)) < 1e-9 * max(1.0, np.max(np.abs(g))))
114
+
115
+ wno_ccw = _winding_number(g, closed)
116
+ # Vinnicombe's condition counts clockwise encirclements of the origin
117
+ # along the Nyquist contour; _winding_number returns the (mathematically
118
+ # standard) counter-clockwise count, so negate.
119
+ wno = -wno_ccw
120
+ eta1, _ = sys1.pole_counts(tol)
121
+ eta2, eta02 = sys2.pole_counts(tol)
122
+ value = wno + eta1 - eta2 - eta02
123
+ ok = (value == 0) and not touches
124
+ return ok, value, touches
125
+
126
+
127
+ def nu_gap(
128
+ sys1: LTI,
129
+ sys2: LTI,
130
+ n: int = 8192,
131
+ tol: float = 1e-7,
132
+ return_details: bool = False,
133
+ ):
134
+ """Vinnicombe nu-gap metric delta_nu(sys1, sys2) in [0, 1].
135
+
136
+ Parameters
137
+ ----------
138
+ sys1, sys2 : LTI
139
+ SISO systems. Must both be continuous or both discrete with the
140
+ same sampling time.
141
+ n : int
142
+ Number of contour samples (resolution of the frequency sweep).
143
+ tol : float
144
+ Tolerance used to classify poles as unstable / on the boundary.
145
+ return_details : bool
146
+ If True, also return a dict with the sup-chordal distance, the
147
+ winding condition value, and the frequency of the maximum.
148
+
149
+ Returns
150
+ -------
151
+ float (or (float, dict) if return_details)
152
+ """
153
+ if sys1.is_discrete() != sys2.is_discrete():
154
+ raise ValueError("cannot compare a continuous system with a discrete one")
155
+ if sys1.is_discrete() and sys2.is_discrete() and sys1.dt != sys2.dt:
156
+ warnings.warn("comparing discrete systems with different sample times")
157
+
158
+ param, points, closed = _contour(sys1, sys2, n)
159
+ P1 = sys1.freqresp(points)
160
+ P2 = sys2.freqresp(points)
161
+
162
+ kappa = chordal_distance(P1, P2)
163
+ imax = int(np.nanargmax(kappa))
164
+ sup_kappa = float(kappa[imax])
165
+
166
+ ok, value, touches = winding_condition(sys1, sys2, n=n, tol=tol)
167
+ delta = sup_kappa if ok else 1.0
168
+
169
+ if return_details:
170
+ details = {
171
+ "sup_chordal": sup_kappa,
172
+ "winding_value": value,
173
+ "winding_ok": ok,
174
+ "touches_origin": touches,
175
+ "arg_at_max": float(param[imax]),
176
+ "result": delta,
177
+ }
178
+ return delta, details
179
+ return delta
nugap/network.py ADDED
@@ -0,0 +1,222 @@
1
+ """Pairwise dynamic-network comparison.
2
+
3
+ Workflow: within each condition, treat every variable as a candidate input for
4
+ every other variable and fit a first-order input->output model. Then, for each
5
+ ordered pair (i -> j), use the nu-gap to compare the condition-A model with the
6
+ condition-B model. Edges with a large nu-gap are interactions whose dynamics
7
+ changed between conditions.
8
+
9
+ With replicates, the within-condition nu-gap (replicate vs replicate of the
10
+ same condition) gives a per-edge noise floor; pooled across all edges it forms
11
+ a well-estimated null for FDR-controlled discovery across the millions of
12
+ edges.
13
+
14
+ Scale note: N variables -> N*(N-1) ordered edges. Each fit is a trivial
15
+ 2-parameter least squares; the cost is the metric. First-order systems are
16
+ smooth on the unit circle, so a small ``n`` (default 256) is accurate and keeps
17
+ this tractable. For thousands of variables, run edge batches in parallel
18
+ (the per-edge work is independent) and/or prescreen edges.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import itertools
24
+ import numpy as np
25
+ import pandas as pd
26
+
27
+ from .fitting import fit_arx_fast, _sample_time
28
+ from .metric import nu_gap
29
+ from .replicates import _bh_fdr
30
+
31
+
32
+ def _center(arr):
33
+ """Subtract each replicate's mean (these models assume no offset)."""
34
+ arr = np.asarray(arr, dtype=float)
35
+ if arr.ndim == 1:
36
+ return arr - np.mean(arr)
37
+ return arr - arr.mean(axis=1, keepdims=True)
38
+
39
+
40
+ def _edge_models(reps_in, reps_out, t, dt, na, nb):
41
+ """Fit a model per replicate for one (input->output) edge."""
42
+ models, r2s = [], []
43
+ for u, y in zip(reps_in, reps_out):
44
+ try:
45
+ m, r2 = fit_arx_fast(t, y, u, na=na, nb=nb, nk=1, dt=dt)
46
+ if not m.is_stable() and np.max(np.abs(m.poles)) > 5:
47
+ continue # discard wildly diverging fits
48
+ models.append(m)
49
+ r2s.append(r2)
50
+ except Exception:
51
+ continue
52
+ return models, r2s
53
+
54
+
55
+ def _gap_pairs(models, n):
56
+ R = len(models)
57
+ g = {}
58
+ for i, j in itertools.combinations(range(R), 2):
59
+ g[(i, j)] = nu_gap(models[i], models[j], n=n)
60
+ return g
61
+
62
+
63
+ def compare_network(
64
+ data_A: dict,
65
+ data_B: dict,
66
+ t,
67
+ order: int = 1,
68
+ n_zeros: int | None = None,
69
+ n: int = 256,
70
+ center: bool = True,
71
+ min_r2: float | None = 0.5,
72
+ gate: str = "either",
73
+ null_from_reliable_only: bool = True,
74
+ include_pairs=None,
75
+ global_null: bool = True,
76
+ progress: bool = False,
77
+ ):
78
+ """Compare the pairwise interaction network across conditions.
79
+
80
+ Parameters
81
+ ----------
82
+ data_A, data_B : dict[str, array]
83
+ variable name -> trajectories. Either 1D (single record) or 2D
84
+ (n_replicates x n_timepoints). With replicates a per-edge noise floor
85
+ is computed.
86
+ t : array
87
+ Common time vector.
88
+ order : int
89
+ Number of poles of the per-edge model. 1 = first-order K/(tau s + 1)
90
+ (default), 2 = second-order. Needs more time points at higher order.
91
+ n_zeros : int or None
92
+ Number of zeros in the numerator. Default None -> 0 (all-pole model:
93
+ first-order = 1 pole/0 zeros, second-order = 2 poles/0 zeros). Set to 1
94
+ for e.g. a two-pole/one-zero model (MATLAB ``tfest(data, 2, 1)``).
95
+ n : int
96
+ Metric contour resolution. 256 is accurate for low-order systems.
97
+ center : bool
98
+ Subtract each trajectory's mean before fitting (recommended).
99
+ min_r2 : float or None
100
+ Fit-quality gate threshold. Only edges where a real relationship holds
101
+ are tested; None disables gating.
102
+ gate : {'either', 'both', 'mean'}
103
+ How the per-condition fit qualities are combined for the gate:
104
+ * 'either' (default) - keep the edge if the best replicate fit in
105
+ EITHER condition reaches min_r2 (catches relationships that appear
106
+ or disappear between conditions).
107
+ * 'both' - require the best replicate fit in BOTH conditions to reach
108
+ min_r2 (a relationship present in both, whose dynamics may differ).
109
+ * 'mean' - use the mean R^2 over all replicate fits of both
110
+ conditions.
111
+ include_pairs : iterable[(src, tgt)] or None
112
+ Restrict to specific ordered edges (e.g. a prescreened candidate set).
113
+ If None, all ordered pairs i != j are tested.
114
+ global_null : bool
115
+ Pool within-condition nu-gaps across all edges into one null and report
116
+ p_global / q_global (BH-FDR).
117
+
118
+ Returns
119
+ -------
120
+ pandas.DataFrame, one row per edge, sorted by significance (or nu_gap).
121
+ """
122
+ if gate not in ("either", "both", "mean"):
123
+ raise ValueError("gate must be 'either', 'both', or 'mean'")
124
+ na = int(order)
125
+ nb = 0 if n_zeros is None else int(n_zeros)
126
+ variables = list(data_A.keys())
127
+ dt = _sample_time(t)
128
+
129
+ def as_reps(d):
130
+ out = {}
131
+ for k, v in d.items():
132
+ v = np.asarray(v, dtype=float)
133
+ if v.ndim == 1:
134
+ v = v[None, :]
135
+ out[k] = _center(v) if center else v
136
+ return out
137
+
138
+ A = as_reps(data_A)
139
+ B = as_reps(data_B)
140
+
141
+ if include_pairs is None:
142
+ pairs = [(i, j) for i in variables for j in variables if i != j]
143
+ else:
144
+ pairs = list(include_pairs)
145
+
146
+ records = []
147
+ global_within = []
148
+ for e, (src, tgt) in enumerate(pairs):
149
+ if progress and e % 5000 == 0:
150
+ print(f" {e}/{len(pairs)} edges")
151
+
152
+ mA, r2A = _edge_models(A[src], A[tgt], t, dt, na, nb)
153
+ mB, r2B = _edge_models(B[src], B[tgt], t, dt, na, nb)
154
+ if not mA or not mB:
155
+ continue
156
+
157
+ # Only test an edge where a real relationship exists; otherwise the
158
+ # meaningless, high-variance fits would pollute the null. How the two
159
+ # conditions' fit qualities are combined is controlled by `gate`.
160
+ best_A = max(r2A, default=-np.inf)
161
+ best_B = max(r2B, default=-np.inf)
162
+ if gate == "either":
163
+ gate_stat = max(best_A, best_B)
164
+ elif gate == "both":
165
+ gate_stat = min(best_A, best_B)
166
+ else: # 'mean'
167
+ gate_stat = float(np.mean(r2A + r2B)) if (r2A or r2B) else -np.inf
168
+ if min_r2 is not None and gate_stat < min_r2:
169
+ continue
170
+ max_r2 = max(best_A, best_B)
171
+
172
+ models = mA + mB
173
+ nA = len(mA)
174
+ idxA, idxB = range(nA), range(nA, len(models))
175
+ gp = _gap_pairs(models, n)
176
+
177
+ def get(i, j):
178
+ return gp[(i, j)] if i < j else gp[(j, i)]
179
+
180
+ between = [get(a, b) for a in idxA for b in idxB]
181
+ within_A = [get(a, b) for a, b in itertools.combinations(idxA, 2)]
182
+ within_B = [get(a, b) for a, b in itertools.combinations(idxB, 2)]
183
+ within = within_A + within_B
184
+
185
+ between_med = float(np.median(between))
186
+ within_med = float(np.median(within)) if within else 0.0
187
+ rec = {
188
+ "source": src, "target": tgt,
189
+ "nu_gap": between_med,
190
+ "within_median": within_med,
191
+ "separation": between_med - within_med,
192
+ "n_reps": min(nA, len(mB)),
193
+ "max_r2": float(max_r2),
194
+ "mean_r2": float(np.mean(r2A + r2B)) if (r2A or r2B) else np.nan,
195
+ }
196
+ records.append(rec)
197
+ if global_null:
198
+ # A within-condition gap belongs in the null only if a real
199
+ # relationship exists in that condition; otherwise we would be
200
+ # pooling the variance of fitting noise to noise, which inflates
201
+ # the null and destroys power (e.g. a condition where the gene
202
+ # went flat). Set null_from_reliable_only=False to pool all.
203
+ thr = -np.inf if (min_r2 is None or not null_from_reliable_only) else min_r2
204
+ if best_A >= thr:
205
+ global_within.extend(within_A)
206
+ if best_B >= thr:
207
+ global_within.extend(within_B)
208
+
209
+ df = pd.DataFrame.from_records(records)
210
+ if df.empty:
211
+ return df
212
+
213
+ if global_null and global_within:
214
+ pool = np.sort(np.asarray(global_within, dtype=float))
215
+ m = pool.size
216
+ ge = m - np.searchsorted(pool, df["nu_gap"].values, side="left")
217
+ df["p_global"] = (ge + 1) / (m + 1)
218
+ df["q_global"] = _bh_fdr(df["p_global"].values)
219
+ df = df.sort_values("q_global", ascending=True).reset_index(drop=True)
220
+ else:
221
+ df = df.sort_values("nu_gap", ascending=False).reset_index(drop=True)
222
+ return df