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 +19 -0
- PyGhostID/_utils.py +416 -0
- PyGhostID/core.py +1330 -0
- pyghostid-1.0.0.dist-info/METADATA +19 -0
- pyghostid-1.0.0.dist-info/RECORD +8 -0
- pyghostid-1.0.0.dist-info/WHEEL +5 -0
- pyghostid-1.0.0.dist-info/licenses/LICENSE +674 -0
- pyghostid-1.0.0.dist-info/top_level.txt +1 -0
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
|