11from __future__ import annotations
22
33from collections .abc import Callable , Sequence
4- from typing import Any
4+ from typing import Literal , NamedTuple
55
66import numpy as np
7+ import numpy .typing as npt
78
89
9- def _coerce_to_float_array (a ):
10- """Helper to ensure input is a 1D float array."""
10+ class RegularizedArray (NamedTuple ):
11+ x : npt .NDArray [np .number ]
12+ y : npt .NDArray [np .number ]
13+ not_na : npt .NDArray [bool ]
14+ kept_na : bool
15+
16+
17+ class Approx (NamedTuple ):
18+ x : npt .NDArray [float ]
19+ y : npt .NDArray [float ]
20+
21+
22+ def _coerce_to_float_array (a : Sequence [int | float | np .number ]):
23+ """Coerce input to a 1D float array.
24+
25+ Args:
26+ a: the array to coerce
27+
28+ Returns:
29+ A floating point 1D array.
30+ """
1131 arr = np .asarray (a , dtype = float )
1232 if arr .ndim != 1 :
1333 arr = arr .ravel ()
1434 return arr
1535
1636
17- def _regularize_values (x : np .ndarray , y : np .ndarray , ties , na_rm : bool ) -> dict [str , Any ]:
18- """Minimal reimplementation of R's regularize.values() used by approx().
37+ def _regularize_values (
38+ x : npt .NDArray [np .number ],
39+ y : npt .NDArray [np .number ],
40+ * ,
41+ na_rm : bool ,
42+ ties : Callable [[np .ndarray ], float ] | Literal ["mean" , "first" , "last" , "min" , "max" , "median" , "sum" ] = "mean" ,
43+ ) -> RegularizedArray :
44+ """Reimplementation of R's regularize.values() used by approx().
1945
2046 - Removes NA pairs if na_rm is True (else requires x to have no NA).
2147 - Sorts by x (stable).
2248 - Collapses duplicate x via `ties` aggregator.
23- Returns dict with:
24- x: sorted unique x
25- y: aggregated y aligned with x
26- not_na: boolean mask of y that are not NaN
27- kept_na: True iff any NaN remained in y after regularization
49+
50+ Args:
51+ x: the x values to regularize
52+ y: ties: the corresponding y values
53+ na_rm: should NA values be removed?
54+ ties: how to handle duplicate x values; can be a string or a callable
55+
56+ Returns:
57+ A NamedTuple with:
58+ - x: sorted unique x
59+ - y: aggregated y aligned with x
60+ - not_na: boolean mask of y that are not NaN
61+ - kept_na: True iff any NaN remained in y after regularization
2862 """
2963 if na_rm :
3064 # Remove pairs where x or y is NA
@@ -39,8 +73,7 @@ def _regularize_values(x: np.ndarray, y: np.ndarray, ties, na_rm: bool) -> dict[
3973 kept_na = np .isnan (y ).any ()
4074
4175 if x .size == 0 :
42- # THIS IS THE CORRECTED LINE:
43- return {"x" : x , "y" : y , "not_na" : np .array ([], dtype = bool ), "kept_na" : kept_na }
76+ return RegularizedArray (x = x , y = y , not_na = np .array ([], dtype = bool ), kept_na = kept_na )
4477
4578 # Use a stable sort (mergesort) to match R's order()
4679 order = np .argsort (x , kind = "mergesort" )
@@ -51,27 +84,26 @@ def _regularize_values(x: np.ndarray, y: np.ndarray, ties, na_rm: bool) -> dict[
5184 if callable (ties ):
5285 agg_fn = ties
5386 else :
54- ties_str = "mean" if ties is None else str ( ties ). lower ()
55- if ties_str in ("mean" , "avg" , "average" ):
87+ # fmt: off
88+ if ties in ("mean" , "avg" , "average" ):
5689 agg_fn = np .mean
57- elif ties_str in ("first" , "left" ):
58-
90+ elif ties in ("first" , "left" ):
5991 def agg_fn (a ):
6092 return a [0 ]
61- elif ties_str in ("last" , "right" ):
62-
93+ elif ties in ("last" , "right" ):
6394 def agg_fn (a ):
6495 return a [- 1 ]
65- elif ties_str == "min" :
96+ elif ties == "min" :
6697 agg_fn = np .min
67- elif ties_str == "max" :
98+ elif ties == "max" :
6899 agg_fn = np .max
69- elif ties_str == "median" :
100+ elif ties == "median" :
70101 agg_fn = np .median
71- elif ties_str == "sum" :
102+ elif ties == "sum" :
72103 agg_fn = np .sum
73104 else :
74105 raise ValueError ("Unsupported `ties`; use a callable or one of 'mean', 'first', 'last', 'min', 'max', 'median', 'sum'." )
106+ # fmt: on
75107
76108 # Find unique x values and their indices/counts
77109 unique_x , start_idx , counts = np .unique (x_sorted , return_index = True , return_counts = True )
@@ -86,8 +118,8 @@ def agg_fn(a):
86118
87119 not_na = ~ np .isnan (y_agg )
88120 # Check if any NaNs remain in the *aggregated* y values
89- kept_na_final = np .any (~ not_na ) if np .any (np .isnan (y_agg )) else False
90- return { "x" : unique_x , "y" : y_agg , " not_na" : not_na , " kept_na" : kept_na_final }
121+ kept_na_final = bool ( np .any (~ not_na ) if np .any (np .isnan (y_agg )) else False )
122+ return RegularizedArray ( x = unique_x , y = y_agg , not_na = not_na , kept_na = kept_na_final )
91123
92124
93125def approx (
@@ -98,11 +130,11 @@ def approx(
98130 n : int = 50 ,
99131 yleft : float | None = None ,
100132 yright : float | None = None ,
101- rule : int | Sequence [int ] = 1 ,
133+ rule : int | Sequence [int ] | npt . NDArray [ int ] = 1 ,
102134 f : float = 0.0 ,
103- ties : str | Callable [[np .ndarray ], float ] = "mean" ,
135+ ties : Callable [[np .ndarray ], float ] | Literal [ "mean" , "first" , "last" , "min" , "max" , "median" , "sum" ] = "mean" ,
104136 na_rm : bool = True ,
105- ) -> dict [ str , np . ndarray ] :
137+ ) -> Approx :
106138 """Faithful Python port of R's `approx` function.
107139
108140 This implementation aims to replicate the behavior of R's `approx`
@@ -114,26 +146,18 @@ def approx(
114146 y: y-coordinates. If None, `x` is treated as `y` and `x` becomes `1..n`.
115147 xout: Points at which to interpolate.
116148 method: Interpolation method. "linear" (1) or "constant" (2).
117- n: If `xout` is not provided, interpolation happens at `n`
118- equally spaced points spanning the range of `x`.
119- yleft: Value to use for extrapolation to the left.
120- Defaults to `NA` (np.nan) if `rule` is 1.
121- yright: Value to use for extrapolation to the right.
122- Defaults to `NA` (np.nan) if `rule` is 1.
149+ n: If `xout` is not provided, interpolation happens at `n` equally spaced points spanning the range of `x`.
150+ yleft: Value to use for extrapolation to the left. Defaults to `NA` (np.nan) if `rule` is 1.
151+ yright: Value to use for extrapolation to the right. Defaults to `NA` (np.nan) if `rule` is 1.
123152 rule: Extrapolation rule.
124153 - 1: Return `np.nan` for points outside the `x` range.
125154 - 2: Use `yleft` and `yright` for points outside the range.
126- f: For `method="constant"`, determines the split point.
127- `f=0` is left-step, `f=1` is right-step, `f=0.5` is midpoint.
128- ties: How to handle duplicate `x` values.
129- Can be 'mean', 'first', 'last', 'min', 'max', 'median', 'sum',
130- or a callable function.
131- na_rm: If True, `NA` pairs are removed before interpolation.
132- If False, `NA`s in `x` cause an error, `NA`s in `y`
133- are propagated.
155+ f: For `method="constant"`, determines the split point. `f=0` is left-step, `f=1` is right-step, `f=0.5` is midpoint.
156+ ties: How to handle duplicate `x` values. Can be 'mean', 'first', 'last', 'min', 'max', 'median', 'sum', or a callable function.
157+ na_rm: If True, `NA` pairs are removed before interpolation. If False, `NA`s in `x` cause an error, `NA`s in `y` are propagated.
134158
135159 Returns:
136- dict with:
160+ `Approx` class with:
137161 - 'x': numpy array of xout used
138162 - 'y': numpy array of interpolated values
139163 """
@@ -164,25 +188,24 @@ def approx(
164188 raise ValueError ("invalid interpolation method" )
165189
166190 # --- Rule normalization ---
167- if isinstance (rule , list | tuple | np .ndarray ):
168- rlist = list (rule )
169- if not (1 <= len (rlist ) <= 2 ):
191+ if isinstance (rule , Sequence | np .ndarray ):
192+ rule_list : list [ int ] = list (rule ) # type: ignore[invalid-argument-type] # This is a valid argument type, not sure why ty is upset
193+ if not (1 <= len (rule_list ) <= 2 ):
170194 raise ValueError ("`rule` must have length 1 or 2" )
171- if len (rlist ) == 1 :
172- rlist = [rlist [0 ], rlist [0 ]]
195+ if len (rule_list ) == 1 :
196+ rule_list = [rule_list [0 ], rule_list [0 ]]
173197 else :
174- rlist = [rule , rule ]
198+ rule_list = [rule , rule ]
175199
176- # --- Regularize values ---
177200 # Sort by x, collapse ties, and handle NAs like R's regularize.values()
178- r = _regularize_values (x_arr , y_arr , ties , na_rm )
179- x_reg = r [ "x" ]
180- y_reg = r [ "y" ]
181- not_na_mask = r [ " not_na" ]
201+ r : RegularizedArray = _regularize_values (x_arr , y_arr , na_rm = na_rm , ties = ties )
202+ x_reg : npt . NDArray [ float ] = r . x
203+ y_reg : npt . NDArray [ float ] = r . y
204+ not_na_mask : npt . NDArray [ bool ] = r . not_na
182205 # no_na is True if we don't have to worry about NAs in y_reg
183- no_na = na_rm or (not r [ " kept_na" ] )
206+ no_na : bool = na_rm or (not r . kept_na )
184207 # nx is the number of *valid* (non-NA) points for interpolation
185- nx = x_reg .size if no_na else int (np .count_nonzero (not_na_mask ))
208+ nx : int = x_reg .size if no_na else int (np .count_nonzero (not_na_mask ))
186209
187210 if np .isnan (nx ):
188211 raise ValueError ("invalid length(x)" )
@@ -194,98 +217,98 @@ def approx(
194217 if nx == 0 :
195218 raise ValueError ("zero non-NA points" )
196219
197- # --- Set extrapolation values (yleft/yright) ---
220+ # set extrapolation values (yleft/yright)
198221 # This logic matches R's C code (R_approxtest)
199222 if no_na :
200- y_first = y_reg [0 ]
201- y_last = y_reg [- 1 ]
223+ y_first : float = y_reg [0 ]
224+ y_last : float = y_reg [- 1 ]
202225 else :
203226 # Find first and last non-NA y values
204- y_valid = y_reg [not_na_mask ]
205- y_first = y_valid [0 ]
206- y_last = y_valid [- 1 ]
227+ y_valid : npt . NDArray [ float ] = y_reg [not_na_mask ]
228+ y_first : float = y_valid [0 ]
229+ y_last : float = y_valid [- 1 ]
207230
208- yleft_val = (np .nan if int (rlist [0 ]) == 1 else y_first ) if yleft is None else float (yleft )
209- yright_val = (np .nan if int (rlist [1 ]) == 1 else y_last ) if yright is None else float (yright )
231+ yleft_val : float = (np .nan if int (rule_list [0 ]) == 1 else y_first ) if yleft is None else float (yleft )
232+ yright_val : float = (np .nan if int (rule_list [1 ]) == 1 else y_last ) if yright is None else float (yright )
210233
211- # --- Fabricate xout if missing ---
234+ # fabricate xout if it is missing
212235 if xout is None :
213236 if n <= 0 :
214237 raise ValueError ("'approx' requires n >= 1" )
215238 if no_na :
216- x_first = x_reg [0 ]
217- x_last = x_reg [nx - 1 ]
239+ x_first : float = x_reg [0 ]
240+ x_last : float = x_reg [nx - 1 ]
218241 else :
219- x_valid = x_reg [not_na_mask ]
220- x_first = x_valid [0 ]
221- x_last = x_valid [- 1 ]
222- xout_arr = np .linspace (x_first , x_last , num = int (n ), dtype = float )
242+ x_valid : npt . NDArray [ float ] = x_reg [not_na_mask ]
243+ x_first : float = x_valid [0 ]
244+ x_last : float = x_valid [- 1 ]
245+ xout_arr : npt . NDArray [ float ] = np .linspace (x_first , x_last , num = int (n ), dtype = float )
223246 else :
224- xout_arr = _coerce_to_float_array (xout )
247+ xout_arr : npt . NDArray [ float ] = _coerce_to_float_array (xout )
225248
226- # --- C_ApproxTest checks ---
249+ # replicate R's C_ApproxTest checks
227250 if method_code == 2 and (not np .isfinite (f ) or f < 0.0 or f > 1.0 ):
228- raise ValueError ("approx(): invalid f value" )
251+ raise ValueError ("approx(): invalid f value; with `method=2`, `f` must be in the range [0,1] (inclusive) or NA " )
229252 if not no_na :
230253 # R re-filters x and y here if NAs remained
231- x_reg = x_reg [not_na_mask ]
232- y_reg = y_reg [not_na_mask ]
254+ x_reg : npt . NDArray [ float ] = x_reg [not_na_mask ]
255+ y_reg : npt . NDArray [ float ] = y_reg [not_na_mask ]
233256
234- # --- Interpolation ---
235- # This section vectorized the logic from R's approx1 and R_approxfun
236- yout = np .empty_like (xout_arr , dtype = float )
237- mask_nan_xout = np .isnan (xout_arr )
257+ # perform interpolation
258+ # this section is a vectorized form of the logic from R's approx1 and R_approxfun
259+ yout : npt . NDArray [ float ] = np .empty_like (xout_arr , dtype = float )
260+ mask_nan_xout : npt . NDArray [ bool ] = np .isnan (xout_arr )
238261 yout [mask_nan_xout ] = np .nan
239- mask_valid = ~ mask_nan_xout
262+ mask_valid : npt . NDArray [ bool ] = ~ mask_nan_xout
240263
241264 if x_reg .size == 0 :
242265 # No valid points to interpolate against
243266 yout [mask_valid ] = np .nan
244- return { "x" : xout_arr , "y" : yout }
267+ return Approx ( x = xout_arr , y = yout )
245268
246- xv = xout_arr [mask_valid ]
247- left_mask = xv < x_reg [0 ]
248- right_mask = xv > x_reg [- 1 ]
249- mid_mask = ~ (left_mask | right_mask )
269+ xv : npt . NDArray [ float ] = xout_arr [mask_valid ]
270+ left_mask : npt . NDArray [ bool ] = xv < x_reg [0 ]
271+ right_mask : npt . NDArray [ bool ] = xv > x_reg [- 1 ]
272+ mid_mask : npt . NDArray [ bool ] = ~ (left_mask | right_mask )
250273
251- res = np .empty_like (xv )
274+ res : npt . NDArray [ float ] = np .empty_like (xv , dtype = float )
252275 res [left_mask ] = yleft_val
253276 res [right_mask ] = yright_val
254277
255278 if np .any (mid_mask ):
256- xm = xv [mid_mask ]
279+ xm : npt . NDArray [ float ] = xv [mid_mask ]
257280
258281 # Find indices
259282 # j_right[k] = index of first x_reg > xm[k]
260- j_right = np .searchsorted (x_reg , xm , side = "right" )
283+ j_right : npt . NDArray [ int ] = np .searchsorted (x_reg , xm , side = "right" )
261284 # j_left[k] = index of first x_reg >= xm[k]
262- j_left = np .searchsorted (x_reg , xm , side = "left" )
285+ j_left : npt . NDArray [ int ] = np .searchsorted (x_reg , xm , side = "left" )
263286
264287 # Points that exactly match an x_reg value
265- eq_mask = j_left != j_right
288+ eq_mask : npt . NDArray [ bool ] = j_left != j_right
266289 # Points that fall between x_reg values
267- in_interval_mask = ~ eq_mask
290+ in_interval_mask : npt . NDArray [ bool ] = ~ eq_mask
268291
269- res_mid = np .empty_like (xm )
292+ res_mid : npt . NDArray [ float ] = np .empty_like (xm , dtype = float )
270293
271294 if np .any (eq_mask ):
272295 # For exact matches, use the corresponding y_reg value
273296 # R's C code uses the 'j_left' index here
274- res_mid [eq_mask ] = y_reg [j_left [eq_mask ]]
297+ res_mid [eq_mask ] = y_reg [j_left [eq_mask ]] # type: ignore[non-subscriptable] # we know this is of type npt.NDArray[float], not sure why ty is upset
275298
276299 if np .any (in_interval_mask ):
277300 # R's C code sets i = j-1, where j is the 'right' index
278- j = j_right [in_interval_mask ]
279- i = j - 1
301+ j : npt . NDArray [ float ] = j_right [in_interval_mask ] # type: ignore[non-subscriptable] # we know this is of type npt.NDArray[float], not sure why ty is upset
302+ i : npt . NDArray [ float ] = j - 1
280303
281304 # Get the surrounding x and y values
282- xi = x_reg [i ]
283- xj = x_reg [j ]
284- yi = y_reg [i ]
285- yj = y_reg [j ]
305+ xi : npt . NDArray [ float ] = x_reg [i ]
306+ xj : npt . NDArray [ float ] = x_reg [j ]
307+ yi : npt . NDArray [ float ] = y_reg [i ]
308+ yj : npt . NDArray [ float ] = y_reg [j ]
286309
287310 if method_code == 1 : # linear
288- t = (xm [in_interval_mask ] - xi ) / (xj - xi )
311+ t : npt . NDArray [ float ] = (xm [in_interval_mask ] - xi ) / (xj - xi )
289312 res_mid [in_interval_mask ] = yi + (yj - yi ) * t
290313 else : # constant
291314 # This matches R_approxfun's constant logic
@@ -294,17 +317,17 @@ def approx(
294317 elif f == 1.0 :
295318 res_mid [in_interval_mask ] = yj
296319 else :
297- # R computes (1-f)*yi + f*yj, but carefully
298- f1 = 1.0 - f
299- f2 = f
300- part = np .zeros_like (yi )
320+ # computes R's (1-f)*yi + f*yj
321+ f1 : float = float ( 1.0 - f )
322+ f2 : float = float ( f )
323+ part : npt . NDArray [ float ] = np .zeros_like (yi , dtype = float )
301324 if f1 != 0.0 :
302- part = part + yi * f1
325+ part : npt . NDArray [ float ] = part + yi * f1
303326 if f2 != 0.0 :
304- part = part + yj * f2
327+ part : npt . NDArray [ float ] = part + yj * f2
305328 res_mid [in_interval_mask ] = part
306329
307330 res [mid_mask ] = res_mid
308331
309332 yout [mask_valid ] = res
310- return { "x" : xout_arr , "y" : yout }
333+ return Approx ( x = xout_arr , y = yout )
0 commit comments