pointgrid-rs 0.1.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.
@@ -0,0 +1,309 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "autocfg"
7
+ version = "1.5.1"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
10
+
11
+ [[package]]
12
+ name = "cfg-if"
13
+ version = "1.0.4"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
16
+
17
+ [[package]]
18
+ name = "heck"
19
+ version = "0.5.0"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
22
+
23
+ [[package]]
24
+ name = "indoc"
25
+ version = "2.0.7"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
28
+ dependencies = [
29
+ "rustversion",
30
+ ]
31
+
32
+ [[package]]
33
+ name = "lapjv"
34
+ version = "0.3.0"
35
+ source = "registry+https://github.com/rust-lang/crates.io-index"
36
+ checksum = "f66c977eaa63543f7d7bae335c66c46657ca973091340aff72ff141a50c0bfb3"
37
+ dependencies = [
38
+ "log",
39
+ "ndarray",
40
+ "num-traits",
41
+ ]
42
+
43
+ [[package]]
44
+ name = "libc"
45
+ version = "0.2.189"
46
+ source = "registry+https://github.com/rust-lang/crates.io-index"
47
+ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
48
+
49
+ [[package]]
50
+ name = "log"
51
+ version = "0.4.33"
52
+ source = "registry+https://github.com/rust-lang/crates.io-index"
53
+ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
54
+
55
+ [[package]]
56
+ name = "matrixmultiply"
57
+ version = "0.3.11"
58
+ source = "registry+https://github.com/rust-lang/crates.io-index"
59
+ checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
60
+ dependencies = [
61
+ "autocfg",
62
+ "rawpointer",
63
+ ]
64
+
65
+ [[package]]
66
+ name = "memoffset"
67
+ version = "0.9.1"
68
+ source = "registry+https://github.com/rust-lang/crates.io-index"
69
+ checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
70
+ dependencies = [
71
+ "autocfg",
72
+ ]
73
+
74
+ [[package]]
75
+ name = "ndarray"
76
+ version = "0.16.1"
77
+ source = "registry+https://github.com/rust-lang/crates.io-index"
78
+ checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
79
+ dependencies = [
80
+ "matrixmultiply",
81
+ "num-complex",
82
+ "num-integer",
83
+ "num-traits",
84
+ "portable-atomic",
85
+ "portable-atomic-util",
86
+ "rawpointer",
87
+ ]
88
+
89
+ [[package]]
90
+ name = "num-complex"
91
+ version = "0.4.6"
92
+ source = "registry+https://github.com/rust-lang/crates.io-index"
93
+ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
94
+ dependencies = [
95
+ "num-traits",
96
+ ]
97
+
98
+ [[package]]
99
+ name = "num-integer"
100
+ version = "0.1.46"
101
+ source = "registry+https://github.com/rust-lang/crates.io-index"
102
+ checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
103
+ dependencies = [
104
+ "num-traits",
105
+ ]
106
+
107
+ [[package]]
108
+ name = "num-traits"
109
+ version = "0.2.19"
110
+ source = "registry+https://github.com/rust-lang/crates.io-index"
111
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
112
+ dependencies = [
113
+ "autocfg",
114
+ ]
115
+
116
+ [[package]]
117
+ name = "numpy"
118
+ version = "0.22.1"
119
+ source = "registry+https://github.com/rust-lang/crates.io-index"
120
+ checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
121
+ dependencies = [
122
+ "libc",
123
+ "ndarray",
124
+ "num-complex",
125
+ "num-integer",
126
+ "num-traits",
127
+ "pyo3",
128
+ "rustc-hash",
129
+ ]
130
+
131
+ [[package]]
132
+ name = "once_cell"
133
+ version = "1.21.4"
134
+ source = "registry+https://github.com/rust-lang/crates.io-index"
135
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
136
+
137
+ [[package]]
138
+ name = "pointgrid-rs"
139
+ version = "0.1.0"
140
+ dependencies = [
141
+ "lapjv",
142
+ "ndarray",
143
+ "numpy",
144
+ "pyo3",
145
+ "thiserror",
146
+ ]
147
+
148
+ [[package]]
149
+ name = "portable-atomic"
150
+ version = "1.14.0"
151
+ source = "registry+https://github.com/rust-lang/crates.io-index"
152
+ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
153
+
154
+ [[package]]
155
+ name = "portable-atomic-util"
156
+ version = "0.2.7"
157
+ source = "registry+https://github.com/rust-lang/crates.io-index"
158
+ checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
159
+ dependencies = [
160
+ "portable-atomic",
161
+ ]
162
+
163
+ [[package]]
164
+ name = "proc-macro2"
165
+ version = "1.0.107"
166
+ source = "registry+https://github.com/rust-lang/crates.io-index"
167
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
168
+ dependencies = [
169
+ "unicode-ident",
170
+ ]
171
+
172
+ [[package]]
173
+ name = "pyo3"
174
+ version = "0.22.6"
175
+ source = "registry+https://github.com/rust-lang/crates.io-index"
176
+ checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
177
+ dependencies = [
178
+ "cfg-if",
179
+ "indoc",
180
+ "libc",
181
+ "memoffset",
182
+ "once_cell",
183
+ "portable-atomic",
184
+ "pyo3-build-config",
185
+ "pyo3-ffi",
186
+ "pyo3-macros",
187
+ "unindent",
188
+ ]
189
+
190
+ [[package]]
191
+ name = "pyo3-build-config"
192
+ version = "0.22.6"
193
+ source = "registry+https://github.com/rust-lang/crates.io-index"
194
+ checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
195
+ dependencies = [
196
+ "once_cell",
197
+ "target-lexicon",
198
+ ]
199
+
200
+ [[package]]
201
+ name = "pyo3-ffi"
202
+ version = "0.22.6"
203
+ source = "registry+https://github.com/rust-lang/crates.io-index"
204
+ checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
205
+ dependencies = [
206
+ "libc",
207
+ "pyo3-build-config",
208
+ ]
209
+
210
+ [[package]]
211
+ name = "pyo3-macros"
212
+ version = "0.22.6"
213
+ source = "registry+https://github.com/rust-lang/crates.io-index"
214
+ checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
215
+ dependencies = [
216
+ "proc-macro2",
217
+ "pyo3-macros-backend",
218
+ "quote",
219
+ "syn",
220
+ ]
221
+
222
+ [[package]]
223
+ name = "pyo3-macros-backend"
224
+ version = "0.22.6"
225
+ source = "registry+https://github.com/rust-lang/crates.io-index"
226
+ checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
227
+ dependencies = [
228
+ "heck",
229
+ "proc-macro2",
230
+ "pyo3-build-config",
231
+ "quote",
232
+ "syn",
233
+ ]
234
+
235
+ [[package]]
236
+ name = "quote"
237
+ version = "1.0.47"
238
+ source = "registry+https://github.com/rust-lang/crates.io-index"
239
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
240
+ dependencies = [
241
+ "proc-macro2",
242
+ ]
243
+
244
+ [[package]]
245
+ name = "rawpointer"
246
+ version = "0.2.1"
247
+ source = "registry+https://github.com/rust-lang/crates.io-index"
248
+ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
249
+
250
+ [[package]]
251
+ name = "rustc-hash"
252
+ version = "1.1.0"
253
+ source = "registry+https://github.com/rust-lang/crates.io-index"
254
+ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
255
+
256
+ [[package]]
257
+ name = "rustversion"
258
+ version = "1.0.23"
259
+ source = "registry+https://github.com/rust-lang/crates.io-index"
260
+ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
261
+
262
+ [[package]]
263
+ name = "syn"
264
+ version = "2.0.119"
265
+ source = "registry+https://github.com/rust-lang/crates.io-index"
266
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
267
+ dependencies = [
268
+ "proc-macro2",
269
+ "quote",
270
+ "unicode-ident",
271
+ ]
272
+
273
+ [[package]]
274
+ name = "target-lexicon"
275
+ version = "0.12.16"
276
+ source = "registry+https://github.com/rust-lang/crates.io-index"
277
+ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
278
+
279
+ [[package]]
280
+ name = "thiserror"
281
+ version = "1.0.69"
282
+ source = "registry+https://github.com/rust-lang/crates.io-index"
283
+ checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
284
+ dependencies = [
285
+ "thiserror-impl",
286
+ ]
287
+
288
+ [[package]]
289
+ name = "thiserror-impl"
290
+ version = "1.0.69"
291
+ source = "registry+https://github.com/rust-lang/crates.io-index"
292
+ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
293
+ dependencies = [
294
+ "proc-macro2",
295
+ "quote",
296
+ "syn",
297
+ ]
298
+
299
+ [[package]]
300
+ name = "unicode-ident"
301
+ version = "1.0.24"
302
+ source = "registry+https://github.com/rust-lang/crates.io-index"
303
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
304
+
305
+ [[package]]
306
+ name = "unindent"
307
+ version = "0.2.4"
308
+ source = "registry+https://github.com/rust-lang/crates.io-index"
309
+ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
@@ -0,0 +1,16 @@
1
+ [package]
2
+ name = "pointgrid-rs"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "Native point-grid and numerical primitives for Digger Solo"
6
+
7
+ [lib]
8
+ name = "pointgrid_rs"
9
+ crate-type = ["cdylib", "rlib"]
10
+
11
+ [dependencies]
12
+ lapjv = "0.3"
13
+ ndarray = "0.16"
14
+ thiserror = "1"
15
+ pyo3 = { version = "0.22", features = ["extension-module"] }
16
+ numpy = "0.22"
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: pointgrid-rs
3
+ Version: 0.1.0
4
+ Requires-Dist: numpy
5
+ Summary: Native point-grid and numerical primitives for Digger Solo
6
+ License: MIT
7
+ Requires-Python: >=3.8
@@ -0,0 +1,14 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.0,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "pointgrid-rs"
7
+ version = "0.1.0"
8
+ description = "Native point-grid and numerical primitives for Digger Solo"
9
+ requires-python = ">=3.8"
10
+ license = { text = "MIT" }
11
+ dependencies = ["numpy"]
12
+
13
+ [tool.maturin]
14
+ features = ["pyo3/extension-module"]
@@ -0,0 +1,442 @@
1
+ //! Deterministic checkerboard point-grid alignment used by semantic maps.
2
+
3
+ use ndarray::Array2;
4
+ use numpy::{PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2, IntoPyArray};
5
+ use pyo3::exceptions::PyValueError;
6
+ use pyo3::prelude::*;
7
+ use thiserror::Error;
8
+
9
+ const DEFAULT_GRID_FILL: f64 = 0.1;
10
+
11
+ #[derive(Debug, Error, PartialEq)]
12
+ pub enum PointGridError {
13
+ #[error("{0}")]
14
+ InvalidInput(String),
15
+ #[error("linear assignment failed: {0}")]
16
+ Assignment(String),
17
+ }
18
+
19
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
20
+ pub enum BoundaryMode {
21
+ Reflect,
22
+ Wrap,
23
+ }
24
+
25
+ pub fn align_points_to_grid(points: &[[f64; 2]]) -> Result<Vec<[f64; 2]>, PointGridError> {
26
+ if points.iter().flatten().any(|value| !value.is_finite()) {
27
+ return Err(PointGridError::InvalidInput(
28
+ "points must contain only finite values".to_string(),
29
+ ));
30
+ }
31
+ if points.is_empty() {
32
+ return Ok(Vec::new());
33
+ }
34
+
35
+ let side = ((points.len() as f64 / DEFAULT_GRID_FILL).sqrt().ceil() as usize).max(2);
36
+ let x_bounds = non_degenerate_bounds(points.iter().map(|point| point[0]));
37
+ let y_bounds = non_degenerate_bounds(points.iter().map(|point| point[1]));
38
+ let x_step = (x_bounds.1 - x_bounds.0) / side as f64;
39
+ let y_step = (y_bounds.1 - y_bounds.0) / side as f64;
40
+ let mut open = vec![false; side * side];
41
+
42
+ for row in 0..side {
43
+ for column in 0..side {
44
+ open[row * side + column] = (row + column) % 2 == 1;
45
+ }
46
+ }
47
+
48
+ let mut aligned = vec![[0.0; 2]; points.len()];
49
+ for (point_index, point) in points.iter().enumerate() {
50
+ let mut radius_x = x_step;
51
+ let mut radius_y = y_step;
52
+ loop {
53
+ if let Some(slot) = first_open_slot(
54
+ &open, side, x_bounds.0, y_bounds.0, x_step, y_step, *point, radius_x, radius_y,
55
+ ) {
56
+ open[slot] = false;
57
+ let row = slot / side;
58
+ let column = slot % side;
59
+ aligned[point_index] = [
60
+ x_bounds.0 + column as f64 * x_step,
61
+ y_bounds.0 + row as f64 * y_step,
62
+ ];
63
+ break;
64
+ }
65
+ radius_x *= 2.0;
66
+ radius_y *= 2.0;
67
+ }
68
+ }
69
+ Ok(aligned)
70
+ }
71
+
72
+ fn non_degenerate_bounds(values: impl Iterator<Item = f64>) -> (f64, f64) {
73
+ let (minimum, maximum) = values.fold(
74
+ (f64::INFINITY, f64::NEG_INFINITY),
75
+ |(minimum, maximum), value| (minimum.min(value), maximum.max(value)),
76
+ );
77
+ if minimum == maximum {
78
+ return (minimum - 0.5, maximum + 0.5);
79
+ }
80
+ (minimum, maximum)
81
+ }
82
+
83
+ #[allow(clippy::too_many_arguments)]
84
+ fn first_open_slot(
85
+ open: &[bool],
86
+ side: usize,
87
+ x_minimum: f64,
88
+ y_minimum: f64,
89
+ x_step: f64,
90
+ y_step: f64,
91
+ point: [f64; 2],
92
+ radius_x: f64,
93
+ radius_y: f64,
94
+ ) -> Option<usize> {
95
+ for row in 0..side {
96
+ let y = y_minimum + row as f64 * y_step;
97
+ if y < point[1] - radius_y || y > point[1] + radius_y {
98
+ continue;
99
+ }
100
+ for column in 0..side {
101
+ let slot = row * side + column;
102
+ let x = x_minimum + column as f64 * x_step;
103
+ if open[slot] && x >= point[0] - radius_x && x <= point[0] + radius_x {
104
+ return Some(slot);
105
+ }
106
+ }
107
+ }
108
+ None
109
+ }
110
+
111
+ pub fn uniform_filter1d(
112
+ values: &[f64],
113
+ shape: &[usize],
114
+ size: usize,
115
+ axis: usize,
116
+ mode: BoundaryMode,
117
+ ) -> Result<Vec<f64>, PointGridError> {
118
+ if shape.is_empty() || axis >= shape.len() {
119
+ return Err(PointGridError::InvalidInput(
120
+ "axis must identify an existing array dimension".to_string(),
121
+ ));
122
+ }
123
+ if size == 0 {
124
+ return Err(PointGridError::InvalidInput(
125
+ "filter size must be greater than zero".to_string(),
126
+ ));
127
+ }
128
+ let expected_len = shape
129
+ .iter()
130
+ .try_fold(1usize, |product, dimension| product.checked_mul(*dimension));
131
+ if expected_len != Some(values.len()) || shape[axis] == 0 {
132
+ return Err(PointGridError::InvalidInput(
133
+ "shape does not match a non-empty input array".to_string(),
134
+ ));
135
+ }
136
+ if values.iter().any(|value| !value.is_finite()) {
137
+ return Err(PointGridError::InvalidInput(
138
+ "values must contain only finite numbers".to_string(),
139
+ ));
140
+ }
141
+
142
+ let axis_len = shape[axis];
143
+ let inner_stride = shape[axis + 1..].iter().product::<usize>();
144
+ let outer_count = values.len() / (axis_len * inner_stride);
145
+ let mut filtered = vec![0.0; values.len()];
146
+
147
+ for outer in 0..outer_count {
148
+ for inner in 0..inner_stride {
149
+ let base = outer * axis_len * inner_stride + inner;
150
+ for position in 0..axis_len {
151
+ let start = position as isize - (size / 2) as isize;
152
+ let sum = (0..size)
153
+ .map(|offset| boundary_index(start + offset as isize, axis_len, mode))
154
+ .map(|source| values[base + source * inner_stride])
155
+ .sum::<f64>();
156
+ filtered[base + position * inner_stride] = sum / size as f64;
157
+ }
158
+ }
159
+ }
160
+ Ok(filtered)
161
+ }
162
+
163
+ fn boundary_index(index: isize, len: usize, mode: BoundaryMode) -> usize {
164
+ if len == 1 {
165
+ return 0;
166
+ }
167
+ match mode {
168
+ BoundaryMode::Wrap => index.rem_euclid(len as isize) as usize,
169
+ BoundaryMode::Reflect => {
170
+ let period = (2 * len) as isize;
171
+ let reflected = index.rem_euclid(period);
172
+ if reflected < len as isize {
173
+ reflected as usize
174
+ } else {
175
+ (period - reflected - 1) as usize
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ pub fn linear_sum_assignment(
182
+ costs: &[f64],
183
+ rows: usize,
184
+ columns: usize,
185
+ ) -> Result<Vec<usize>, PointGridError> {
186
+ if rows == 0 || rows != columns || costs.len() != rows * columns {
187
+ return Err(PointGridError::InvalidInput(
188
+ "cost matrix must be a non-empty square matrix".to_string(),
189
+ ));
190
+ }
191
+ if costs.iter().any(|cost| !cost.is_finite()) {
192
+ return Err(PointGridError::InvalidInput(
193
+ "cost matrix must contain only finite values".to_string(),
194
+ ));
195
+ }
196
+ let matrix = Array2::from_shape_vec((rows, columns), costs.to_vec())
197
+ .map_err(|error| PointGridError::InvalidInput(error.to_string()))?;
198
+ let (columns_by_row, _) =
199
+ lapjv::lapjv(&matrix).map_err(|error| PointGridError::Assignment(error.to_string()))?;
200
+ Ok(columns_by_row)
201
+ }
202
+
203
+ #[cfg(test)]
204
+ mod tests {
205
+ use super::*;
206
+ use std::collections::HashSet;
207
+
208
+ #[test]
209
+ fn grid_alignment_is_deterministic_unique_and_ordered() {
210
+ let points = [[0.0, 0.0], [0.1, 0.2], [0.9, 1.0], [0.4, 0.6]];
211
+ let first = align_points_to_grid(&points).unwrap();
212
+ let second = align_points_to_grid(&points).unwrap();
213
+ assert_eq!(first, second);
214
+ assert_eq!(first.len(), points.len());
215
+ let unique: HashSet<_> = first
216
+ .iter()
217
+ .map(|point| (point[0].to_bits(), point[1].to_bits()))
218
+ .collect();
219
+ assert_eq!(unique.len(), points.len());
220
+ assert!(first.iter().all(|point| point[0] >= 0.0 && point[0] <= 0.9));
221
+ assert!(first.iter().all(|point| point[1] >= 0.0 && point[1] <= 1.0));
222
+ }
223
+
224
+ #[test]
225
+ fn grid_alignment_handles_degenerate_axes() {
226
+ let result = align_points_to_grid(&[[2.0, 4.0], [2.0, 5.0], [2.0, 6.0]]).unwrap();
227
+ assert!(result.iter().flatten().all(|value| value.is_finite()));
228
+ assert_eq!(result.len(), 3);
229
+ }
230
+
231
+ #[test]
232
+ fn grid_alignment_matches_seeded_python_golden_output() {
233
+ let points = [[0.0, 0.0], [0.1, 0.2], [0.9, 1.0], [0.4, 0.6]];
234
+ let expected = [
235
+ [0.1285714285714286, 0.0],
236
+ [0.0, 0.14285714285714285],
237
+ [0.6428571428571429, 0.8571428571428571],
238
+ [0.3857142857142858, 0.5714285714285714],
239
+ ];
240
+ let actual = align_points_to_grid(&points).unwrap();
241
+ assert!(actual.iter().zip(expected).all(|(actual, expected)| {
242
+ (actual[0] - expected[0]).abs() < 1e-12 && (actual[1] - expected[1]).abs() < 1e-12
243
+ }));
244
+ }
245
+
246
+ #[test]
247
+ fn grid_alignment_rejects_non_finite_values() {
248
+ assert!(align_points_to_grid(&[[f64::NAN, 0.0]]).is_err());
249
+ }
250
+
251
+ #[test]
252
+ fn uniform_filter_matches_scipy_golden_values() {
253
+ let values = [1.0, 2.0, 3.0, 4.0];
254
+ assert_eq!(
255
+ uniform_filter1d(&values, &[4], 3, 0, BoundaryMode::Reflect).unwrap(),
256
+ vec![4.0 / 3.0, 2.0, 3.0, 11.0 / 3.0]
257
+ );
258
+ assert_eq!(
259
+ uniform_filter1d(&values, &[4], 3, 0, BoundaryMode::Wrap).unwrap(),
260
+ vec![7.0 / 3.0, 2.0, 3.0, 8.0 / 3.0]
261
+ );
262
+ }
263
+
264
+ #[test]
265
+ fn uniform_filter_supports_multiple_axes_and_even_kernels() {
266
+ let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
267
+ assert_eq!(
268
+ uniform_filter1d(&values, &[2, 3], 2, 1, BoundaryMode::Reflect).unwrap(),
269
+ vec![1.0, 1.5, 2.5, 4.0, 4.5, 5.5]
270
+ );
271
+ assert_eq!(
272
+ uniform_filter1d(&values, &[2, 3], 2, 0, BoundaryMode::Wrap).unwrap(),
273
+ vec![2.5, 3.5, 4.5, 2.5, 3.5, 4.5]
274
+ );
275
+ }
276
+
277
+ #[test]
278
+ fn uniform_filter_matches_multidimensional_scipy_fixtures() {
279
+ let values = (1..=12).map(f64::from).collect::<Vec<_>>();
280
+ let cases = [
281
+ (
282
+ 0,
283
+ 2,
284
+ BoundaryMode::Reflect,
285
+ vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],
286
+ ),
287
+ (
288
+ 1,
289
+ 3,
290
+ BoundaryMode::Reflect,
291
+ vec![
292
+ 1.6666666666666667,
293
+ 2.6666666666666665,
294
+ 3.0,
295
+ 4.0,
296
+ 4.333333333333333,
297
+ 5.333333333333333,
298
+ 7.666666666666667,
299
+ 8.666666666666666,
300
+ 9.0,
301
+ 10.0,
302
+ 10.333333333333334,
303
+ 11.333333333333334,
304
+ ],
305
+ ),
306
+ (
307
+ 2,
308
+ 5,
309
+ BoundaryMode::Reflect,
310
+ vec![1.6, 1.4, 3.6, 3.4, 5.6, 5.4, 7.6, 7.4, 9.6, 9.4, 11.6, 11.4],
311
+ ),
312
+ (
313
+ 0,
314
+ 3,
315
+ BoundaryMode::Wrap,
316
+ vec![5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
317
+ ),
318
+ (
319
+ 1,
320
+ 2,
321
+ BoundaryMode::Wrap,
322
+ vec![
323
+ 3.0, 4.0, 2.0, 3.0, 4.0, 5.0, 9.0, 10.0, 8.0, 9.0, 10.0, 11.0,
324
+ ],
325
+ ),
326
+ (
327
+ 2,
328
+ 4,
329
+ BoundaryMode::Wrap,
330
+ vec![1.5, 1.5, 3.5, 3.5, 5.5, 5.5, 7.5, 7.5, 9.5, 9.5, 11.5, 11.5],
331
+ ),
332
+ ];
333
+ for (axis, size, mode, expected) in cases {
334
+ let actual = uniform_filter1d(&values, &[2, 3, 2], size, axis, mode).unwrap();
335
+ assert!(actual
336
+ .iter()
337
+ .zip(expected)
338
+ .all(|(actual, expected)| (actual - expected).abs() < 1e-12));
339
+ }
340
+ }
341
+
342
+ #[test]
343
+ fn assignment_finds_the_optimal_permutation() {
344
+ let costs = [4.0, 1.0, 3.0, 2.0, 0.0, 5.0, 3.0, 2.0, 2.0];
345
+ assert_eq!(linear_sum_assignment(&costs, 3, 3).unwrap(), vec![1, 0, 2]);
346
+ }
347
+
348
+ #[test]
349
+ fn malformed_assignment_matrices_are_rejected() {
350
+ assert!(linear_sum_assignment(&[1.0, 2.0], 1, 2).is_err());
351
+ assert!(linear_sum_assignment(&[], 0, 0).is_err());
352
+ }
353
+
354
+ #[test]
355
+ fn assignment_handles_ties_and_flas_sized_matrices() {
356
+ let tied = linear_sum_assignment(&[0.0; 16], 4, 4).unwrap();
357
+ let mut sorted = tied.clone();
358
+ sorted.sort_unstable();
359
+ assert_eq!(sorted, vec![0, 1, 2, 3]);
360
+
361
+ let side = 49usize;
362
+ let costs = (0..side)
363
+ .flat_map(|row| (0..side).map(move |column| row.abs_diff(column) as f64))
364
+ .collect::<Vec<_>>();
365
+ assert_eq!(
366
+ linear_sum_assignment(&costs, side, side).unwrap(),
367
+ (0..side).collect::<Vec<_>>()
368
+ );
369
+ }
370
+ }
371
+
372
+ #[pymodule]
373
+ fn pointgrid_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
374
+ m.add_function(wrap_pyfunction!(py_align_points_to_grid, m)?)?;
375
+ m.add_function(wrap_pyfunction!(py_uniform_filter1d, m)?)?;
376
+ m.add_function(wrap_pyfunction!(py_linear_sum_assignment, m)?)?;
377
+ Ok(())
378
+ }
379
+
380
+ #[pyfunction(name = "align_points_to_grid")]
381
+ fn py_align_points_to_grid<'py>(
382
+ py: Python<'py>,
383
+ points: PyReadonlyArray2<'py, f64>,
384
+ ) -> PyResult<Bound<'py, PyArray2<f64>>> {
385
+ let points_array = points.as_array();
386
+ let (n, cols) = (points_array.shape()[0], points_array.shape()[1]);
387
+
388
+ if cols != 2 {
389
+ return Err(PyValueError::new_err(
390
+ "points must have shape (n, 2)",
391
+ ));
392
+ }
393
+
394
+ let points_vec: Vec<[f64; 2]> = (0..n)
395
+ .map(|i| [points_array[[i, 0]], points_array[[i, 1]]])
396
+ .collect();
397
+
398
+ let result = align_points_to_grid(&points_vec).map_err(|e| PyValueError::new_err(e.to_string()))?;
399
+
400
+ let result_flat: Vec<f64> = result.iter().flat_map(|p| [p[0], p[1]]).collect();
401
+ let result_2d = ndarray::Array2::from_shape_vec((n, 2), result_flat)
402
+ .map_err(|e| PyValueError::new_err(format!("array creation failed: {}", e)))?;
403
+
404
+ Ok(result_2d.into_pyarray_bound(py))
405
+ }
406
+
407
+ #[pyfunction(name = "uniform_filter1d")]
408
+ fn py_uniform_filter1d<'py>(
409
+ py: Python<'py>,
410
+ values: PyReadonlyArray1<'py, f64>,
411
+ shape: Vec<usize>,
412
+ size: usize,
413
+ axis: usize,
414
+ mode: &str,
415
+ ) -> PyResult<Bound<'py, PyArray1<f64>>> {
416
+ let boundary_mode = match mode {
417
+ "reflect" => BoundaryMode::Reflect,
418
+ "wrap" => BoundaryMode::Wrap,
419
+ _ => return Err(PyValueError::new_err("mode must be 'reflect' or 'wrap'")),
420
+ };
421
+
422
+ let result = uniform_filter1d(values.as_slice()?, &shape, size, axis, boundary_mode)
423
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
424
+
425
+ Ok(result.into_pyarray_bound(py))
426
+ }
427
+
428
+ #[pyfunction(name = "linear_sum_assignment")]
429
+ fn py_linear_sum_assignment<'py>(
430
+ py: Python<'py>,
431
+ costs: PyReadonlyArray2<'py, f64>,
432
+ ) -> PyResult<Bound<'py, PyArray1<usize>>> {
433
+ let costs_array = costs.as_array();
434
+ let (rows, columns) = (costs_array.shape()[0], costs_array.shape()[1]);
435
+
436
+ let costs_vec: Vec<f64> = costs_array.iter().copied().collect();
437
+
438
+ let result = linear_sum_assignment(&costs_vec, rows, columns)
439
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
440
+
441
+ Ok(result.into_pyarray_bound(py))
442
+ }