fwdd 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.
fwdd/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ from .filter_function import (
2
+ filter_function_approx,
3
+ numba_complex_sum,
4
+ filter_function_finite,
5
+ )
6
+ from .noise_spectra import (
7
+ noise_spectrum_1f,
8
+ noise_spectrum_lor,
9
+ noise_spectrum_white,
10
+ noise_spectrum_double_power_law,
11
+ noise_spectrum_combination,
12
+ )
13
+ from .coherence_profile import (
14
+ coherence_decay_profile_delta,
15
+ noise_inversion_delta,
16
+ coherence_decay_profile_finite_peaks_with_widths,
17
+ parallel_coherence_decay,
18
+ ParallelExecutionError,
19
+ MemoryThresholdError,
20
+ )
21
+ from .noise_learning_fitting import (
22
+ func_to_fit,
23
+ fit_coherence_decay,
24
+ fit_noise_spectrum,
25
+ fit_coherence_decay_combined,
26
+ create_parameter_constraints,
27
+ create_coherence_parameter_constraints,
28
+ )
29
+ from .fitting_utils import (
30
+ find_widest_contiguous_stretch,
31
+ find_time_range_for_C_t_bounds,
32
+ add_gaussian_noise,
33
+ format_parameters,
34
+ create_combined_analysis_plot,
35
+ calculate_total_combinations,
36
+ bootstrap_multiple_samples,
37
+ analyze_intervals,
38
+ )
@@ -0,0 +1,428 @@
1
+ import os
2
+ import psutil
3
+ import time
4
+
5
+ import numpy as np
6
+ import numba as nb
7
+
8
+ import joblib
9
+ from joblib import Parallel, delayed, parallel_backend
10
+
11
+ from scipy.signal import find_peaks
12
+ from scipy.integrate import quad, simpson, trapezoid, IntegrationWarning
13
+
14
+ from . import filter_function as ff
15
+
16
+ # Custom Exception Classes
17
+ class ParallelExecutionError(Exception):
18
+ """Custom exception for parallel execution failures"""
19
+ pass
20
+
21
+ class MemoryThresholdError(Exception):
22
+ """Custom exception for memory threshold exceeded"""
23
+ pass
24
+
25
+ # define the function to compute coherence decay using a delta function approximation
26
+ @nb.njit(parallel=False)
27
+ def coherence_decay_profile_delta(t,noise_profile):
28
+ """
29
+ Calculate the coherence decay profile based on the provided formula, e**(-χ(t)),
30
+ under the assumption that the filter function is a delta function, (i.e. χ(t)=t*S(ω)/π).
31
+ Inputs:
32
+ noise_profile: a function that implements the noise spectrum, S(omega)
33
+ t: a float of the time variable
34
+ Output:
35
+ e**(-χ(t))
36
+ """
37
+ chi_t = t*noise_profile/np.pi
38
+ return np.exp(-chi_t)
39
+
40
+ def noise_inversion_delta(t , C_t):
41
+ """
42
+ Calculate the noise, S(ω), based on the provided formula, C(t) = 1 - e**(-χ(t)),
43
+ under the assumption that the filter function is a delta function, (i.e. χ(t)=t*S(ω)/π).
44
+ Inputs:
45
+ t: a float of the time variable
46
+ C_t: a float of the coherence decay profile at time t
47
+ Output:
48
+ S(ω)
49
+ """
50
+ return -np.pi*np.log(C_t)/t
51
+
52
+ def monitor_memory():
53
+ """Monitor current memory usage"""
54
+ process = psutil.Process(os.getpid())
55
+ return process.memory_info().rss / 1024 / 1024 # in MB
56
+
57
+ def find_k_largest_peaks(arr, k):
58
+ """
59
+ Find the k largest peaks in a 1D array.
60
+
61
+ Parameters:
62
+ -----------
63
+ arr : array-like
64
+ Input 1D array to find peaks in
65
+ k : int
66
+ Number of largest peaks to return
67
+
68
+ Returns:
69
+ --------
70
+ peak_indices : ndarray
71
+ Indices of the k largest peaks
72
+ peak_heights : ndarray
73
+ Heights of the k largest peaks
74
+ """
75
+ # Find all peaks
76
+ peaks, _ = find_peaks(arr)
77
+
78
+ # Get peak heights
79
+ peak_heights = arr[peaks]
80
+
81
+ # Sort peaks by height in descending order
82
+ sorted_peak_indices = np.argsort(peak_heights)[::-1]
83
+
84
+ # Select k largest peaks
85
+ k_largest_peak_indices = peaks[sorted_peak_indices[:k]]
86
+ k_largest_peak_heights = peak_heights[sorted_peak_indices[:k]]
87
+
88
+ return k_largest_peak_indices, k_largest_peak_heights
89
+
90
+
91
+ #####
92
+ # Note on computing C(t):
93
+ # Performing this integral is challenging due to the oscillatory nature of the integrand and the presence of sharp peaks and the log scaling of ω over which the noise can be relevant.
94
+ # In order to tackle these challenges, we employ a combination of techniques, including adaptive quadrature and careful ω selection.
95
+ # adaptive quadrature integration tends to be slow and inaccurate due to the sharp peaks. So, we reccomned using one of the included Riemann methods "trapezoid" or "simpson".
96
+ # We numerically evaluate these integrals by carefully selecting the integration points, especially around the peaks of the integrand, so that we can capture the important features of the integrand without requiring an excessively fine grid.
97
+ # We begin with a coarse grid defined by kwargs["omega_range"] and kwargs["omega_resolution"], and add to these points any singularities from the noise, S(ω), and the approximate locations of the first M peaks near ω = (2m+1)n*π/t.
98
+ # Then, we compute what the integrand would be and find the largest points in the integrand, and add kwargs["peak_resolution"] points around each of these peaks to better resolve them.
99
+ # The integrand is computed one final time, and this array is used to perform the Riemann integration.
100
+ #####
101
+
102
+
103
+ ### FYI, there are two hyperparameters that you currently can't feed into this function. "k = 10**4" and "M=100", explained below.
104
+ # You can edit this function to make them passable parameters if you wish, or just edit them directly. ###
105
+ def coherence_decay_profile_finite_peaks_with_widths(t, N, tau_p, method, noise_profile, *args, narrow_window = True, max_memory_mb=15000, **kwargs):
106
+ """Computes the integral in eq #1 in [Meneses,Wise,Pagliero,et.al. 2022] exactly, considering finite pulse widths, which computes the Coherence decay profile, C(t).
107
+ FYI, there are two hyperparameters that you currently can't feed into this function. "k = 10**4" and "M=100", explained below. You can edit this function to make them passable parameters if you wish, or just edit them directly.
108
+ Inputs:
109
+ t: a float of the time variable
110
+ N: a float of the number of pulses
111
+ tau_p: a float of the pulse width
112
+ method: a string of the method to use for integration. Options are "quad", "trapezoid", "simpson", and "log_sum_exp".
113
+ noise_profile: a function that implements the noise spectrum, S(ω), or a numpy array of the noise spectrum
114
+ *args: a list of arguments to pass to the noise_profile function
115
+ narrow_window: a boolean that determines whether to lower bound the omega range used for integration by (1/2)*(N*π/t)
116
+ **kwargs: a dictionary of keyword arguments to pass to the function
117
+ Output:
118
+ e**(-χ(t)): The coherence decay profile, C(t), the omega values used in the integration, the filter function values, and the integrand values.
119
+ omega_values: a numpy array of angular frequencies
120
+ filter_values: a numpy array of the filter function values, evaluated at omega_values
121
+ integrand: a numpy array of the integrand values given by S(ω)*F(ω*t)/(2*π*ω**2), evaluated at omega_values
122
+ """
123
+ if not (isinstance(N, int) and N > 0):
124
+ raise TypeError(f"N must be a positive integer, got {type(N).__name__}")
125
+
126
+ if kwargs.get('num_peaks_cutoff'):
127
+ if not (isinstance(kwargs['num_peaks_cutoff'], int) and kwargs['num_peaks_cutoff'] >= 0):
128
+ raise TypeError(f"num_peaks_cutoff must be a positive integer, got {type(kwargs['num_peaks_cutoff']).__name__}")
129
+
130
+ current_memory = monitor_memory()
131
+ if current_memory > max_memory_mb:
132
+ raise MemoryError(f"Memory usage ({current_memory}MB) exceeded threshold")
133
+
134
+ # If narrow_window is enabled, adjust the omega range so that the lower bound is set to 0.5*N*np.pi/t. This avoids wasting numerical points
135
+ # at low frequencies, where the filter function is small. Disable narrow_window if you want full control over the integration window in ω.
136
+ if narrow_window:
137
+ old_range = kwargs.get("omega_range") # Default range if not provided
138
+ kwargs["omega_range"] = (0.5*N*np.pi/t,old_range[1]) # Set the lower bound of the omega range to 0.5*N*np.pi/tau_p
139
+
140
+ if callable(noise_profile): # If noise_profile is a function
141
+ try:
142
+ omegas = np.logspace(np.log10(kwargs.get("omega_range")[0]), np.log10(kwargs.get("omega_range")[1]), 10**7) # setting default resolution to 10^7 points. Might be a little overkill
143
+ except KeyError:
144
+ omegas = np.logspace(-4, 8, 10**7) # setting resolution to 10^7 points. Might be a little overkill
145
+
146
+ S_w = noise_profile(omegas, *args) # Check that the noise_profile function works with the omega_values
147
+
148
+ # First, we identify the k largest values. k is arbitrary, and you can increase it if you want more resolution.
149
+ k = 10**4
150
+ # Find the k largest values.
151
+ # k_largest_values = np.partition(S_w, -k)[-k:] # not needed, but included in case you need it for something else.
152
+ k_largest_indices = np.argpartition(S_w, -k)[-k:]
153
+
154
+ local_maxima_indices, _ = find_peaks(S_w)
155
+ local_minima_indices, _ = find_peaks(-S_w)
156
+
157
+ points_of_interest = np.unique(np.concatenate((omegas[local_maxima_indices], omegas[local_minima_indices], omegas[k_largest_indices])))
158
+ # print(len(points_of_interest))
159
+
160
+ # Find the peaks of the filter function, located at approximately ω = (2m+1)n*π/t for integer m
161
+ base = np.pi * N / t
162
+
163
+ M = 100
164
+ omega_peaks = np.array([base * (2*m + 1) for m in range(M)]) # consider the first 100 FF resonance peaks.
165
+ # 100 peaks is an arbitrary choice here. You can include more peaks if you want more resolution.
166
+
167
+ # Check for additional FF resonance peaks near the points of interest on the noise
168
+ # additional_filter_peaks = []
169
+ # for point in points_of_interest:
170
+ # k = round((point/base - 1)/2)
171
+ # additional_filter_peaks.append(base * (2*k + 1))
172
+ # additional_filter_peaks = np.array(additional_filter_peaks)
173
+
174
+ k_values = np.round((points_of_interest/base - 1)/2).astype(int)
175
+ additional_filter_peaks = base * (2*k_values + 1)
176
+
177
+ try:
178
+ omega_sweep = np.logspace(np.log10(kwargs.get("omega_range")[0]), np.log10(kwargs.get("omega_range")[1]), kwargs.get("omega_resolution",10**5))
179
+ except KeyError:
180
+ omega_sweep = np.logspace(-4, 8, kwargs.get("omega_resolution",10**5))
181
+
182
+ breakpoints = np.unique(np.concatenate((omega_peaks, additional_filter_peaks, points_of_interest)))
183
+ omega_values = np.unique(np.concatenate((breakpoints, omega_sweep)))
184
+
185
+ filter_values = ff.filter_function_finite(omega_values, N, t, tau_p)
186
+
187
+ integrand = np.multiply(filter_values, np.divide(noise_profile(omega_values, *args),(np.power(omega_values,2))))
188
+
189
+ # Now that we've computed the integrand once, we find the largest peaks in the integrand, which will contribute most to the integral.
190
+ largest_peak_indices, _ = find_k_largest_peaks(integrand, kwargs.get('num_peaks_cutoff', len(omega_values)))
191
+
192
+ # for each of these peaks, we will add kwargs["peak_resolution"] additional ω points around them for more precise integration.
193
+ if list(largest_peak_indices): # If there are singularities in the filter function within the specified omega range
194
+ additional_omega_values = np.array([])
195
+ for index in largest_peak_indices:
196
+ if kwargs.get("peak_resolution"):
197
+ # Joonhee says that the width of the peaks should scale as O(1/N)
198
+ # Using 1/N here is not percise, for low values of N, it can lead to negative values of omega being added to omega_values (i.e. peak_width/2 > peak), which I
199
+ # correct for by removing negative values below. A more sophisticated approach would be to use a peak width that scales with N.
200
+ peak = omega_values[index]
201
+ peak_width = 1/N # Width in indices
202
+
203
+ # Otherwise, add additional points around the peak, if no resolution is specified, use 10 points.
204
+ peak_res = kwargs.get("peak_resolution", 10)
205
+
206
+ # You could opt to use a logspace around the peak, but I went with a linear space for now. Have to fix issue where peak_width/2 > peak, which would require a
207
+ # more sophisticated approach to setting peak_width.
208
+ # additional_omega_values.append(omega_values, np.logspace(np.log10(peak-peak*peak_width), np.log10(peak+peak*peak_width), peak_res)) # uncomment if you wish to use logspace
209
+ additional_omega_values = np.append(additional_omega_values,np.linspace(peak-peak*peak_width, peak+peak*peak_width, peak_res))
210
+
211
+ additional_omega_values = additional_omega_values[additional_omega_values > 0] # Remove non-positive values to be safe. Important when N=1 to avoid devide by zero errors.
212
+ additional_filter_values = ff.filter_function_finite(additional_omega_values, N, t, tau_p)
213
+ additional_integrand = np.multiply(additional_filter_values, np.divide(noise_profile(additional_omega_values, *args),(np.power(additional_omega_values,2))))
214
+
215
+ # Concatenate and get unique, sorted omega values
216
+ merged_omega_values = np.sort(np.unique(np.concatenate((omega_values, additional_omega_values))))
217
+ # Create merged filter values array
218
+ merged_integrand = np.zeros_like(merged_omega_values, dtype=integrand.dtype)
219
+ merged_filter_values = np.zeros_like(merged_omega_values, dtype=filter_values.dtype)
220
+
221
+ # Find indices of original omega values in merged array
222
+ original_indices = np.searchsorted(merged_omega_values, omega_values)
223
+ merged_integrand[original_indices] = integrand
224
+ merged_filter_values[original_indices] = filter_values
225
+
226
+ # Find indices of new omega values in merged array
227
+ new_indices = np.searchsorted(merged_omega_values, additional_omega_values)
228
+ merged_integrand[new_indices] = additional_integrand
229
+ merged_filter_values[new_indices] = additional_filter_values
230
+
231
+ omega_values = merged_omega_values
232
+ integrand = merged_integrand
233
+ filter_values = merged_filter_values
234
+
235
+ # print("Range of Omega Values: ", np.format_float_scientific(np.min(omega_values)), np.format_float_scientific(np.max(omega_values)))
236
+ else: # If the noise_profile the user specified is not a function, (i.e. tt's an array of the noise values at certain frequencies)
237
+ # In this case, we don't get to re-define the frequency values to better evaluate the integral. Instead, we have to take the frequency points the user gives us.
238
+ # The default is to assume the frequency values are taken in logspace, and defined by kwargs["omega_range"] and kwargs["omega_resolution"]. If this is not the case, you will need to specify
239
+ # omega_values directly below.
240
+ try:
241
+ S_w = np.array(noise_profile)
242
+ if not np.issubdtype(S_w.dtype, np.number):
243
+ raise ValueError("The noise_profile must be a numerical object broadcastable numpy array.")
244
+ except Exception as e:
245
+ raise ValueError("The noise_profile must be a function or a numerical object broadcastable numpy array.") from e
246
+
247
+ try:
248
+ omega_values = np.logspace(np.log10(kwargs.get("omega_range")[0]), np.log10(kwargs.get("omega_range")[1]), kwargs.get("omega_resolution",len(S_w)))
249
+ except KeyError:
250
+ omega_values = np.logspace(-4, 8, kwargs.get("omega_resolution",len(S_w)))
251
+
252
+ filter_values = ff.filter_function_finite(omega_values, N, t, tau_p)
253
+ integrand = np.multiply(filter_values, np.divide(S_w, (np.power(omega_values,2))))
254
+
255
+ if method == "quad":
256
+ if not callable(noise_profile):
257
+ raise ValueError("The noise_profile must be a function when using the quad method. Try using the trapezoid or simpson method instead.")
258
+
259
+ # for the scipy adaptive quadrature, we need to provide the integrand as a function of omega
260
+ integrand = lambda omega: (noise_profile(omega, *args) * ff.filter_function_finite(omega, N, t, tau_p)) / np.power(omega,2)
261
+ # Integrate the integrand over the specified omega range using a maximum of "limit" subdivisions in the adaptive alogrithm,
262
+ # and using the omega_peaks as break points where sigularities occur.
263
+
264
+ # with warnings.catch_warnings():
265
+ # warnings.filterwarnings('ignore', category=IntegrationWarning)
266
+ chi_t = 0
267
+ chi_t_i, _ = quad(integrand, 0, breakpoints[0],
268
+ limit=kwargs.get('quad_limit',len(breakpoints)+10),
269
+ points=[0,breakpoints[0]],
270
+ epsabs=kwargs.get('epsabs', 1.49e-8),
271
+ epsrel=kwargs.get('epsrel', 1.49e-8))
272
+ chi_t += chi_t_i
273
+ for i in range(len(breakpoints)-1):
274
+ chi_t_i, _ = quad(integrand, breakpoints[i], breakpoints[i+1],
275
+ limit=kwargs.get('quad_limit',len(breakpoints)+10),
276
+ points=[breakpoints[i],breakpoints[i+1]],
277
+ epsabs=kwargs.get('epsabs', 1.49e-8),
278
+ epsrel=kwargs.get('epsrel', 1.49e-8))
279
+ chi_t += chi_t_i
280
+
281
+ chi_t = chi_t/(np.pi)
282
+
283
+ return np.exp(-chi_t), omega_values, filter_values, integrand(omega_values)/(np.pi) # No omega_values, filter_values, or integrand for quad method
284
+ else:
285
+
286
+ # integrand = np.multiply(filter_values, np.divide(noise_profile(omega_values, *args),(np.power(omega_values,2))))
287
+
288
+ # I'm using the "trapezoid" method because it's error has better scaling with the size of the step size of x values, which
289
+ # is large since we are using log spacing for points.
290
+ if method == "simpson":
291
+ chi_t = simpson(integrand, x=omega_values)
292
+ elif method == "trapezoid":
293
+ chi_t = trapezoid(integrand, x=omega_values)
294
+ else:
295
+ raise ValueError(f"Unknown method: {method}")
296
+
297
+
298
+ chi_t = chi_t/(np.pi)
299
+
300
+ return np.exp(-chi_t), omega_values, filter_values, integrand/(np.pi)
301
+
302
+
303
+ ###
304
+ # The rest of the functions in this document implement CPU parallelism for computing the coherence profile, C(t), via coherence_decay_profile_finite_peaks_with_widths
305
+ # Since, in general, we want to compute C(t) over an array of timepoints, this can be computationally annoying as the filter function, F(omega,t) changes for each time point,
306
+ # so, we must re-compute the integral for each of these timepoints. Fortunately, these computations are independent of one another, so we can parallelize across these timepoints, and
307
+ # use every CPU available to us, rather than just one.
308
+ ###
309
+
310
+ def retry_parallel_execution(func=None, max_retries=None, initial_n_jobs=None, min_n_jobs=1):
311
+ """
312
+ Decorator that implements retry logic with CPU reduction for parallel execution.
313
+ Can be used with or without parameters.
314
+
315
+ Args:
316
+ func: The function to wrap
317
+ max_retries: Maximum number of retry attempts (default: use all available CPUs down to min_n_jobs)
318
+ initial_n_jobs: Initial number of CPUs to use (default: all available - 1)
319
+ min_n_jobs: Minimum number of CPUs to try before giving up (default: 1)
320
+ """
321
+ def decorator(f):
322
+ def wrapper(*args, **kwargs):
323
+ # Allow overriding initial_n_jobs through function kwargs
324
+ n_jobs = kwargs.pop('initial_n_jobs', None) or initial_n_jobs or max(1, os.cpu_count())
325
+ if max_retries is None:
326
+ max_retry_attempts = n_jobs - min_n_jobs + 1
327
+ else:
328
+ max_retry_attempts = max_retries
329
+
330
+ attempt = 0
331
+ current_n_jobs = n_jobs
332
+
333
+ while attempt < max_retry_attempts and current_n_jobs >= min_n_jobs:
334
+ try:
335
+ kwargs['n_jobs'] = current_n_jobs
336
+ # print(f"\nAttempting execution with {current_n_jobs} CPU{'s' if current_n_jobs > 1 else ''}...")
337
+ result = f(*args, **kwargs)
338
+ # print(f"Successfully completed using {current_n_jobs} CPU{'s' if current_n_jobs > 1 else ''}")
339
+ return result
340
+
341
+ except Exception as e:
342
+ if "SIGKILL" in str(e) or isinstance(e, MemoryError):
343
+ attempt += 1
344
+ current_n_jobs -= 1
345
+
346
+ if current_n_jobs >= min_n_jobs:
347
+ print(f"\nExecution failed with {current_n_jobs + 1} CPUs: {str(e)}")
348
+ print(f"Retrying with {current_n_jobs} CPU{'s' if current_n_jobs > 1 else ''}...")
349
+ time.sleep(2) # Give system time to clean up resources
350
+ else:
351
+ print("\nReached minimum CPU count. Raising error.")
352
+ raise ParallelExecutionError(
353
+ f"Failed to execute even with minimum {min_n_jobs} CPU(s). Last error: {str(e)}"
354
+ )
355
+ else:
356
+ # If it's not a SIGKILL or MemoryError, re-raise the original exception
357
+ raise
358
+
359
+ raise ParallelExecutionError(
360
+ f"Exceeded maximum retry attempts ({max_retry_attempts}) or minimum CPU count reached"
361
+ )
362
+ return wrapper
363
+
364
+ if func is None:
365
+ return decorator
366
+ return decorator(func)
367
+
368
+ @retry_parallel_execution
369
+ def parallel_coherence_decay(times, N, tau_p, method, noise_profile, *args, n_jobs=None, batch_size=None, narrow_window = True, max_memory_per_worker=1000, **kwargs):
370
+ """
371
+ Parallelized version of coherence_decay_profile_finite_peaks_with_widths. Computes the coherence decay profile, C(t), over an array of timepoints.
372
+ The function defaults to using all available CPUs (n_jobs=None). You can change this parameter to use a specific number of CPUs.
373
+ Inputs:
374
+ times: a numpy array of time values
375
+ n: a float of the number of pulses
376
+ tau_p: a float of the pulse width
377
+ method: a string of the method to use for integration. Options are "quad", "trapezoid", "simpson", and "log_sum_exp".
378
+ noise_profile: a function that implements the noise spectrum, S(omega)
379
+ *args: a list of arguments to pass to the noise_profile function
380
+ n_jobs: an int of the number of CPUs to use for computation. Default is None, which uses all available CPUs.
381
+ **kwargs: a dictionary of keyword arguments to pass to the function
382
+ Output:
383
+ C_t: a numpy array of the coherence decay profile, C(t)
384
+ omega_values_list: a list of numpy arrays of angular frequencies used in the integration
385
+ filter_values_list: a list of numpy arrays of the filter function values, evaluated at omega_values
386
+ integrand_list: a list of numpy arrays of the integrand values given by S(omega)*F(omega*t)/(2*pi*omega**2), evaluated at omega_values
387
+ """
388
+ if not isinstance(N, int):
389
+ raise TypeError(f"N must be an integer, got {type(N).__name__}")
390
+
391
+ if n_jobs is None:
392
+ n_jobs = -1 # -1 means using all available CPUs
393
+
394
+ if batch_size is None:
395
+ batch_size = max(1, len(times) // (n_jobs * 4)) # Divide work into smaller chunks
396
+
397
+ # print(n_jobs)
398
+ # if n_jobs >1:
399
+ # print(f"Using {n_jobs} CPUs with batch size {batch_size}")
400
+ # elif n_jobs < 0:
401
+ # print(f"Using {os.cpu_count() + n_jobs + 1} CPU with batch size {batch_size}")
402
+
403
+ time_batches = [times[i:i + batch_size] for i in range(0, len(times), batch_size)]
404
+
405
+ def process_batch(batch):
406
+ return [coherence_decay_profile_finite_peaks_with_widths(
407
+ t, N, tau_p, method, noise_profile, *args,
408
+ narrow_window = narrow_window, max_memory_mb=max_memory_per_worker, **kwargs)
409
+ for t in batch]
410
+
411
+ with parallel_backend('loky', n_jobs=n_jobs):
412
+ # with parallel_backend('loky', n_jobs=n_jobs, inner_max_num_threads=1): # Use this line if you are using numpy or scipy functions that are not thread-safe
413
+ results_nested = Parallel(verbose=0)(
414
+ delayed(process_batch)(batch)
415
+ # for batch in tqdm(time_batches, desc="Processing batches")
416
+ for batch in time_batches
417
+ )
418
+
419
+ # Flatten results and filter out None values
420
+ results = [r for batch in results_nested for r in batch if r is not None]
421
+
422
+ if not results:
423
+ raise RuntimeError("No valid results were produced")
424
+
425
+ # Unpack the results
426
+ C_t, omega_values_list, filter_values_list, integrand_list = zip(*results)
427
+
428
+ return (np.array(C_t), omega_values_list, filter_values_list, integrand_list)
@@ -0,0 +1,87 @@
1
+ import numpy as np
2
+ import numba as nb
3
+
4
+ @nb.njit(parallel=False)
5
+ def filter_function_approx(omega, N, T):
6
+ '''Implemnents an approximation of the filter function, F(omega*t) (eq #2 in [Meneses,Wise,Pagliero,et.al. 2022]).
7
+ Approximation assumes pi-pulse are instantaneous (i.e. tau_pi = 0). Note, this function has singular points at (2m+1)n*Pi/t for integer m.
8
+ Inputs:
9
+ omega: a numpy array of angular frequencies
10
+ N: a float of the number of pulses
11
+ T: a float of the total time of the experiment
12
+ Output:
13
+ F(omega*t): a numpy array
14
+ '''
15
+ if not isinstance(N, int):
16
+ raise TypeError(f"N must be an integer, got {type(N).__name__}")
17
+
18
+ if N % 2 == 0:
19
+ return 16 * (np.sin((omega*T) / 2) ** 2) * (np.sin((omega*T)/(4*N))**4) / (np.cos((omega*T)/(2*N))**2)
20
+ else:
21
+ return 16 * (np.cos((omega*T) / 2) ** 2) * (np.sin((omega*T)/(4*N))**4) / (np.cos((omega*T)/(2*N))**2)
22
+
23
+ @nb.njit(parallel=False)
24
+ def numba_complex_sum(t_k, omega):
25
+ result = np.zeros_like(omega, dtype=np.complex128)
26
+ for i in range(len(omega)):
27
+ for k in range(len(t_k)):
28
+ result[i] += ((-1)**(k+1))*np.exp(1j * omega[i] * t_k[k])
29
+ return result
30
+
31
+ def filter_function_finite(omega, N, T, tau_p, method='numba'):
32
+ ''' Implements the filter function, F(omega*t) (eq #1 in the paper).
33
+ Inputs:
34
+ omega: a numpy array of angular frequencies
35
+ N: a float of the number of pulses
36
+ T: a float of the total time for the experiment
37
+ tau_p: a float of the pulse width
38
+ Output:
39
+ F(omega*t): a numpy array
40
+ '''
41
+ # Convert single number to array if needed
42
+ single_number = isinstance(omega, (int, float))
43
+ if single_number:
44
+ omega = np.array([omega])
45
+
46
+ if T < N * tau_p:
47
+ raise ValueError("The total time of the experiment is less than the total time of the pulses.")
48
+ else:
49
+ # t_k = np.linspace((T/N-tau_p)/2, T*(1-1/(2*N))-(tau_p/2), N) # Pulses are evenly spaced. This is the time of beginning of each pulse. This should not be used, but I'm leaving in as a comment in case you need the beggining pulse timings for something else.
50
+ t_k = T/(2*N)*np.arange(1,2*N+1,2) # Pulses are evenly spaced. This is the middle of the each pulse. Alternatively, np.linspace((T/N)/2, T*(1-1/(2*N)), N)
51
+ if (t_k<0).any():
52
+ raise ValueError("One of the Pulse start times is negative. This is not allowed.")
53
+
54
+ if method == 'numba':
55
+ # Uses numba to speed up the sum. Should be the fastest implementation.
56
+ # Doesn't store the intermediate results, so it's memory efficient.
57
+ sum_term = numba_complex_sum(t_k, omega)
58
+ elif method == 'numpy_array':
59
+ # Uses numpy array broadcasting to vectorize the sum.
60
+ # A little faster than a summation loop, and more numerically stable, but has a higher memory overhead.
61
+ sum_array = np.exp(1j * omega[:, np.newaxis] * t_k)
62
+ sum_array[:,::2] *= -1 # Adds negative sign to odd indices of t_k
63
+ # sum_array.sort(axis=1)
64
+ sum_term = np.sum(sum_array, axis=1)
65
+ elif method == "numpy":
66
+ # Uses a for loop to sum the terms. Slowest implementation, but more memory efficient than the array version.
67
+ # You can instead sum the positive and negative terms separately, then subtract them, for a slightly more numerically stable result (see below).
68
+
69
+ # neg_sum_term = np.zeros(omega.shape)
70
+ # pos_sum_term = np.zeros(omega.shape)
71
+ # for k in range(0,N,2):
72
+ # # neg_sum_term = neg_sum_term - np.exp(1j * omega * t_k[k])
73
+ # neg_sum_term = np.sum(np.vstack((neg_sum_term,np.exp(1j * omega * t_k[k]))),axis=0)
74
+
75
+ # for k in range(1,N+1,2):
76
+ # # pos_sum_term = pos_sum_term + np.exp(1j * omega * t_k[k])
77
+ # pos_sum_term = np.sum(np.vstack((pos_sum_term,np.exp(1j * omega * t_k[k]))),axis=0)
78
+
79
+ # sum_term = (pos_sum_term-neg_sum_term)
80
+
81
+ sum_term = np.zeros(omega.shape, dtype=np.complex128)
82
+ for k in range(N):
83
+ sum_term += ((-1)**(k+1)) * np.exp(1j * omega * t_k[k])
84
+
85
+ result = np.power(np.abs(1 + np.power(-1,N+1) * np.exp(1j * omega * T) + 2 * np.cos(omega * tau_p / 2) * sum_term),2)
86
+
87
+ return result