Coverage for src/sdynpy/signal_processing/sdynpy_interpolation.py: 4%
90 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-18 21:32 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-18 21:32 +0000
1# -*- coding: utf-8 -*-
2"""
3Functions for interpolating signals
4"""
5"""
6Copyright 2022 National Technology & Engineering Solutions of Sandia,
7LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the U.S.
8Government retains certain rights in this software.
10This program is free software: you can redistribute it and/or modify
11it under the terms of the GNU General Public License as published by
12the Free Software Foundation, either version 3 of the License, or
13(at your option) any later version.
15This program is distributed in the hope that it will be useful,
16but WITHOUT ANY WARRANTY; without even the implied warranty of
17MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18GNU General Public License for more details.
20You should have received a copy of the GNU General Public License
21along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""
24import numpy as np
27import numpy as np
30def adaptive_linear_interpolation(
31 y,
32 x=None,
33 rtol=1e-3,
34 atol=0.0,
35 scale="range",
36 max_points=None,
37 return_reconstruction=False,
38 relative_mode="global",
39 local_floor_fraction=1e-3,
40):
41 """
42 Adaptive piecewise-linear approximation with shared breakpoints across channels.
44 Parameters
45 ----------
46 y : array_like, shape (C, N) or (N,)
47 Signal values.
48 x : array_like, shape (N,), optional
49 Sample locations. If None, uses np.arange(N).
50 rtol : float or array_like, shape (C,)
51 Relative tolerance(s).
52 atol : float or array_like, shape (C,)
53 Absolute tolerance(s).
54 scale : {"range", "maxabs", "rms"} or array_like, shape (C,)
55 Global scale per channel.
56 max_points : int, optional
57 Maximum number of breakpoints including endpoints.
58 return_reconstruction : bool, default False
59 If True, return reconstructed y_hat.
60 relative_mode : {"global", "local", "hybrid"}, default "global"
61 How relative error is normalized.
62 local_floor_fraction : float, default 1e-3
63 Minimum local scale as a fraction of global channel scale.
65 Returns
66 -------
67 breakpoints, x_bp, y_bp[, y_hat]
68 """
69 y = np.asarray(y, dtype=float)
70 if y.ndim == 1:
71 y = y[np.newaxis, :]
72 elif y.ndim != 2:
73 raise ValueError("y must be 1D or 2D")
75 C, N = y.shape
76 if N < 2:
77 raise ValueError("Need at least two samples")
79 if x is None:
80 x = np.arange(N, dtype=float)
81 else:
82 x = np.asarray(x, dtype=float)
83 if x.shape != (N,):
84 raise ValueError("x must have shape (N,)")
85 if not np.all(np.diff(x) > 0):
86 raise ValueError("x must be strictly increasing")
88 rtol = np.asarray(rtol, dtype=float)
89 if rtol.ndim == 0:
90 rtol = np.full(C, rtol)
91 elif rtol.shape != (C,):
92 raise ValueError("rtol must be scalar or shape (C,)")
94 atol = np.asarray(atol, dtype=float)
95 if atol.ndim == 0:
96 atol = np.full(C, atol)
97 elif atol.shape != (C,):
98 raise ValueError("atol must be scalar or shape (C,)")
100 if isinstance(scale, str):
101 if scale == "range":
102 global_scale = y.max(axis=1) - y.min(axis=1)
103 elif scale == "maxabs":
104 global_scale = np.max(np.abs(y), axis=1)
105 elif scale == "rms":
106 global_scale = np.sqrt(np.mean(y**2, axis=1))
107 else:
108 raise ValueError("scale must be 'range', 'maxabs', 'rms', or an array")
109 else:
110 global_scale = np.asarray(scale, dtype=float)
111 if global_scale.shape != (C,):
112 raise ValueError("scale array must have shape (C,)")
114 eps = np.finfo(float).eps
115 global_scale = np.maximum(global_scale, eps)
116 local_floor = local_floor_fraction * global_scale
118 def reconstruct(bp):
119 y_hat = np.empty_like(y)
120 for k in range(len(bp) - 1):
121 i0 = bp[k]
122 i1 = bp[k + 1]
123 x0 = x[i0]
124 x1 = x[i1]
125 alpha = (x[i0:i1+1] - x0) / (x1 - x0)
126 y_hat[:, i0:i1+1] = (
127 y[:, [i0]] + (y[:, [i1]] - y[:, [i0]]) * alpha[np.newaxis, :]
128 )
129 return y_hat
131 def allowed_error(y_true, y_fit):
132 if relative_mode == "global":
133 rel_scale = global_scale[:, None]
134 elif relative_mode == "local":
135 rel_scale = np.maximum(np.maximum(np.abs(y_true), np.abs(y_fit)),
136 local_floor[:, None])
137 elif relative_mode == "hybrid":
138 local_scale = np.maximum(np.maximum(np.abs(y_true), np.abs(y_fit)),
139 local_floor[:, None])
140 rel_scale = np.maximum(0.1 * global_scale[:, None], local_scale)
141 else:
142 raise ValueError("relative_mode must be 'global', 'local', or 'hybrid'")
144 return atol[:, None] + rtol[:, None] * rel_scale
146 bp = [0, N - 1]
148 while True:
149 bp = sorted(set(bp))
150 y_hat = reconstruct(bp)
152 abs_err = np.abs(y - y_hat)
153 allowed = allowed_error(y, y_hat)
154 violation = abs_err / allowed
156 max_violation = np.max(violation)
157 if max_violation <= 1.0:
158 break
160 score = np.max(violation, axis=0)
161 score[np.array(bp)] = -np.inf
163 idx = int(np.argmax(score))
164 if not np.isfinite(score[idx]):
165 break
167 bp.append(idx)
169 if max_points is not None and len(set(bp)) >= max_points:
170 bp = sorted(set(bp))
171 y_hat = reconstruct(bp)
172 break
174 bp = np.array(sorted(set(bp)), dtype=int)
175 x_bp = x[bp]
176 y_bp = y[:, bp]
178 if return_reconstruction:
179 y_hat = reconstruct(bp)
180 return bp, x_bp, y_bp, y_hat
182 return bp, x_bp, y_bp