PyGhostID 1.0.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.
PyGhostID/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """
2
+ PyGhostID - identification of SN-ghosts and composite ghost structures in dynamical systems.
3
+
4
+ 2025-2026 by Daniel Koch
5
+ """
6
+
7
+ from .core import ghostID, ghostID_phaseSpaceSample, make_batch_model, find_local_Qminimum, qOnGrid, track_ghost_branch, ghost_connections, unique_ghosts,unify_IDs, draw_network
8
+
9
+ __all__ = ["ghostID",
10
+ "ghostID_phaseSpaceSample",
11
+ "make_batch_model",
12
+ "find_local_Qminimum",
13
+ "qOnGrid",
14
+ "track_ghost_branch",
15
+ "ghost_connections",
16
+ "unique_ghosts",
17
+ "unify_IDs",
18
+ "draw_network"
19
+ ]
PyGhostID/_utils.py ADDED
@@ -0,0 +1,416 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Created on Sat Oct 4 14:50:48 2025
4
+
5
+ @author: dkoch
6
+ """
7
+
8
+ import numpy as np
9
+ import jax
10
+ import jax.numpy as jnp
11
+ import matplotlib.pyplot as plt
12
+ from matplotlib.patches import FancyArrowPatch
13
+ from scipy.stats import qmc
14
+
15
+ def iqr_sliding_filter(x, windowsize, k):
16
+ x = np.asarray(x, dtype=float)
17
+ y = x.copy()
18
+ n = len(x)
19
+
20
+ for i in range(n):
21
+ i0 = max(0, i - windowsize // 2)
22
+ i1 = min(n, i + windowsize // 2 + 1)
23
+
24
+ w = x[i0:i1]
25
+ q1, q3 = np.percentile(w, [25, 75])
26
+ iqr = q3 - q1
27
+
28
+ if iqr == 0:
29
+ continue
30
+
31
+ lower = q1 - k * iqr
32
+ upper = q3 + k * iqr
33
+
34
+ if x[i] < lower or x[i] > upper:
35
+ w_no_outlier = w[w != x[i]]
36
+ if w_no_outlier.size > 0:
37
+ y[i] = w_no_outlier.mean()
38
+
39
+ return y
40
+
41
+ def sort_NN(x):
42
+ x_ = np.zeros(x.shape)
43
+ x_[:,0] = x[:,0]
44
+ for t in range(0,x.shape[1]-1):
45
+ idcs_used = []
46
+
47
+ for j in range(0,x.shape[0]):
48
+
49
+ if t < 2:
50
+ d = np.abs(x[:,t+1]-x_[j,t])
51
+ idcs_sorted = np.argsort(d)
52
+ else:
53
+ x_pred = x_[j, t] + (x_[j, t] - x_[j, t-1])
54
+ d = np.abs(x[:, t+1] - x_pred)
55
+ idcs_sorted = np.argsort(d)
56
+ for i in range(len(idcs_sorted)):
57
+ if idcs_sorted[i] not in idcs_used:
58
+ idx = idcs_sorted[i]
59
+ idcs_used.append(idx)
60
+ break
61
+ x_[j,t+1] = x[idx,t+1]
62
+ return x_
63
+
64
+
65
+ def sign_change(arr,OR,OR_ws,OR_k,**kwargs):
66
+ """
67
+ Returns True if and only if:
68
+ 1. arr has at least two elements
69
+ 2. arr[0] < 0 (starts negative)
70
+ 3. arr[-1] > 0 (ends positive)
71
+ AND
72
+ 4. Values are increasing (arr[i] >= arr[i-1])
73
+ 5. There is exactly one transition where prev < 0 and curr > 0
74
+
75
+ Zero values or held-constant segments will cause it to return False.
76
+ """
77
+
78
+ display_warnings = kwargs.get("display_warnings", True)
79
+
80
+ if len(arr) < 2:
81
+ return False
82
+
83
+ if arr[0] >= 0 or arr[-1] <= 0:
84
+ return False
85
+
86
+ sign_change_occured = False
87
+ tryOR = False
88
+ for prev, curr in zip(arr, arr[1:]):
89
+ if curr < prev:
90
+ if display_warnings:
91
+ print(f"Error in evaluating sign change of eigenvalues: monotonicity violated. {"Trying outlier removal..." if OR else ""}")
92
+ tryOR = True
93
+ break
94
+ # 2) detect the one negative→positive jump
95
+ if prev < 0 and curr > 0:
96
+ if not sign_change_occured:
97
+ sign_change_occured = True
98
+ else:
99
+ if display_warnings:
100
+ print(f"Error in evaluating sign change of eigenvalues: more than one sign changes detected. {"Trying outlier removal..." if OR else ""}")
101
+ sign_change_occured = False
102
+ tryOR = True
103
+ break
104
+
105
+ if OR and tryOR:
106
+
107
+ arr_ = iqr_sliding_filter(arr,OR_ws,OR_k)
108
+ for prev, curr in zip(arr_, arr_[1:]):
109
+ if curr < prev:
110
+ if display_warnings:
111
+ print("... unsuccessful.")
112
+ break
113
+ # 2) detect the one negative→positive jump
114
+ if prev < 0 and curr > 0:
115
+ if not sign_change_occured:
116
+ sign_change_occured = True
117
+ if display_warnings:
118
+ print("... success.")
119
+ else:
120
+ if display_warnings:
121
+ print("... unsuccessful.")
122
+ sign_change_occured = False
123
+ break
124
+
125
+ return sign_change_occured
126
+
127
+ def phaseSpaceLHS(ranges, n_samples, seed):
128
+ """
129
+ Latin-hypercube sample points from a phase-space region defined by np.linspace ranges.
130
+
131
+ Parameters
132
+ ----------
133
+ ranges : list of np.ndarray
134
+ Each array defines the range (e.g., np.linspace(...)) for one dimension.
135
+ n_samples : int
136
+ Number of points to sample.
137
+
138
+ Returns
139
+ -------
140
+ samples : np.ndarray of shape (n_samples, n_dims)
141
+ The sampled points within the specified state-space region.
142
+ """
143
+ n_dims = len(ranges)
144
+ sampler = qmc.LatinHypercube(d=n_dims,seed=seed)
145
+ unit_samples = sampler.random(n=n_samples)
146
+
147
+ # Scale to each dimension’s bounds
148
+ bounds = np.array([[r[0], r[-1]] for r in ranges])
149
+ samples = qmc.scale(unit_samples, bounds[:, 0], bounds[:, 1])
150
+ return samples
151
+
152
+
153
+ def trjSegment(idcs,iq):
154
+
155
+ start = np.searchsorted(idcs, iq)
156
+
157
+ # a,b=iq,iq
158
+
159
+ a = start
160
+ b = start
161
+
162
+
163
+ for i in range(start-1,-1,-1):
164
+ d = idcs[i+1]-idcs[i]
165
+ if d != 1:
166
+ break
167
+ a = i
168
+
169
+ for i in range(start+1,len(idcs)):
170
+ d = idcs[i]-idcs[i-1]
171
+ if d != 1:
172
+ break
173
+ b = i
174
+
175
+ return idcs[np.arange(a, b+1, 1, dtype=int)]
176
+
177
+ # ---------------- JAX Jacobian utility ----------------
178
+ def make_jacfun(model, params):
179
+ F = lambda x: model(0, x, params)
180
+ J_fun = jax.jacfwd(F)
181
+ return jax.jit(J_fun)
182
+
183
+ # ---------------- Fast slope calculation ----------------
184
+ def slope_and_r2(y_, dt, ev_outlier_removal, ev_outlier_removal_ws, ev_outlier_removal_k):
185
+ """
186
+ Compute slope and R² of linear regression of y vs time
187
+ y: shape (N,)
188
+ dt: time step
189
+ """
190
+
191
+ if ev_outlier_removal:
192
+ y = iqr_sliding_filter(y_, ev_outlier_removal_ws, ev_outlier_removal_k)
193
+ else:
194
+ y = y_
195
+
196
+ N = len(y)
197
+ x = np.arange(N) * dt
198
+ x_mean = np.mean(x)
199
+ y_mean = np.mean(y)
200
+ slope = np.sum((x - x_mean) * (y - y_mean)) / np.sum((x - x_mean)**2)
201
+ y_pred = slope * (x - x_mean) + y_mean
202
+ ss_res = np.sum((y - y_pred)**2)
203
+ ss_tot = np.sum((y - y_mean)**2)
204
+ r2 = 1 - ss_res/ss_tot if ss_tot != 0 else 0
205
+ return slope, r2
206
+
207
+ def icAtQmin(qmin,step,nlowest,model,params):
208
+
209
+ J_fun = make_jacfun(model, params)
210
+ eig = jnp.linalg.eig(J_fun(qmin))
211
+ eigVals = jnp.real(eig[0])
212
+ eigVecs = eig[1]
213
+
214
+ idcs = np.argsort(np.abs(eigVals))
215
+
216
+ direction = eigVecs[:, idcs[0]]
217
+
218
+ if nlowest > 1:
219
+ for i in range(1,nlowest):
220
+ direction += eigVecs[:, idcs[i]]
221
+
222
+ direction_norm = direction/jnp.linalg.norm(direction)
223
+
224
+ pos = qmin + step * direction_norm
225
+
226
+ return pos,eigVals,eigVecs
227
+
228
+ def get_ctrl_plot_settings(kwargs, key, default_x="linear", default_y="linear"):
229
+ ctrl = kwargs.get("ctrlOutputs", {})
230
+ flag = ctrl.get(f"ctrl_{key}", False)
231
+
232
+ if not isinstance(flag, bool):
233
+ raise TypeError(f"ctrl_{key} must be boolean, got {type(flag).__name__}")
234
+ if not flag:
235
+ return False, None, None
236
+
237
+ xscale = ctrl.get(f"{key}_xscale", default_x)
238
+ yscale = ctrl.get(f"{key}_yscale", default_y)
239
+
240
+ if xscale not in ("linear", "log"):
241
+ raise ValueError(f"{key}_xscale must be 'linear' or 'log', got {xscale!r}")
242
+ if yscale not in ("linear", "log"):
243
+ raise ValueError(f"{key}_yscale must be 'linear' or 'log', got {yscale!r}")
244
+
245
+ return True, xscale, yscale
246
+
247
+ def draw_custom_edges(G, pos, edgelist, color="red", head_length=10, head_width=5, width=1, rad=0.1, trim_fraction=0.1):
248
+ """
249
+ Draws edges using FancyArrowPatch. Instead of drawing an arrow from the exact start to end points,
250
+ the arrow is drawn only along the inner portion of the edge. The arrow starts at:
251
+ (x1, y1) + trim_fraction * (x2 - x1, y2 - y1)
252
+ and ends at:
253
+ (x1, y1) + (1 - trim_fraction) * (x2 - x1, y2 - y1)
254
+
255
+ mutation_scale is set to 1.
256
+ """
257
+ ax = plt.gca() # Get current axis
258
+ for u, v in edgelist:
259
+ if u in pos and v in pos:
260
+ x1, y1 = pos[u]
261
+ x2, y2 = pos[v]
262
+ tf = trim_fraction
263
+ # Compute trimmed start and end points
264
+ l = tf * np.sqrt((y2-y1)**2+(x2-x1)**2)
265
+ if l > 30000:
266
+ tf = 28000/np.sqrt((y2-y1)**2+(x2-x1)**2)
267
+
268
+ # if l < 10000:
269
+ # tf = 10000/np.sqrt((y2-y1)**2+(x2-x1)**2)
270
+ start = (x1 + tf * (x2 - x1), y1 + tf * (y2 - y1))
271
+ end = (x1 + (1 - tf) * (x2 - x1), y1 + (1 - tf) * (y2 - y1))
272
+
273
+ arrow = FancyArrowPatch(
274
+ start, end,
275
+ arrowstyle=f"-|>,head_length={head_length},head_width={head_width}",
276
+ color=color,
277
+ linewidth=width,
278
+ connectionstyle=f"arc3,rad={rad}",
279
+ mutation_scale=1 # Set mutation_scale as requested
280
+ )
281
+ ax.add_patch(arrow)
282
+
283
+
284
+ def parse_kwargs(**kwargs):
285
+ """Parse and validate kwargs for ghostID and dependent functions.
286
+
287
+ Returns a dictionary with all parameters and their validated values.
288
+ """
289
+ config = {}
290
+
291
+ # ghostID parameters
292
+
293
+ config['delta_gid'] = kwargs.get("delta_gid", 0.1)
294
+
295
+ # Peak detection parameters
296
+ config['peak_kwargs'] = kwargs.get("peak_kwargs", {})
297
+
298
+ # Model and batch processing
299
+ config['batchModel'] = kwargs.get("batchModel", None)
300
+
301
+ # Control outputs and plotting
302
+ config['ctrlOutputs'] = kwargs.get("ctrlOutputs", {})
303
+ config['return_ctrl_figs'] = config['ctrlOutputs'].get('return_ctrl_figs', False)
304
+ config['display_warnings'] = kwargs.get("display_warnings", True)
305
+
306
+ # Plotting control settings
307
+ ctrl = config['ctrlOutputs']
308
+
309
+ # Q-plot settings
310
+ config['ctrl_qplot'] = ctrl.get("ctrl_qplot", False)
311
+ if not isinstance(config['ctrl_qplot'], bool):
312
+ raise TypeError(f"ctrl_qplot must be boolean, got {type(config['ctrl_qplot']).__name__}")
313
+ if config['ctrl_qplot']:
314
+ config['qplot_xscale'] = ctrl.get("qplot_xscale", "linear")
315
+ config['qplot_yscale'] = ctrl.get("qplot_yscale", "linear")
316
+ if config['qplot_xscale'] not in ("linear", "log"):
317
+ raise ValueError(f"qplot_xscale must be 'linear' or 'log', got {config['qplot_xscale']!r}")
318
+ if config['qplot_yscale'] not in ("linear", "log"):
319
+ raise ValueError(f"qplot_yscale must be 'linear' or 'log', got {config['qplot_yscale']!r}")
320
+ else:
321
+ config['qplot_xscale'] = None
322
+ config['qplot_yscale'] = None
323
+
324
+ # Eigenvalue plot settings
325
+ config['ctrl_evplot'] = ctrl.get("ctrl_evplot", False)
326
+ if not isinstance(config['ctrl_evplot'], bool):
327
+ raise TypeError(f"ctrl_evplot must be boolean, got {type(config['ctrl_evplot']).__name__}")
328
+ if config['ctrl_evplot']:
329
+ config['evplot_xscale'] = ctrl.get("evplot_xscale", "linear")
330
+ config['evplot_yscale'] = ctrl.get("evplot_yscale", "linear")
331
+ if config['evplot_xscale'] not in ("linear", "log"):
332
+ raise ValueError(f"evplot_xscale must be 'linear' or 'log', got {config['evplot_xscale']!r}")
333
+ if config['evplot_yscale'] not in ("linear", "log"):
334
+ raise ValueError(f"evplot_yscale must be 'linear' or 'log', got {config['evplot_yscale']!r}")
335
+ else:
336
+ config['evplot_xscale'] = None
337
+ config['evplot_yscale'] = None
338
+
339
+ # Eigenvalue processing
340
+ config['eigval_NN_sorting'] = kwargs.get("eigval_NN_sorting", False)
341
+ config['ev_outlier_removal'] = kwargs.get("ev_outlier_removal", False)
342
+ config['ev_outlier_removal_ws'] = kwargs.get("ev_outlier_removal_ws", 7)
343
+ config['ev_outlier_removal_k'] = kwargs.get("ev_outlier_removal_k", 1.5)
344
+ config['evLimit'] = kwargs.get("evLimit", 0)
345
+
346
+ # Slope limits with validation
347
+ sl = kwargs.get("slopeLimits", None)
348
+ if sl is not None:
349
+ sl = np.asarray(sl, dtype=float)
350
+ if sl.shape == (2,) and np.all(sl >= 0) and sl[0] < sl[1]:
351
+ config['slopeLimits'] = sl
352
+ else:
353
+ raise ValueError(
354
+ f"slopeLimits must be a 2-element nonnegative interval [min,max], got {sl}"
355
+ )
356
+ else:
357
+ config['slopeLimits'] = np.array([0, np.inf])
358
+
359
+ #############################################################
360
+
361
+ # ghostID_phaseSpaceSample specific parameters
362
+ config['epsilon_gid'] = kwargs.get("epsilon_gid", 0.1)
363
+ config['epsilon_unify'] = kwargs.get("epsilon_unify", 0.1)
364
+ # config['n_samples'] = kwargs.get("n_samples", 50)
365
+ config['seed'] = kwargs.get("seed", None)
366
+
367
+ # track_ghost_branch specific parameters
368
+ config['distQminThr'] = kwargs.get("distQminThr", np.inf)
369
+
370
+ # --- Define method-aware defaults ---------------------
371
+ DEFAULT_QMIN_GLOB_OPTIONS = {
372
+ "lhs": {
373
+ "n_samples": None, # auto = max(200, 20*dim) inside optimizer
374
+ "k_seeds": None, # auto = min(5, sqrt(dim))
375
+ "seed": None, # reproducible if set
376
+ },
377
+ "differential_evolution": {
378
+ "maxiter": 1000,
379
+ "tol": 1e-2,
380
+ "seed": None, # reproducible if set
381
+ },
382
+ "dual_annealing": {
383
+ "maxiter": 1000,
384
+ },
385
+ "basin_hopping": {
386
+ "niter": 100,
387
+ "stepsize": 0.5,
388
+ },
389
+ }
390
+
391
+ DEFAULT_QMIN_LOC_OPTIONS = {
392
+ "L-BFGS-B": {"maxiter": 500, "gtol": 1e-6},
393
+ "BFGS": {"maxiter": 500, "gtol": 1e-6},
394
+ "CG": {"maxiter": 500, "gtol": 1e-6},
395
+ "TNC": {"maxiter": 500, "gtol": 1e-6},
396
+ "SLSQP": {"maxiter": 500, "ftol": 1e-9},
397
+ None: {},
398
+ }
399
+
400
+ # --- Pick methods from kwargs with fallback -----------
401
+ config['qmin_glob_method'] = kwargs.get("qmin_glob_method", "lhs")
402
+ config['qmin_loc_method'] = kwargs.get("qmin_loc_method", "L-BFGS-B")
403
+
404
+ glob_method = config['qmin_glob_method']
405
+ loc_method = config['qmin_loc_method']
406
+
407
+ # --- Merge user-specified options with defaults -------
408
+ user_glob_opts = kwargs.get("qmin_glob_options", {})
409
+ default_glob_opts = DEFAULT_QMIN_GLOB_OPTIONS.get(glob_method, {})
410
+ config['qmin_glob_options'] = {**default_glob_opts, **user_glob_opts}
411
+
412
+ user_loc_opts = kwargs.get("qmin_loc_options", {})
413
+ default_loc_opts = DEFAULT_QMIN_LOC_OPTIONS.get(loc_method, {})
414
+ config['qmin_loc_options'] = {**default_loc_opts, **user_loc_opts}
415
+
416
+ return config