dct-toolkit 1.0.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/AGENTS.md +13 -8
  2. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/CHANGELOG.md +43 -0
  3. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/PKG-INFO +117 -8
  4. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/README.md +116 -7
  5. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/dct_toolkit/__init__.py +5 -3
  6. dct_toolkit-2.0.0/dct_toolkit/_gaussian.py +50 -0
  7. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/dct_toolkit/_kernels.py +1 -1
  8. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/dct_toolkit/cartesian.py +7 -3
  9. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/dct_toolkit/core.py +15 -3
  10. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/dct_toolkit/polar.py +26 -6
  11. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/dct_toolkit/stats.py +11 -1
  12. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/docs/API_REFERENCE.md +37 -10
  13. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/docs/MATHEMATICAL_BASIS.md +52 -5
  14. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/docs/RELEASING.md +13 -13
  15. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/pyproject.toml +1 -1
  16. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_core.py +4 -4
  17. dct_toolkit-2.0.0/tests/test_gaussian.py +194 -0
  18. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_polar_kernels.py +13 -4
  19. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_polar_nd.py +18 -9
  20. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/.gitignore +0 -0
  21. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/LICENSE +0 -0
  22. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/dct_toolkit/_geometry.py +0 -0
  23. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/dct_toolkit/_widths.py +0 -0
  24. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/dct_toolkit/gap_filling.py +0 -0
  25. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/examples/basic_polar.py +0 -0
  26. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/examples/comprehensive_demo.py +0 -0
  27. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/examples/nd_polar.py +0 -0
  28. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_api.py +0 -0
  29. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_cartesian.py +0 -0
  30. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_gap_filling.py +0 -0
  31. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_geometry_precision.py +0 -0
  32. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_polar.py +0 -0
  33. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_polar_nd_fallback.py +0 -0
  34. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_polar_nd_stats.py +0 -0
  35. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_polar_robustness.py +0 -0
  36. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_stats.py +0 -0
  37. {dct_toolkit-1.0.0 → dct_toolkit-2.0.0}/tests/test_stats_nd.py +0 -0
@@ -1,11 +1,11 @@
1
1
  # AGENTS.md - DCT Toolkit Agent Guidelines
2
2
 
3
3
  **Project**: dct-toolkit
4
- **Version**: 1.0.0
4
+ **Version**: 2.0.0
5
5
 
6
- **Status**: Geometry-aware N-D polar release track
6
+ **Status**: Full sampled Gaussian statistics release track
7
7
 
8
- **Last Updated**: 2026-10-26
8
+ **Last Updated**: 2026-11-11
9
9
 
10
10
  ---
11
11
 
@@ -330,7 +330,7 @@ Before declaring task complete:
330
330
  - New public APIs need independent numerical tests and an independent code/docs review.
331
331
  - Do not modify manuscripts or review trackers as part of this software task.
332
332
 
333
- ### Future Beyond v1.0.0
333
+ ### Future Beyond v2.0.0
334
334
 
335
335
  - [ ] Additional kernels (Savitzky-Golay, Hanning)
336
336
  - [ ] Performance optimizations (numba?)
@@ -345,7 +345,11 @@ Before declaring task complete:
345
345
  ### Transfer Functions
346
346
  - **Boxcar (Analytical)**: `H[k] = sin(W·θ)/(W·sin(θ))`, θ = πk/(2n)
347
347
  - **Boxcar (Discrete)**: Sum of cosines for discrete kernels
348
- - **Gaussian**: `H[k] = exp(-0.5·(ω·σ)²)`, σ = width/√12
348
+ - **Gaussian (default)**: full normalized sampled Gaussian, σ = width/√12;
349
+ evaluate its cosine sum or equivalent frequency-alias sum to float64 precision.
350
+ No fixed spatial cutoff; nonnegative spatial weights support statistical moments.
351
+ - **Gaussian (spectral compatibility)**: `gaussian_spectral` retains
352
+ `H[k] = exp(-0.5·(ω·σ)²)`, σ = width/√12; spatial weights can be signed.
349
353
 
350
354
  ### Normalized Convolution
351
355
  Formula for robust mean with gaps:
@@ -456,8 +460,9 @@ git merge feature/hanning-window
456
460
 
457
461
  ### Current Version
458
462
 
459
- - Version: `1.0.0`, adding explicit geometry-aware N-D polar processing.
460
- - Preserve the historical `v0.6.0` and older release records.
463
+ - Version: `2.0.0`, using the full sampled Gaussian by default across smoothing
464
+ and statistics. Use `gaussian_spectral` to reproduce pre-2.0 Gaussian results.
465
+ - Preserve the historical `v1.0.0`, `v0.6.0`, and older release records.
461
466
  - Do not tag, push, upload to PyPI/conda-forge, or create a public archival record
462
467
  without approval.
463
468
  - See `docs/RELEASING.md` for local artifact verification and release handoff.
@@ -465,7 +470,7 @@ git merge feature/hanning-window
465
470
  ### Gap-Filling Status
466
471
  - Code exists in `dct_toolkit/gap_filling.py` (available for import directly)
467
472
  - **Not exported** in public API (`__init__.py`) — research-internal
468
- - Full gap-filling to be released in future v1.x after research matures
473
+ - Full gap-filling to be released in a future version after research matures
469
474
 
470
475
  ---
471
476
 
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [2.0.0] - 2026-11-11
11
+
12
+ Major release changing the default Gaussian's numerical definition. Existing
13
+ calls remain accepted, but Gaussian outputs change. For reproducible pre-2.0
14
+ results, explicitly select `'gaussian_spectral'` wherever Gaussian filtering was
15
+ previously used, including each Gaussian entry in hybrid kernel sequences.
16
+ Widths, coordinate conventions, and the public function signatures are unchanged.
17
+
18
+ ### Breaking Changes
19
+ - Redefine `'gaussian'`, the default kernel, as the full normalized sampled
20
+ Gaussian rather than the exponential spectral response used in v1.0.0.
21
+ This affects existing smoothing/statistics calls even when `kernel_type`
22
+ is omitted. Use `'gaussian_spectral'` to retain the previous response.
23
+
24
+ ### Changed
25
+ - Make the default `'gaussian'` kernel the full normalized sampled Gaussian,
26
+ evaluated to floating-point precision without a fixed spatial truncation.
27
+ Cartesian, legacy polar, explicit N-D polar, both boundaries, normalized
28
+ moments, prefill, and fallback now share this nonnegative spatial kernel.
29
+ Numerical results change, particularly for widths near or below one grid cell.
30
+ - Preserve the previous exponential response as `'gaussian_spectral'` for
31
+ reproducing v1.0.0 Gaussian results, including per-axis hybrid selections.
32
+ Its spatial weights can be signed; it is not the default for statistics.
33
+
34
+ ### Fixed
35
+ - Remove substantive negative raw variances caused by Gaussian spatial ringing.
36
+ The final variance clamp remains for floating-point cancellation. Positivity
37
+ requires nonnegative kernels on every selected axis; fractional analytical
38
+ boxcars and `'gaussian_spectral'` do not provide that guarantee.
39
+ - Evaluate broad sampled Gaussians with an equivalent frequency-alias sum,
40
+ avoiding allocations proportional to potentially enormous effective widths.
41
+
42
+ ### Documentation
43
+ - Document sampled Gaussian normalization, numerical evaluation, narrow-width
44
+ behavior, statistical guarantees, and migration to `'gaussian_spectral'`.
45
+ - Clarify required N-D polar metadata, the default physical width units, and
46
+ coordinate dependencies; add a runnable coordinate-free grid-width example.
47
+ - Compare Cartesian and explicit polar N-D processing, including boundaries,
48
+ axis selection, and the conditions for equivalent finite-data smoothing.
49
+ - Explain input-NaN restoration and the low-level polar width argument in the README.
50
+
8
51
  ## [1.0.0] - 2026-10-26
9
52
 
10
53
  Geometry-aware N-D polar release, preserving the v0.6.0 legacy 2D interface.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dct-toolkit
3
- Version: 1.0.0
3
+ Version: 2.0.0
4
4
  Summary: DCT/FFT-based convolutional statistics for Cartesian and polar data
5
5
  Author-email: "Jairo M. Valdivia" <Jairo.ValdiviaPrado@colorado.edu>
6
6
  License: MIT License
@@ -52,8 +52,10 @@ Description-Content-Type: text/markdown
52
52
  `dct_toolkit` provides DCT/FFT-based smoothing and normalized-convolution statistics
53
53
  for data with gaps. Runtime dependencies are NumPy and SciPy only.
54
54
 
55
- **Version: v1.0.0.** Geometry-aware N-D polar processing requires version 1.0.0
56
- or newer. Existing 2D polar calls retain their index-based width conventions.
55
+ **Version: v2.0.0.** The default Gaussian now uses the full normalized sampled
56
+ kernel. To retain pre-2.0 Gaussian results, select `'gaussian_spectral'` explicitly.
57
+ Geometry-aware N-D polar processing requires version 1.0.0 or newer. Existing 2D
58
+ polar calls retain their index-based width conventions.
57
59
 
58
60
  ## Scope
59
61
 
@@ -148,7 +150,7 @@ order. Azimuth width adapts with range:
148
150
  azimuth spacing in radians. Range width is in range gates. Gaussian
149
151
  `sigma = effective_width / sqrt(12)` on the selected axis. Filtering applies
150
152
  azimuth first, then range; both periodic and reflective azimuth support all
151
- three kernel types, including `'boxcar_discrete'`.
153
+ four kernel types, including `'boxcar_discrete'` and `'gaussian_spectral'`.
152
154
  `az_res_deg` must be a finite, positive scalar in degrees. For gapped data,
153
155
  periodic azimuth also wraps the nearest-neighbor prefill fallback across the
154
156
  sweep seam; range distances remain nonperiodic.
@@ -164,10 +166,35 @@ var = dct.dct_variance(data, width=10.0)
164
166
  std = dct.dct_std(data, width=10.0)
165
167
  ```
166
168
 
169
+ Version 2.0.0 changes the default `'gaussian'` to the **full normalized
170
+ sampled Gaussian**, evaluated to floating-point precision with no fixed spatial
171
+ cutoff. Its spatial weights are nonnegative, so normalized means stay within the
172
+ supported data range and population variances are nonnegative in exact arithmetic.
173
+ This applies to Cartesian and both polar modes, including reflective/periodic
174
+ boundaries and gap prefill. For mixed statistical kernels, use `'boxcar_discrete'`
175
+ on boxcar axes to retain this property; fractional analytical `'boxcar'` kernels
176
+ can have signed weights.
177
+
178
+ **Migration from v1.0.0 to v2.0.0:** Gaussian results change, especially at narrow widths.
179
+ Use `kernel_type="gaussian_spectral"` to retain the previous
180
+ `exp(-0.5 * (omega * sigma)**2)` response. Both Gaussian types use
181
+ `sigma = effective_grid_width / sqrt(12)`. Sampling means the new kernel's actual
182
+ second moment need not equal `sigma**2`; very small widths approach identity.
183
+ Variance still uses a numerical nonnegativity clamp; that clamp cannot repair
184
+ signed-weight statistical estimates.
185
+
186
+ For hybrid kernels, replace each previous `'gaussian'` entry with
187
+ `'gaussian_spectral'` to reproduce earlier results. For example,
188
+ `('gaussian', 'boxcar_discrete')` becomes
189
+ `('gaussian_spectral', 'boxcar_discrete')`. Set this explicitly for every
190
+ smoothing/statistics call that must retain the old response; prefill and
191
+ fallback inherit the selected kernels. Calls that omit `kernel_type` use the
192
+ new sampled Gaussian.
193
+
167
194
  ### 4) Mixed Kernels for a 3D Volume
168
195
 
169
196
  For a volume stored in **(z, y, x)** order, apply Gaussian smoothing vertically
170
- and boxcar smoothing horizontally:
197
+ and discrete boxcar smoothing horizontally:
171
198
 
172
199
  ```python
173
200
  rng = np.random.default_rng(42)
@@ -176,7 +203,7 @@ volume = rng.standard_normal((16, 32, 32)) # (z, y, x)
176
203
  smoothed = dct.dct_smooth(
177
204
  volume,
178
205
  width=(3.0, 5.0, 5.0),
179
- kernel_type=("gaussian", "boxcar", "boxcar"),
206
+ kernel_type=("gaussian", "boxcar_discrete", "boxcar_discrete"),
180
207
  )
181
208
 
182
209
  # The same kernels work with normalized statistics on data containing NaNs.
@@ -184,7 +211,7 @@ volume[5:7, 12:15, 12:15] = np.nan
184
211
  mean = dct.dct_mean(
185
212
  volume,
186
213
  width=(3.0, 5.0, 5.0),
187
- kernel_type=("gaussian", "boxcar", "boxcar"),
214
+ kernel_type=("gaussian", "boxcar_discrete", "boxcar_discrete"),
188
215
  )
189
216
  ```
190
217
 
@@ -196,7 +223,89 @@ kernel string applies that kernel to every axis. Equal widths with different
196
223
  kernel types can still produce anisotropic smoothing. Legacy polar smoothing
197
224
  accepts a kernel pair, using the `(azimuth, range)` convention shown above.
198
225
 
199
- ### 5) N-D Polar Geometry with Actual Coordinates (v1.0.0)
226
+ ### 5) N-D Polar Smoothing (v1.0.0)
227
+
228
+ #### Which inputs are required?
229
+
230
+ Use `coordinates="polar"` to select the polar backend and supply `axis_types`
231
+ to enable explicit N-D geometry. You do **not** need to supply all three of
232
+ `axis_types`, `axis_coords`, and `width_units` in every call:
233
+
234
+ | Argument | Required for explicit N-D polar processing? |
235
+ |----------|--------------------------------------------|
236
+ | `axis_types` | **Yes:** one role per array axis, in array order. |
237
+ | `axis_coords` | Only where physical width conversion or geometry dependencies need them. |
238
+ | `width_units` | **No:** defaults to `"physical"`; use `"grid"` for widths in cells. |
239
+
240
+ With `width_units="grid"`, no coordinate arrays are required. Physical filtering
241
+ needs coordinates on selected, non-singleton axes. Physical angular filtering
242
+ also needs actual positive range coordinates; physical azimuth filtering needs
243
+ elevation coordinates when that role exists. These geometry dependencies remain
244
+ required even when range/elevation are unfiltered. Coordinate entries for batch
245
+ or unfiltered axes may otherwise be `None`; supplied coordinates are always
246
+ validated.
247
+
248
+ Omitting `axis_types` does **not** infer N-D geometry: it retains the legacy
249
+ 2D `(azimuth, range)` mode described above.
250
+
251
+ #### Start with grid widths: no coordinates needed
252
+
253
+ This self-contained example filters a `(time, elevation, azimuth, range)` array
254
+ using widths in cells, with periodic azimuth and reflective boundaries elsewhere:
255
+
256
+ ```python
257
+ import numpy as np
258
+ import dct_toolkit as dct
259
+
260
+ data = np.random.default_rng(42).standard_normal((3, 3, 36, 24))
261
+ data[0, 1, :3, 8:12] = np.nan
262
+
263
+ mean_grid = dct.dct_mean(
264
+ data,
265
+ width=(3.0, 3.0, 5.0, 7.0), # time, elevation, azimuth, range cells
266
+ coordinates="polar",
267
+ axis_types=("time", "elevation", "azimuth", "range"),
268
+ width_units="grid",
269
+ boundary=("reflective", "reflective", "periodic", "reflective"),
270
+ restore_input_nan=False, # retain supported estimates inside gaps
271
+ )
272
+ ```
273
+
274
+ By default, `dct_mean` restores input NaNs. Setting `restore_input_nan=False`
275
+ keeps estimates in gaps where support exists; wholly missing independent domains
276
+ still remain NaN. To keep time slices independent, add `filter_axes=(1, 2, 3)`.
277
+ Still supply one positive width per array axis; the unfiltered time width is then
278
+ an unused placeholder.
279
+
280
+ #### How does this differ from Cartesian N-D?
281
+
282
+ Both backends already handle N-dimensional arrays. The polar extension adds
283
+ geometry and filtering controls, not just more dimensions:
284
+
285
+ | Feature | Cartesian N-D | Explicit polar N-D |
286
+ |---------|---------------|--------------------|
287
+ | Axis roles | Not specified | Named roles in array order |
288
+ | Width units | Grid cells | Grid or coordinate-based physical units |
289
+ | Angular widths | No angular metric | Physical widths adapt with range/elevation |
290
+ | Boundaries | Reflective on every axis | Reflective or periodic per axis |
291
+ | Filtered axes | All axes | `filter_axes`; batch axes excluded |
292
+
293
+ Cartesian calls need no axis metadata: `dct.dct_mean(data, width=(3, 3, 5, 7))`.
294
+ Convert physical Cartesian widths to cells yourself using each axis's spacing.
295
+ Polar physical angular widths additionally account for range and elevation, as
296
+ shown below; they are distances, not degrees or radians.
297
+
298
+ With grid widths, the same widths/kernels, every axis filtered, and all boundaries
299
+ reflective, **finite-data smoothing matches Cartesian smoothing** up to numerical
300
+ rounding. This equivalence concerns the smoothing operator, not nominal count
301
+ conventions or missing-data fallback rules.
302
+
303
+ The same polar geometry options work with `dct_smooth`, `dct_variance`, `dct_std`,
304
+ `dct_count`, and `dct_prefill`. The low-level `smooth_polar` uses `width_pixels=`
305
+ instead of `width=` and does not take `coordinates=`. See the
306
+ [API reference](docs/API_REFERENCE.md#dct_toolkitpolar) for full syntax and validation.
307
+
308
+ #### Physical widths with actual coordinates
200
309
 
201
310
  For a regular array in **(time, elevation, azimuth, range)** order:
202
311
 
@@ -6,8 +6,10 @@
6
6
  `dct_toolkit` provides DCT/FFT-based smoothing and normalized-convolution statistics
7
7
  for data with gaps. Runtime dependencies are NumPy and SciPy only.
8
8
 
9
- **Version: v1.0.0.** Geometry-aware N-D polar processing requires version 1.0.0
10
- or newer. Existing 2D polar calls retain their index-based width conventions.
9
+ **Version: v2.0.0.** The default Gaussian now uses the full normalized sampled
10
+ kernel. To retain pre-2.0 Gaussian results, select `'gaussian_spectral'` explicitly.
11
+ Geometry-aware N-D polar processing requires version 1.0.0 or newer. Existing 2D
12
+ polar calls retain their index-based width conventions.
11
13
 
12
14
  ## Scope
13
15
 
@@ -102,7 +104,7 @@ order. Azimuth width adapts with range:
102
104
  azimuth spacing in radians. Range width is in range gates. Gaussian
103
105
  `sigma = effective_width / sqrt(12)` on the selected axis. Filtering applies
104
106
  azimuth first, then range; both periodic and reflective azimuth support all
105
- three kernel types, including `'boxcar_discrete'`.
107
+ four kernel types, including `'boxcar_discrete'` and `'gaussian_spectral'`.
106
108
  `az_res_deg` must be a finite, positive scalar in degrees. For gapped data,
107
109
  periodic azimuth also wraps the nearest-neighbor prefill fallback across the
108
110
  sweep seam; range distances remain nonperiodic.
@@ -118,10 +120,35 @@ var = dct.dct_variance(data, width=10.0)
118
120
  std = dct.dct_std(data, width=10.0)
119
121
  ```
120
122
 
123
+ Version 2.0.0 changes the default `'gaussian'` to the **full normalized
124
+ sampled Gaussian**, evaluated to floating-point precision with no fixed spatial
125
+ cutoff. Its spatial weights are nonnegative, so normalized means stay within the
126
+ supported data range and population variances are nonnegative in exact arithmetic.
127
+ This applies to Cartesian and both polar modes, including reflective/periodic
128
+ boundaries and gap prefill. For mixed statistical kernels, use `'boxcar_discrete'`
129
+ on boxcar axes to retain this property; fractional analytical `'boxcar'` kernels
130
+ can have signed weights.
131
+
132
+ **Migration from v1.0.0 to v2.0.0:** Gaussian results change, especially at narrow widths.
133
+ Use `kernel_type="gaussian_spectral"` to retain the previous
134
+ `exp(-0.5 * (omega * sigma)**2)` response. Both Gaussian types use
135
+ `sigma = effective_grid_width / sqrt(12)`. Sampling means the new kernel's actual
136
+ second moment need not equal `sigma**2`; very small widths approach identity.
137
+ Variance still uses a numerical nonnegativity clamp; that clamp cannot repair
138
+ signed-weight statistical estimates.
139
+
140
+ For hybrid kernels, replace each previous `'gaussian'` entry with
141
+ `'gaussian_spectral'` to reproduce earlier results. For example,
142
+ `('gaussian', 'boxcar_discrete')` becomes
143
+ `('gaussian_spectral', 'boxcar_discrete')`. Set this explicitly for every
144
+ smoothing/statistics call that must retain the old response; prefill and
145
+ fallback inherit the selected kernels. Calls that omit `kernel_type` use the
146
+ new sampled Gaussian.
147
+
121
148
  ### 4) Mixed Kernels for a 3D Volume
122
149
 
123
150
  For a volume stored in **(z, y, x)** order, apply Gaussian smoothing vertically
124
- and boxcar smoothing horizontally:
151
+ and discrete boxcar smoothing horizontally:
125
152
 
126
153
  ```python
127
154
  rng = np.random.default_rng(42)
@@ -130,7 +157,7 @@ volume = rng.standard_normal((16, 32, 32)) # (z, y, x)
130
157
  smoothed = dct.dct_smooth(
131
158
  volume,
132
159
  width=(3.0, 5.0, 5.0),
133
- kernel_type=("gaussian", "boxcar", "boxcar"),
160
+ kernel_type=("gaussian", "boxcar_discrete", "boxcar_discrete"),
134
161
  )
135
162
 
136
163
  # The same kernels work with normalized statistics on data containing NaNs.
@@ -138,7 +165,7 @@ volume[5:7, 12:15, 12:15] = np.nan
138
165
  mean = dct.dct_mean(
139
166
  volume,
140
167
  width=(3.0, 5.0, 5.0),
141
- kernel_type=("gaussian", "boxcar", "boxcar"),
168
+ kernel_type=("gaussian", "boxcar_discrete", "boxcar_discrete"),
142
169
  )
143
170
  ```
144
171
 
@@ -150,7 +177,89 @@ kernel string applies that kernel to every axis. Equal widths with different
150
177
  kernel types can still produce anisotropic smoothing. Legacy polar smoothing
151
178
  accepts a kernel pair, using the `(azimuth, range)` convention shown above.
152
179
 
153
- ### 5) N-D Polar Geometry with Actual Coordinates (v1.0.0)
180
+ ### 5) N-D Polar Smoothing (v1.0.0)
181
+
182
+ #### Which inputs are required?
183
+
184
+ Use `coordinates="polar"` to select the polar backend and supply `axis_types`
185
+ to enable explicit N-D geometry. You do **not** need to supply all three of
186
+ `axis_types`, `axis_coords`, and `width_units` in every call:
187
+
188
+ | Argument | Required for explicit N-D polar processing? |
189
+ |----------|--------------------------------------------|
190
+ | `axis_types` | **Yes:** one role per array axis, in array order. |
191
+ | `axis_coords` | Only where physical width conversion or geometry dependencies need them. |
192
+ | `width_units` | **No:** defaults to `"physical"`; use `"grid"` for widths in cells. |
193
+
194
+ With `width_units="grid"`, no coordinate arrays are required. Physical filtering
195
+ needs coordinates on selected, non-singleton axes. Physical angular filtering
196
+ also needs actual positive range coordinates; physical azimuth filtering needs
197
+ elevation coordinates when that role exists. These geometry dependencies remain
198
+ required even when range/elevation are unfiltered. Coordinate entries for batch
199
+ or unfiltered axes may otherwise be `None`; supplied coordinates are always
200
+ validated.
201
+
202
+ Omitting `axis_types` does **not** infer N-D geometry: it retains the legacy
203
+ 2D `(azimuth, range)` mode described above.
204
+
205
+ #### Start with grid widths: no coordinates needed
206
+
207
+ This self-contained example filters a `(time, elevation, azimuth, range)` array
208
+ using widths in cells, with periodic azimuth and reflective boundaries elsewhere:
209
+
210
+ ```python
211
+ import numpy as np
212
+ import dct_toolkit as dct
213
+
214
+ data = np.random.default_rng(42).standard_normal((3, 3, 36, 24))
215
+ data[0, 1, :3, 8:12] = np.nan
216
+
217
+ mean_grid = dct.dct_mean(
218
+ data,
219
+ width=(3.0, 3.0, 5.0, 7.0), # time, elevation, azimuth, range cells
220
+ coordinates="polar",
221
+ axis_types=("time", "elevation", "azimuth", "range"),
222
+ width_units="grid",
223
+ boundary=("reflective", "reflective", "periodic", "reflective"),
224
+ restore_input_nan=False, # retain supported estimates inside gaps
225
+ )
226
+ ```
227
+
228
+ By default, `dct_mean` restores input NaNs. Setting `restore_input_nan=False`
229
+ keeps estimates in gaps where support exists; wholly missing independent domains
230
+ still remain NaN. To keep time slices independent, add `filter_axes=(1, 2, 3)`.
231
+ Still supply one positive width per array axis; the unfiltered time width is then
232
+ an unused placeholder.
233
+
234
+ #### How does this differ from Cartesian N-D?
235
+
236
+ Both backends already handle N-dimensional arrays. The polar extension adds
237
+ geometry and filtering controls, not just more dimensions:
238
+
239
+ | Feature | Cartesian N-D | Explicit polar N-D |
240
+ |---------|---------------|--------------------|
241
+ | Axis roles | Not specified | Named roles in array order |
242
+ | Width units | Grid cells | Grid or coordinate-based physical units |
243
+ | Angular widths | No angular metric | Physical widths adapt with range/elevation |
244
+ | Boundaries | Reflective on every axis | Reflective or periodic per axis |
245
+ | Filtered axes | All axes | `filter_axes`; batch axes excluded |
246
+
247
+ Cartesian calls need no axis metadata: `dct.dct_mean(data, width=(3, 3, 5, 7))`.
248
+ Convert physical Cartesian widths to cells yourself using each axis's spacing.
249
+ Polar physical angular widths additionally account for range and elevation, as
250
+ shown below; they are distances, not degrees or radians.
251
+
252
+ With grid widths, the same widths/kernels, every axis filtered, and all boundaries
253
+ reflective, **finite-data smoothing matches Cartesian smoothing** up to numerical
254
+ rounding. This equivalence concerns the smoothing operator, not nominal count
255
+ conventions or missing-data fallback rules.
256
+
257
+ The same polar geometry options work with `dct_smooth`, `dct_variance`, `dct_std`,
258
+ `dct_count`, and `dct_prefill`. The low-level `smooth_polar` uses `width_pixels=`
259
+ instead of `width=` and does not take `coordinates=`. See the
260
+ [API reference](docs/API_REFERENCE.md#dct_toolkitpolar) for full syntax and validation.
261
+
262
+ #### Physical widths with actual coordinates
154
263
 
155
264
  For a regular array in **(time, elevation, azimuth, range)** order:
156
265
 
@@ -23,7 +23,7 @@ from .stats import (
23
23
  dct_variance,
24
24
  )
25
25
 
26
- __version__ = "1.0.0"
26
+ __version__ = "2.0.0"
27
27
 
28
28
 
29
29
  __all__ = [
@@ -80,8 +80,10 @@ def dct_smooth(
80
80
  and ``az_res_deg`` must be a finite, positive real scalar.
81
81
  Geometry and kernels are validated even for all-NaN inputs. The same
82
82
  operator is used for prefill and final smoothing. Default kernel is
83
- 'gaussian'. ``min_effective_density`` and private mean controls apply
84
- to prefill only and are not passed to the low-level smoother.
83
+ the full normalized sampled 'gaussian', evaluated to floating-point
84
+ precision without a fixed spatial cutoff. Use 'gaussian_spectral' for
85
+ the v1.0.0 exponential response. ``min_effective_density`` and private
86
+ mean controls apply to prefill only and are not passed to the low-level smoother.
85
87
 
86
88
  Returns
87
89
  -------
@@ -0,0 +1,50 @@
1
+ """Full sampled Gaussian responses for reflective and periodic filters."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def _sampled_gaussian_transfer(n: int, width: float, periodic: bool = False) -> np.ndarray:
7
+ """Evaluate the normalized infinite sampled Gaussian to float64 precision.
8
+
9
+ Parameters
10
+ ----------
11
+ n : int
12
+ Axis length.
13
+ width : float
14
+ Effective grid width, with ``sigma = width / sqrt(12)``.
15
+ periodic : bool, default=False
16
+ Use RFFT frequencies for a periodic axis, otherwise DCT-II frequencies.
17
+
18
+ Returns
19
+ -------
20
+ ndarray
21
+ Response of length ``n // 2 + 1`` (periodic) or ``n`` (reflective).
22
+
23
+ Notes
24
+ -----
25
+ For sigma < 1, sum the spatial samples directly. The omitted two-sided
26
+ mass beyond offset 9 is below 4e-22. For sigma >= 1, Poisson summation
27
+ gives an equivalent sum of frequency-shifted Gaussians. The nearest
28
+ three aliases suffice: omitted terms are below float64 relative precision
29
+ throughout the represented frequency interval [0, pi]. Neither evaluation
30
+ imposes a finite spatial support or allocates memory proportional to width.
31
+ """
32
+ n_freq = n // 2 + 1 if periodic else n
33
+ sigma = width / np.sqrt(12)
34
+ if sigma == 0 or n_freq == 0:
35
+ return np.ones(n_freq)
36
+ omega = (2 if periodic else 1) * np.pi * np.arange(n_freq) / n
37
+ with np.errstate(over="ignore", under="ignore"):
38
+ if sigma < 1:
39
+ offsets = np.arange(1, 10, dtype=float)
40
+ weights = np.exp(-0.5 * (offsets / sigma) ** 2)
41
+ response = 1 + 2 * np.sum(
42
+ weights[:, None] * np.cos(offsets[:, None] * omega), axis=0
43
+ )
44
+ else:
45
+ response = np.zeros(n_freq)
46
+ for alias in (-1, 0, 1):
47
+ response += np.exp(-0.5 * ((omega + alias * 2 * np.pi) * sigma) ** 2)
48
+ response /= response[0]
49
+ response[0] = 1.0
50
+ return response
@@ -26,6 +26,6 @@ def normalize_kernel_types(kernel_type: KernelLike, ndim: int) -> Tuple[str, ...
26
26
  for axis, kernel in enumerate(kernels):
27
27
  if not isinstance(kernel, str):
28
28
  raise ValueError(f"kernel_type entries must be strings, got {kernel!r} at axis {axis}")
29
- if kernel not in ("boxcar", "boxcar_discrete", "gaussian"):
29
+ if kernel not in ("boxcar", "boxcar_discrete", "gaussian", "gaussian_spectral"):
30
30
  raise ValueError(f"Unknown kernel type: {kernel!r} at axis {axis}")
31
31
  return kernels
@@ -35,9 +35,9 @@ def smooth_cartesian(
35
35
  and follows array axis order. For Gaussian kernels,
36
36
  ``sigma = width / sqrt(12)`` along the corresponding axis.
37
37
  kernel_type : str or sequence of str, default='gaussian'
38
- Kernel type ('boxcar', 'boxcar_discrete', 'gaussian'). A string applies
39
- to every axis. A sequence must have length ``data.ndim`` and selects
40
- one kernel per axis in array order, independently of ``width``.
38
+ Kernel type ('boxcar', 'boxcar_discrete', 'gaussian', 'gaussian_spectral').
39
+ A string applies to every axis. A sequence must have length ``data.ndim``
40
+ and selects one kernel per axis in array order, independently of ``width``.
41
41
 
42
42
  Returns
43
43
  -------
@@ -55,6 +55,10 @@ def smooth_cartesian(
55
55
  Boundaries are reflective on every axis. Equal widths with different
56
56
  kernel types can still produce anisotropic smoothing. Input should be
57
57
  finite; use ``dct_mean`` or ``dct_smooth`` for data containing NaNs.
58
+ The default ``gaussian`` is the full normalized sampled Gaussian evaluated
59
+ to floating-point precision, with no fixed spatial cutoff and nonnegative
60
+ spatial weights. ``gaussian_spectral`` retains the v1.0.0 exponential
61
+ response; its spatial weights can be signed.
58
62
 
59
63
  Examples
60
64
  --------
@@ -9,6 +9,9 @@ This module provides the low-level building blocks for DCT-based smoothing:
9
9
  import numpy as np
10
10
  import scipy.fft
11
11
 
12
+ from ._gaussian import _sampled_gaussian_transfer
13
+
14
+
12
15
  def get_dct_transfer_function(n: int, kernel_type: str, width: float) -> np.ndarray:
13
16
  """
14
17
  Generate the 1D DCT Transfer Function H[k].
@@ -18,11 +21,14 @@ def get_dct_transfer_function(n: int, kernel_type: str, width: float) -> np.ndar
18
21
  n : int
19
22
  Number of points.
20
23
  kernel_type : str
21
- 'boxcar', 'boxcar_discrete', or 'gaussian'.
24
+ 'boxcar', 'boxcar_discrete', 'gaussian', or 'gaussian_spectral'.
25
+ 'gaussian' uses the full nonnegative normalized sampled Gaussian,
26
+ evaluated to floating-point precision. 'gaussian_spectral' retains
27
+ the original exponential spectral response, whose spatial weights can be signed.
22
28
  width : float
23
29
  Kernel width.
24
30
  - boxcar: full width
25
- - gaussian: equivalent boxcar width (sigma = width / sqrt(12))
31
+ - both Gaussian types: equivalent boxcar width (sigma = width / sqrt(12))
26
32
 
27
33
  Returns
28
34
  -------
@@ -72,6 +78,9 @@ def get_dct_transfer_function(n: int, kernel_type: str, width: float) -> np.ndar
72
78
  return H
73
79
 
74
80
  elif kernel_type == 'gaussian':
81
+ return _sampled_gaussian_transfer(n, width)
82
+
83
+ elif kernel_type == 'gaussian_spectral':
75
84
  # Gaussian: sigma = width / sqrt(12)
76
85
  sigma = width / np.sqrt(12)
77
86
  if sigma < 1e-9:
@@ -84,6 +93,7 @@ def get_dct_transfer_function(n: int, kernel_type: str, width: float) -> np.ndar
84
93
  else:
85
94
  raise ValueError(f"Unknown kernel type: {kernel_type}")
86
95
 
96
+
87
97
  def dct_convolve_1d(data: np.ndarray, H: np.ndarray, axis: int = -1) -> np.ndarray:
88
98
  """
89
99
  Apply 1D DCT-based convolution.
@@ -104,7 +114,9 @@ def dct_convolve_1d(data: np.ndarray, H: np.ndarray, axis: int = -1) -> np.ndarr
104
114
  """
105
115
  # Validate shapes
106
116
  if data.shape[axis] != len(H):
107
- raise ValueError(f"Transfer function length {len(H)} does not match data dimension {data.shape[axis]}")
117
+ raise ValueError(
118
+ f"Transfer function length {len(H)} does not match data dimension {data.shape[axis]}"
119
+ )
108
120
 
109
121
  # Broadcast H to data shape
110
122
  shape = [1] * data.ndim