Skip to content

Commit 21db7f4

Browse files
committed
finishing touches post claude
1 parent 5201024 commit 21db7f4

5 files changed

Lines changed: 32 additions & 50 deletions

File tree

pynumdiff/kalman_smooth.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ def rtsdiff(x, dt_or_t, order, log_qr_ratio, forwardbackward):
111111
"""Perform Rauch-Tung-Striebel smoothing with a naive constant derivative model. Makes use of :code:`kalman_filter`
112112
and :code:`rts_smooth`, which are made public. :code:`constant_X` methods in this module call this function.
113113
114-
:param np.array[float] x: data series to differentiate
114+
:param np.array[float] x: data series to differentiate. May contain NaN values (missing data); NaNs are excluded from
115+
fitting and imputed by dynamical model evolution.
115116
:param float or array[float] dt_or_t: This function supports variable step size. This parameter is either the constant
116117
step size if given as a single float, or data locations if given as an array of same length as :code:`x`.
117118
:param int order: which derivative to stabilize in the constant-derivative model

pynumdiff/polynomial_fit.py

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,10 @@ def polydiff(x, dt_or_t, params=None, options=None, degree=None, window_size=Non
5151
kernel='friedrichs'):
5252
"""Fit polynomials to the data, and differentiate the polynomials.
5353
54-
:param np.array[float] x: data to differentiate. May contain NaN values (missing data);
55-
NaNs are excluded from fitting and imputed by polynomial interpolation.
56-
:param float or np.array[float] dt_or_t: This function supports variable step size. This
57-
parameter is either the constant :math:`\\Delta t` if given as a single float, or data
58-
locations if given as an array of same length as :code:`x`.
54+
:param np.array[float] x: data to differentiate. May contain NaN values (missing data); NaNs are excluded from
55+
fitting and imputed by polynomial interpolation.
56+
:param float or array[float] dt_or_t: This function supports variable step size. This parameter is either the constant
57+
:math:`\\Delta t` if given as a single float, or data locations if given as an array of same length as :code:`x`.
5958
:param list[int] params: (**deprecated**, prefer :code:`degree` and :code:`window_size`)
6059
:param dict options: (**deprecated**, prefer :code:`step_size` and :code:`kernel`)
6160
a dictionary consisting of {'sliding': (bool), 'step_size': (int), 'kernel_name': (str)}
@@ -86,15 +85,9 @@ def polydiff(x, dt_or_t, params=None, options=None, degree=None, window_size=Non
8685
warn("Kernel window size should be odd. Added 1 to length.")
8786

8887
def _polydiff(x, dt_or_t, degree, weights=None):
89-
# Build time array: either from scalar dt or from passed array of locations
90-
if np.isscalar(dt_or_t):
91-
t = np.arange(len(x)) * dt_or_t
92-
else:
93-
t = np.asarray(dt_or_t)
94-
95-
# Filter out NaN values so polyfit doesn't fail on missing data
96-
mask = ~np.isnan(x)
97-
if not np.any(mask): return np.full_like(x, np.nan), np.full_like(x, np.nan) # all NaN window
88+
t = dt_or_t if not np.isscalar(dt_or_t) else np.arange(len(x)) * dt_or_t # sample locations
89+
mask = ~np.isnan(x) # Filter out any NaN values so polyfit doesn't lose its mind in the event of missing data
90+
if not np.any(mask): warn("Window of all NaNs encountered. `polyfit` will fail. Choose a wider `window_size`?")
9891

9992
r = np.polyfit(t[mask], x[mask], degree, w=weights[mask] if weights is not None else None) # polyfit returns highest order first
10093
dr = np.polyder(r) # power rule already implemented for us
@@ -104,8 +97,7 @@ def _polydiff(x, dt_or_t, degree, weights=None):
10497

10598
return x_hat, dxdt_hat
10699

107-
if not window_size:
108-
return _polydiff(x, dt_or_t, degree)
100+
if not window_size: return _polydiff(x, dt_or_t, degree)
109101

110102
kernel = {'gaussian':utility.gaussian_kernel, 'friedrichs':utility.friedrichs_kernel}[kernel](window_size)
111103
return utility.slide_function(_polydiff, x, dt_or_t, kernel, degree, stride=step_size, pass_weights=True)

pynumdiff/tests/test_diff_methods.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def iterated_second_order(*args, **kwargs): return second_order(*args, **kwargs)
1414
def iterated_fourth_order(*args, **kwargs): return fourth_order(*args, **kwargs)
1515
def spline_irreg_step(*args, **kwargs): return splinediff(*args, **kwargs)
1616
def polydiff_irreg_step(*args, **kwargs): return polydiff(*args, **kwargs)
17+
irreg_list = [spline_irreg_step, polydiff_irreg_step, rbfdiff, rtsdiff] # methods to test with irregular time steps
1718

1819
dt = 0.1
1920
t = np.linspace(0, 3, 31) # sample locations, including the endpoint
@@ -135,7 +136,7 @@ def polydiff_irreg_step(*args, **kwargs): return polydiff(*args, **kwargs)
135136
[(0, 0), (1, 1), (0, -1), (1, 1)],
136137
[(0, 0), (3, 3), (0, 0), (3, 3)]],
137138
polydiff_irreg_step: [[(-14, -15), (-14, -14), (0, -1), (1, 1)],
138-
[(-14, -14), (-13, -14), (0, -1), (1, 1)],
139+
[(-14, -14), (-13, -13), (0, -1), (1, 1)],
139140
[(-14, -14), (-13, -13), (0, -1), (1, 1)],
140141
[(-2, -2), (0, 0), (0, -1), (1, 1)],
141142
[(0, 0), (1, 1), (0, 0), (1, 1)],
@@ -250,9 +251,9 @@ def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # re
250251
i, latex_name, f, df = test_func_and_deriv
251252

252253
# sample the true function and true derivative, and make noisy samples
253-
x = f(t) if diff_method not in [spline_irreg_step, polydiff_irreg_step, rbfdiff, rtsdiff] else f(t_irreg)
254-
dxdt = df(t) if diff_method not in [spline_irreg_step, polydiff_irreg_step, rbfdiff, rtsdiff] else df(t_irreg)
255-
_t = dt if diff_method not in [spline_irreg_step, polydiff_irreg_step, rbfdiff, rtsdiff] else t_irreg
254+
x = f(t) if diff_method not in irreg_list else f(t_irreg)
255+
dxdt = df(t) if diff_method not in irreg_list else df(t_irreg)
256+
_t = dt if diff_method not in irreg_list else t_irreg
256257
x_noisy = x + noise
257258

258259
# differentiate without and with noise, accounting for new and old styles of calling functions
@@ -266,7 +267,7 @@ def test_diff_method(diff_method_and_params, test_func_and_deriv, request): # re
266267
# plotting code
267268
if request.config.getoption("--plot") and not isinstance(params, list): # Get the plot flag from pytest configuration
268269
fig, axes = request.config.plots[diff_method] # get the appropriate plot, set up by the store_plots fixture in conftest.py
269-
t_ = t_irreg if diff_method in [spline_irreg_step, polydiff_irreg_step, rtsdiff, rbfdiff] else t
270+
t_ = t_irreg if diff_method in irreg_list else t
270271
axes[i, 0].plot(t_, f(t_))
271272
axes[i, 0].plot(t_, x, 'C0+')
272273
axes[i, 0].plot(t_, x_hat, 'C2.', ms=4)
@@ -381,6 +382,3 @@ def test_multidimensionality(multidim_method_and_params, request):
381382
ax3.plot_wireframe(T1, T2, computed_laplacian, label='computed')
382383
legend = ax3.legend(bbox_to_anchor=(0.7, 0.8)); legend.legend_handles[0].set_facecolor(pyplot.cm.viridis(0.6))
383384
fig.suptitle(f'{diff_method.__name__}', fontsize=16)
384-
385-
386-

pynumdiff/tests/test_utils.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from pynumdiff.utils import utility, evaluate
66
from pynumdiff.utils.simulate import sine, triangle, pop_dyn, linear_autonomous, pi_cruise_control, lorenz_x
7-
np.random.seed(42) # The answer to life, the universe, and everything
7+
np.random.seed(7)
88

99

1010
def test_integrate_dxdt_hat():
@@ -55,9 +55,9 @@ def test_convolutional_smoother():
5555

5656
def test_peakdet(request):
5757
"""Verify peakdet finds peaks and valleys"""
58-
rng = np.random.RandomState(42) # own seed so test order doesn't affect results
5958
t = np.arange(0, 10, 0.001)
60-
x = 0.3*np.sin(t) + np.sin(1.3*t) + 0.9*np.sin(4.2*t) + 0.02*rng.randn(10000)
59+
x = 0.3*np.sin(t) + np.sin(1.3*t) + 0.9*np.sin(4.2*t) + \
60+
0.02*np.random.RandomState(42).randn(10000) # isolated source of randomness so test order doesn't affect results
6161
maxtab, mintab = utility.peakdet(x, 0.5, t)
6262

6363
if request.config.getoption("--plot"):
@@ -88,13 +88,11 @@ def identity(x, dt_or_t): return x, 0 # should come back the same
8888
x = np.arange(100, dtype=float)
8989
kernel = utility.gaussian_kernel(9)
9090

91-
# Scalar dt: existing behavior should be unchanged
92-
x_hat, dxdt_hat = utility.slide_function(identity, x, 0.1, kernel, stride=2)
93-
assert np.allclose(x, x_hat)
91+
x_hat_dt, _ = utility.slide_function(identity, x, 0.1, kernel, stride=2)
92+
assert np.allclose(x, x_hat_dt)
9493

95-
# Array t: func receives a t-slice instead of scalar dt; identity still returns x unchanged
96-
t = np.linspace(0, 10, 100)
97-
x_hat_t, _ = utility.slide_function(identity, x, t, kernel, stride=2)
94+
# time array: func receives a kernel-length slice of times instead of scalar dt; identity still returns x unchanged
95+
x_hat_t, _ = utility.slide_function(identity, x, np.linspace(0, 10, 100, endpoint=False), kernel, stride=2)
9896
assert np.allclose(x, x_hat_t)
9997

10098

pynumdiff/utils/utility.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -120,23 +120,20 @@ def slide_function(func, x, dt_or_t, kernel, *args, stride=1, pass_weights=False
120120
121121
:param callable func: name of the function to slide
122122
:param np.array[float] x: data to differentiate
123-
:param float or np.array[float] dt_or_t: constant step size (scalar) or array of sample
124-
locations (same length as x). When given as an array, the actual time values for each
125-
window are passed to func, enabling support for nonuniform spacing.
123+
:param float or np.array[float] dt_or_t: constant step size (scalar) or array of sample locations (same length as x).
124+
When given as an array, the actual time values for each window are passed to :code:`func`, enabling nonuniform spacing.
126125
:param np.array[float] kernel: values to weight the sliding window
127-
:param list args: passed to func
128-
:param int stride: step size for slide (e.g. 1 means slide by 1 step)
126+
:param list args: passed to :code:`func`
127+
:param int stride: step size for slide (e.g. 1 means slide by 1 index location)
129128
:param bool pass_weights: whether weights should be passed to func via update to kwargs
130-
:param dict kwargs: passed to func
129+
:param dict kwargs: passed to :code:`func`
131130
132131
:return: - **x_hat** -- estimated (smoothed) x
133132
- **dxdt_hat** -- estimated derivative of x
134133
"""
135134
if len(kernel) % 2 == 0: raise ValueError("Kernel window size should be odd.")
136135
half_window_size = (len(kernel) - 1)//2 # int because len(kernel) is always odd
137-
138-
scalar_dt = np.isscalar(dt_or_t)
139-
t = None if scalar_dt else np.asarray(dt_or_t)
136+
equispaced = np.isscalar(dt_or_t)
140137

141138
x_hat = np.zeros(x.shape)
142139
dxdt_hat = np.zeros(x.shape)
@@ -146,7 +143,7 @@ def slide_function(func, x, dt_or_t, kernel, *args, stride=1, pass_weights=False
146143
# find where to index data and kernel, taking care at edges
147144
start = max(0, midpoint - half_window_size)
148145
end = min(len(x), midpoint + half_window_size + 1) # +1 because slicing is exclusive of end
149-
window = slice(start, end)
146+
window = slice(start, end) # This is in terms of indices, not true time in the event of nonuniform spacing
150147

151148
kstart = max(0, half_window_size - midpoint)
152149
kend = kstart + (end - start)
@@ -155,12 +152,8 @@ def slide_function(func, x, dt_or_t, kernel, *args, stride=1, pass_weights=False
155152
w = kernel if (end-start) == len(kernel) else kernel[kslice]/np.sum(kernel[kslice])
156153
if pass_weights: kwargs['weights'] = w
157154

158-
# When t is an array, pass the actual time values for the window so func can handle
159-
# nonuniform spacing; otherwise pass dt as before (backward compatible).
160-
dt_or_t_window = dt_or_t if scalar_dt else t[window]
161-
162-
# run the function on the window and add weighted results to cumulative answers
163-
x_window_hat, dxdt_window_hat = func(x[window], dt_or_t_window, *args, **kwargs)
155+
# Run the function on the window and add weighted results to cumulative answers. If not equispaced, pass times for window.
156+
x_window_hat, dxdt_window_hat = func(x[window], dt_or_t if equispaced else dt_or_t[window], *args, **kwargs)
164157
x_hat[window] += w * x_window_hat
165158
dxdt_hat[window] += w * dxdt_window_hat
166159
weight_sum[window] += w # save sum of weights for normalization at the end

0 commit comments

Comments
 (0)