Skip to content

Commit dfcc0c5

Browse files
authored
Merge branch 'ARM-DOE:main' into dev
2 parents 4f3def1 + 45ba380 commit dfcc0c5

7 files changed

Lines changed: 408 additions & 12 deletions

File tree

‎pysp2/util/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@
1818
from .particle_properties import calc_diams_masses, process_psds
1919
from .deadtime import deadtime
2020
from .leo_fit import beam_shape,leo_fit
21-
from .normalized_derivative_method import central_difference
21+
from .normalized_derivative_method import central_difference, plot_normalized_derivative, mle_tau_moteki_kondo

‎pysp2/util/normalized_derivative_method.py‎

Lines changed: 332 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
from __future__ import annotations
2+
13
import numpy as np
24
import 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

Comments
 (0)