1+ from __future__ import annotations
2+
13import numpy as np
24import xarray as xr
35
4- def central_difference (S , num_records = None , normalize = True ):
6+ from dataclasses import dataclass
7+ from typing import Optional , Union
8+
9+
10+ def central_difference (S , num_records = None , normalize = True , baseline_to_zero = True ):
511
612 """
713 Compute fourth order derivative S'(t) using the
@@ -23,6 +29,8 @@ def central_difference(S, num_records=None, normalize=True):
2329 normalize: bool
2430 If True, normalize the derivative by the scattering signal
2531 S(t) to get (1/S) * dS/dt.
32+ baseline_to_zero: bool
33+ If True, shift each record's minimum to zero before differentiation.
2634
2735 Returns
2836 -------
@@ -39,6 +47,11 @@ def central_difference(S, num_records=None, normalize=True):
3947
4048 for ch in channels :
4149 y = S [ch ].isel (event_index = slice (0 , num_records )).values
50+
51+ # Baseline shift: make each record's minimum be 0
52+ if baseline_to_zero :
53+ y_min = np .nanmin (y , axis = 1 , keepdims = True ) # shape (n_records, 1)
54+ y = y - y_min
4255 d = np .full_like (y , np .nan , dtype = np .float64 )
4356
4457 # Interior points (vectorized)
@@ -105,15 +118,330 @@ def plot_normalized_derivative(ds, record_no, chn=0):
105118 spectra ['Data_ch' + str (chn )].values [np .newaxis , :],
106119 dims = ['time' , 'bins' ])
107120 inp_data = xr .Dataset (inp_data )
108- bins = np .linspace (0 , 100 , 100 )
121+
122+ bins = np .arange (0 , 0.00004 - 0.3e-6 , 0.4e-6 ) # 0 to 0.0004 microseconds in steps of 0.4e-6 seconds
123+ bins = bins * 1e6 # convert to microseconds for plotting
109124
110125 ch_name = f'Data_ch{ chn } '
111126 plt .figure (figsize = (10 , 6 ))
112127 ax = plt .gca ()
113- inp_data [ch_name ].plot (ax = ax )
128+ # Plot using bins for x-axis
129+ ax .plot (bins , spectra ['Data_ch' + str (chn )].values , label = ch_name )
130+ ax .set_xlim ([bins [0 ], bins [- 1 ]])
114131 ax .set_title (f'Normalized Derivative of Scattering Signal - Channel { chn } Record { record_no } ' )
115- ax .set_xlabel ('Time (s)' )
132+ ax .set_xlabel ('Time ($\mu$ s)' )
116133 ax .set_ylabel ('Normalized Derivative' )
117134 plt .grid ()
135+ ax .legend ()
118136
119137 return ax
138+
139+
140+ @dataclass (frozen = True )
141+ class MLEConfig :
142+ """
143+ Configuration parameters for Maximum Likelihood Estimation (MLE)
144+ of the Moteki & Kondo normalized derivative method.
145+
146+ Parameters
147+ ----------
148+ h : float
149+ Time resolution of the scattering signal (instrument specification).
150+ sigma_bar : float
151+ Mean value of the noise standard deviation (measured value).
152+ delta_sigma : float
153+ Increment for the noise standard deviation (measured value).
154+ A1 : float
155+ Coefficient for the first term in the model (determined experimentally).
156+ A2 : float
157+ Coefficient for the second term in the model (determined experimentally).
158+ A3 : float
159+ Coefficient for the third term in the model (determined experimentally).
160+ grid_size : int, default=401
161+ Number of grid points for tau estimation.
162+ grid_margin : float, default=0.5
163+ Margin for the tau grid as a fraction of the range.
164+ """
165+ h : float
166+ sigma_bar : float
167+ delta_sigma : float
168+ A1 : float
169+ A2 : float
170+ A3 : float
171+ grid_size : int = 401
172+ grid_margin : float = 0.5
173+
174+ def mle_tau_moteki_kondo (
175+ S : Union [xr .DataArray , xr .Dataset ],
176+ norm_deriv : Union [xr .DataArray , xr .Dataset ],
177+ p : int ,
178+ * ,
179+ ch : Optional [str ] = None ,
180+ event_index : int ,
181+ event_dim : str = "event_index" ,
182+ S_sample_dim : Optional [str ] = None ,
183+ y_sample_dim : Optional [str ] = None ,
184+ tau_grid : Optional [Union [np .ndarray , xr .DataArray ]] = None ,
185+ k_end : Optional [int ] = None ,
186+ config : Optional [MLEConfig ] = None ,
187+ ) -> xr .DataArray :
188+ """
189+ Estimate tau_hat using the Moteki & Kondo grid-search MLE.
190+
191+ Parameters
192+ ----------
193+ S : xr.DataArray or xr.Dataset
194+ Scattering signal.
195+ norm_deriv : xr.DataArray or xr.Dataset
196+ Normalized derivative.
197+ p : int
198+ Number of consecutive points in each k-subset.
199+ ch : str, optional
200+ Variable to select when S and/or norm_deriv are Datasets.
201+ Required if a Dataset contains multiple variables and no unique choice exists.
202+ event_index : int
203+ Event index to select. This function returns tau_hat(k) for one event only.
204+ event_dim : str
205+ Name of event dimension.
206+ S_sample_dim : str, optional
207+ Sample dimension in S.
208+ y_sample_dim : str, optional
209+ Sample dimension in norm_deriv.
210+ tau_grid : 1D array-like, optional
211+ Global tau grid for all subsets.
212+ k_end : int, optional
213+ Largest starting k.
214+ config : MLEConfig
215+ Calibration / noise / grid settings.
216+ """
217+ if config is None :
218+ raise ValueError ("config must be provided." )
219+
220+ def _to_dataarray (obj : Union [xr .DataArray , xr .Dataset ], name : str ) -> xr .DataArray :
221+ """
222+ Accept either a DataArray or Dataset.
223+ If a Dataset is provided, select the variable named `ch`.
224+ """
225+ if isinstance (obj , xr .DataArray ):
226+ return obj
227+ if isinstance (obj , xr .Dataset ):
228+ if ch is not None :
229+ # Use the user input channel.
230+ if ch not in obj .data_vars :
231+ raise ValueError (
232+ f"{ ch !r} not found in { name } .data_vars={ list (obj .data_vars )} "
233+ )
234+ return obj [ch ]
235+ if len (obj .data_vars ) == 1 :
236+ only_var = next (iter (obj .data_vars ))
237+ return obj [only_var ]
238+ raise ValueError (
239+ f"{ name } is a Dataset with multiple variables. "
240+ f"Provide ch. Available: { list (obj .data_vars )} "
241+ )
242+ raise TypeError (f"{ name } must be an xarray DataArray or Dataset." )
243+
244+ # Convert datasets to the selected DataArrays.
245+ S = _to_dataarray (S , "S" )
246+ norm_deriv = _to_dataarray (norm_deriv , "norm_deriv" )
247+
248+ # The method requires one event axis and one sample axis.
249+ if event_dim not in S .dims :
250+ raise ValueError (f"{ event_dim !r} not found in S.dims={ S .dims } " )
251+ if event_dim not in norm_deriv .dims :
252+ raise ValueError (f"{ event_dim !r} not found in norm_deriv.dims={ norm_deriv .dims } " )
253+
254+ # Infer the sample dimension if the user did not specify it.
255+ if S_sample_dim is None :
256+ s_non_event_dims = [d for d in S .dims if d != event_dim ]
257+ if len (s_non_event_dims ) != 1 :
258+ raise ValueError (
259+ f"Could not infer S sample dim. Non-event dims in S: { s_non_event_dims } "
260+ )
261+ S_sample_dim = s_non_event_dims [0 ]
262+
263+ if y_sample_dim is None :
264+ y_non_event_dims = [d for d in norm_deriv .dims if d != event_dim ]
265+ if len (y_non_event_dims ) != 1 :
266+ raise ValueError (
267+ f"Could not infer norm_deriv sample dim. Non-event dims in norm_deriv: { y_non_event_dims } "
268+ )
269+ y_sample_dim = y_non_event_dims [0 ]
270+
271+ # Rename the sample dimensions to a common internal name.
272+ S_std = S .rename ({S_sample_dim : "sample" })
273+ y_std = norm_deriv .rename ({y_sample_dim : "sample" })
274+
275+ # Align the arrays so the same event/sample positions are used in both inputs.
276+ S_std , y_std = xr .align (S_std , y_std , join = "inner" )
277+
278+ if event_index < 0 or event_index >= S_std .sizes [event_dim ]:
279+ raise ValueError (
280+ f"event_index must be in [0, { S_std .sizes [event_dim ] - 1 } ], got { event_index } "
281+ )
282+
283+ n_samples = S_std .sizes ["sample" ]
284+
285+ if p < 2 or p > n_samples :
286+ raise ValueError (f"p must be in [2, { n_samples } ], got { p } " )
287+
288+ if k_end is None :
289+ k_end = n_samples - p
290+ if k_end < 0 or k_end > n_samples - p :
291+ raise ValueError (f"k_end must be in [0, { n_samples - p } ], got { k_end } " )
292+
293+ # Optional tau grid for the 1D grid search in tau.
294+ # Moteki & Kondo determine tau numerically by maximizing L_k(tau).
295+ if tau_grid is not None :
296+ tau_grid_np = np .asarray (
297+ tau_grid .data if isinstance (tau_grid , xr .DataArray ) else tau_grid ,
298+ dtype = float ,
299+ )
300+ if tau_grid_np .ndim != 1 :
301+ raise ValueError ("tau_grid must be 1D." )
302+ else :
303+ tau_grid_np = None
304+
305+ # Parameters from Appendix A.
306+ h = float (config .h )
307+ sigma_bar = float (config .sigma_bar )
308+ delta_sigma = float (config .delta_sigma )
309+ A1 , A2 , A3 = float (config .A1 ), float (config .A2 ), float (config .A3 )
310+
311+ # Time axis used in the fit.
312+ # Here we use physical time spacing h so tk is in seconds (or whatever unit h uses).
313+ # This must match sigma_bar and delta_sigma units.
314+ t = np .arange (n_samples ) * h
315+
316+ if h <= 0 :
317+ raise ValueError ("config.h must be positive." )
318+ if sigma_bar <= 0 :
319+ raise ValueError ("config.sigma_bar must be positive." )
320+ if delta_sigma < 0 :
321+ raise ValueError ("config.delta_sigma must be >= 0." )
322+
323+ # Eq. (A.7): finite-difference amplification factor for the derivative noise.
324+ Af_d = np .sqrt (130.0 ) / 12.0
325+
326+ def _logL_for_tau (yk : np .ndarray , sk : np .ndarray , tk : np .ndarray , tau : float ) -> float :
327+ """
328+ Log-likelihood for one k-subset and one candidate tau.
329+
330+ Mean model:
331+ ybar_i(tau) = -(t_i - tau) / sigma_bar^2 [Eq. (A.4)]
332+ where y_i = S'_i / S_i.
333+
334+ Covariance:
335+ Cov[y_i, y_j] = 4 / sigma_bar^6 * (t_i - tau)(t_j - tau) * (delta_sigma)^2 [Eq. (A.10a)]
336+ Var[y_i] = 4 / sigma_bar^6 * (t_i - tau)^2 * (delta_sigma)^2
337+ + (Af_d^2 / h^2) * (1/S_i^2) * (delta S_i)^2 [Eq. (A.10b)]
338+ with
339+ delta S_i = sqrt(A1^2 + A2^2 S_i + A3^2 S_i^2) [Eq. (A.6)]
340+ and
341+ (delta y_i)_ran = Af_d * (1/h) * (1/S_i) * delta S_i [Eq. (A.7)]
342+
343+ The full likelihood is the multivariate Gaussian in Eq. (A.9).
344+ """
345+ # Mean vector of the normalized derivative under the Gaussian beam model.
346+ # This is the line I'/I = -(t - tau)/sigma^2 [Eq. (5)] used as the mean [Eq. (A.4)].
347+ ybar = - (tk - tau ) / (sigma_bar * sigma_bar )
348+
349+ # Signal-noise amplitude from Appendix A [Eq. (A.6)].
350+ deltaS = np .sqrt (A1 * A1 + (A2 * A2 ) * sk + (A3 * A3 ) * (sk * sk ))
351+
352+ # Random variance of y = S'/S from finite-difference error propagation [Eq. (A.7)].
353+ with np .errstate (divide = "ignore" , invalid = "ignore" ):
354+ var_rand_k = (Af_d * Af_d ) / (h * h ) * (deltaS * deltaS ) / (sk * sk )
355+
356+ # If any term is non-finite, this tau candidate is unusable.
357+ if not np .all (np .isfinite (var_rand_k )):
358+ return - np .inf
359+ if np .any (var_rand_k <= 0 ):
360+ return - np .inf
361+
362+ # Systematic covariance from particle-by-particle fluctuations in sigma [Eq. (A.10a)].
363+ dt = (tk - tau ).reshape (- 1 , 1 )
364+ sys_pref = 4.0 * (delta_sigma * delta_sigma ) / (sigma_bar ** 6 )
365+ Sigma = sys_pref * (dt @ dt .T )
366+ # Add the diagonal random variance term [Eq. (A.10b)].
367+ Sigma [np .diag_indices_from (Sigma )] += var_rand_k
368+
369+ # Residual vector y - ybar.
370+ r = yk - ybar
371+
372+ # Use Cholesky factorization for numerical stability when evaluating Eq. (A.9).
373+ try :
374+ L = np .linalg .cholesky (Sigma )
375+ except np .linalg .LinAlgError :
376+ return - np .inf
377+
378+ # Compute statistical distance.
379+ # d^2 = (y - ybar)^T Sigma^{-1} (y - ybar) [Eq. (A.11)]
380+ z = np .linalg .solve (L , r )
381+ d2 = float (z .T @ z )
382+ # log |Sigma| from the Cholesky factor.
383+ logdet = 2.0 * np .sum (np .log (np .diag (L )))
384+
385+ # Multivariate normal log-likelihood [Eq. (A.9)].
386+ p_local = yk .size
387+ return float (- 0.5 * (p_local * np .log (2.0 * np .pi ) + logdet + d2 ))
388+
389+ def _tau_hat_for_one_event (s_event : np .ndarray , y_event : np .ndarray ) -> np .ndarray :
390+ """
391+ For one event, scan all k-subsets of length p and return tau_hat(k).
392+ """
393+ tau_hat = np .full (k_end + 1 , np .nan , dtype = float )
394+
395+ # Skip events with missing values.
396+ if not (np .all (np .isfinite (s_event )) and np .all (np .isfinite (y_event ))):
397+ return tau_hat
398+
399+ for k in range (k_end + 1 ):
400+ # Consecutive p-point subset starting at k.
401+ # This is the subset over which Moteki & Kondo search for the leading-edge
402+ # segment that best matches I'/I [Appendix A.5].
403+ yk = y_event [k : k + p ]
404+ sk = s_event [k : k + p ]
405+ tk = t [k : k + p ]
406+
407+ if not (np .all (np .isfinite (yk )) and np .all (np .isfinite (sk ))):
408+ continue
409+
410+ # If the user did not supply a global tau grid, build a local grid for this k.
411+ if tau_grid_np is None :
412+ span = float (tk [- 1 ] - tk [0 ])
413+ margin = config .grid_margin * (span + h )
414+ grid = np .linspace (tk [0 ] - margin , tk [- 1 ] + margin , config .grid_size )
415+ else :
416+ grid = tau_grid_np
417+
418+ # Grid-search maximization of L_k(tau) [Appendix A.5].
419+ best_ll = - np .inf
420+ best_tau = np .nan
421+ for tau_cand in grid :
422+ ll = _logL_for_tau (yk , sk , tk , float (tau_cand ))
423+ if ll > best_ll :
424+ best_ll = ll
425+ best_tau = float (tau_cand )
426+
427+ if np .isfinite (best_ll ):
428+ tau_hat [k ] = best_tau
429+
430+ return tau_hat
431+
432+ # Select the requested event only, and return tau_hat(k) for that one event.
433+ s_event = np .asarray (S_std .sel ({event_dim : event_index }).values , dtype = float )
434+ y_event = np .asarray (y_std .sel ({event_dim : event_index }).values , dtype = float )
435+
436+ tau_hat_1d = _tau_hat_for_one_event (s_event , y_event )
437+
438+ return xr .DataArray (
439+ tau_hat_1d ,
440+ dims = ("k" ,),
441+ coords = {"k" : np .arange (k_end + 1 )},
442+ name = "tau_hat" ,
443+ attrs = {
444+ "long_name" : f"MLE tau_hat(k) for { event_dim } ={ event_index } " ,
445+ "units" : "sample_index_or_time_units_of_t" ,
446+ },
447+ )
0 commit comments