mass-screen-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.
- mass_screen_rs-0.1.0/.cargo/config.toml +12 -0
- mass_screen_rs-0.1.0/Cargo.lock +280 -0
- mass_screen_rs-0.1.0/Cargo.toml +22 -0
- mass_screen_rs-0.1.0/LICENSE +29 -0
- mass_screen_rs-0.1.0/PKG-INFO +62 -0
- mass_screen_rs-0.1.0/README.md +35 -0
- mass_screen_rs-0.1.0/pyproject.toml +78 -0
- mass_screen_rs-0.1.0/python/mass_screen_rs/__init__.py +11 -0
- mass_screen_rs-0.1.0/python/mass_screen_rs/screen.py +55 -0
- mass_screen_rs-0.1.0/python/mass_screen_rs/tests/__init__.py +0 -0
- mass_screen_rs-0.1.0/python/mass_screen_rs/tests/data/torch_reference.npz +0 -0
- mass_screen_rs-0.1.0/python/mass_screen_rs/tests/make_torch_reference.py +63 -0
- mass_screen_rs-0.1.0/python/mass_screen_rs/tests/test_mass_screen.py +95 -0
- mass_screen_rs-0.1.0/python/mass_screen_rs/tests/test_torch_parity.py +31 -0
- mass_screen_rs-0.1.0/src/api.rs +50 -0
- mass_screen_rs-0.1.0/src/lib.rs +31 -0
- mass_screen_rs-0.1.0/src/screen.rs +180 -0
- mass_screen_rs-0.1.0/src/tests/mod.rs +1 -0
- mass_screen_rs-0.1.0/src/tests/screen.rs +65 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# See https://pyo3.rs/latest/building-and-distribution.html#macos
|
|
2
|
+
[target.x86_64-apple-darwin]
|
|
3
|
+
rustflags = [
|
|
4
|
+
"-C", "link-arg=-undefined",
|
|
5
|
+
"-C", "link-arg=dynamic_lookup",
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
[target.aarch64-apple-darwin]
|
|
9
|
+
rustflags = [
|
|
10
|
+
"-C", "link-arg=-undefined",
|
|
11
|
+
"-C", "link-arg=dynamic_lookup",
|
|
12
|
+
]
|
|
@@ -0,0 +1,280 @@
|
|
|
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 = "crossbeam-deque"
|
|
13
|
+
version = "0.8.8"
|
|
14
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
15
|
+
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"crossbeam-epoch",
|
|
18
|
+
"crossbeam-utils",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[[package]]
|
|
22
|
+
name = "crossbeam-epoch"
|
|
23
|
+
version = "0.9.21"
|
|
24
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
25
|
+
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
|
26
|
+
dependencies = [
|
|
27
|
+
"crossbeam-utils",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[package]]
|
|
31
|
+
name = "crossbeam-utils"
|
|
32
|
+
version = "0.8.23"
|
|
33
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
34
|
+
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
|
35
|
+
|
|
36
|
+
[[package]]
|
|
37
|
+
name = "either"
|
|
38
|
+
version = "1.19.0"
|
|
39
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
40
|
+
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
|
41
|
+
|
|
42
|
+
[[package]]
|
|
43
|
+
name = "heck"
|
|
44
|
+
version = "0.5.0"
|
|
45
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
46
|
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
47
|
+
|
|
48
|
+
[[package]]
|
|
49
|
+
name = "libc"
|
|
50
|
+
version = "0.2.191"
|
|
51
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
52
|
+
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
|
53
|
+
|
|
54
|
+
[[package]]
|
|
55
|
+
name = "mass-screen-rs"
|
|
56
|
+
version = "0.1.0"
|
|
57
|
+
dependencies = [
|
|
58
|
+
"numpy",
|
|
59
|
+
"pyo3",
|
|
60
|
+
"rayon",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[[package]]
|
|
64
|
+
name = "matrixmultiply"
|
|
65
|
+
version = "0.3.11"
|
|
66
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
67
|
+
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
|
68
|
+
dependencies = [
|
|
69
|
+
"autocfg",
|
|
70
|
+
"rawpointer",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[[package]]
|
|
74
|
+
name = "ndarray"
|
|
75
|
+
version = "0.17.2"
|
|
76
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
77
|
+
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
|
78
|
+
dependencies = [
|
|
79
|
+
"matrixmultiply",
|
|
80
|
+
"num-complex",
|
|
81
|
+
"num-integer",
|
|
82
|
+
"num-traits",
|
|
83
|
+
"portable-atomic",
|
|
84
|
+
"portable-atomic-util",
|
|
85
|
+
"rawpointer",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[[package]]
|
|
89
|
+
name = "num-complex"
|
|
90
|
+
version = "0.4.6"
|
|
91
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
92
|
+
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
|
93
|
+
dependencies = [
|
|
94
|
+
"num-traits",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
[[package]]
|
|
98
|
+
name = "num-integer"
|
|
99
|
+
version = "0.1.47"
|
|
100
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
101
|
+
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
|
102
|
+
dependencies = [
|
|
103
|
+
"num-traits",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
[[package]]
|
|
107
|
+
name = "num-traits"
|
|
108
|
+
version = "0.2.19"
|
|
109
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
110
|
+
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
|
111
|
+
dependencies = [
|
|
112
|
+
"autocfg",
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
[[package]]
|
|
116
|
+
name = "numpy"
|
|
117
|
+
version = "0.30.0"
|
|
118
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
119
|
+
checksum = "aa0b9111761885449e894981fa8051dcf69e0bd114aecbd4bcdd830b6be4ed92"
|
|
120
|
+
dependencies = [
|
|
121
|
+
"libc",
|
|
122
|
+
"ndarray",
|
|
123
|
+
"num-complex",
|
|
124
|
+
"num-integer",
|
|
125
|
+
"num-traits",
|
|
126
|
+
"pyo3",
|
|
127
|
+
"pyo3-build-config",
|
|
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 = "portable-atomic"
|
|
139
|
+
version = "1.15.0"
|
|
140
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
141
|
+
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
|
142
|
+
|
|
143
|
+
[[package]]
|
|
144
|
+
name = "portable-atomic-util"
|
|
145
|
+
version = "0.2.8"
|
|
146
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
147
|
+
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
|
|
148
|
+
dependencies = [
|
|
149
|
+
"portable-atomic",
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
[[package]]
|
|
153
|
+
name = "proc-macro2"
|
|
154
|
+
version = "1.0.107"
|
|
155
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
156
|
+
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
157
|
+
dependencies = [
|
|
158
|
+
"unicode-ident",
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
[[package]]
|
|
162
|
+
name = "pyo3"
|
|
163
|
+
version = "0.30.1"
|
|
164
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
165
|
+
checksum = "229ca13b8a15bb0d1487b81ab975bfcd97513416499a8312011b6036e42e0de4"
|
|
166
|
+
dependencies = [
|
|
167
|
+
"libc",
|
|
168
|
+
"once_cell",
|
|
169
|
+
"portable-atomic",
|
|
170
|
+
"pyo3-build-config",
|
|
171
|
+
"pyo3-ffi",
|
|
172
|
+
"pyo3-macros",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
[[package]]
|
|
176
|
+
name = "pyo3-build-config"
|
|
177
|
+
version = "0.30.1"
|
|
178
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
179
|
+
checksum = "70130e2b6dd1ee589a8788026a429561ca5c40fa7a2fa7914c2282eddae24700"
|
|
180
|
+
dependencies = [
|
|
181
|
+
"target-lexicon",
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
[[package]]
|
|
185
|
+
name = "pyo3-ffi"
|
|
186
|
+
version = "0.30.1"
|
|
187
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
188
|
+
checksum = "5e7ff73c555c286d212d4ce7e5fbda845a973c4f61bd5f160f5f2c3c2a04d1c4"
|
|
189
|
+
dependencies = [
|
|
190
|
+
"libc",
|
|
191
|
+
"pyo3-build-config",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
[[package]]
|
|
195
|
+
name = "pyo3-macros"
|
|
196
|
+
version = "0.30.1"
|
|
197
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
198
|
+
checksum = "c46d2e5862c7bb8207e3b639f713dda3c499933444784154c11d013c5a691b60"
|
|
199
|
+
dependencies = [
|
|
200
|
+
"proc-macro2",
|
|
201
|
+
"pyo3-macros-backend",
|
|
202
|
+
"quote",
|
|
203
|
+
"syn",
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
[[package]]
|
|
207
|
+
name = "pyo3-macros-backend"
|
|
208
|
+
version = "0.30.1"
|
|
209
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
210
|
+
checksum = "38b89f066f2ae72a2aef52d1537ca2fcd104440ebfadee06e97aa90738a25b9b"
|
|
211
|
+
dependencies = [
|
|
212
|
+
"heck",
|
|
213
|
+
"proc-macro2",
|
|
214
|
+
"quote",
|
|
215
|
+
"syn",
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
[[package]]
|
|
219
|
+
name = "quote"
|
|
220
|
+
version = "1.0.47"
|
|
221
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
222
|
+
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
223
|
+
dependencies = [
|
|
224
|
+
"proc-macro2",
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
[[package]]
|
|
228
|
+
name = "rawpointer"
|
|
229
|
+
version = "0.2.1"
|
|
230
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
231
|
+
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
|
232
|
+
|
|
233
|
+
[[package]]
|
|
234
|
+
name = "rayon"
|
|
235
|
+
version = "1.12.0"
|
|
236
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
237
|
+
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
|
238
|
+
dependencies = [
|
|
239
|
+
"either",
|
|
240
|
+
"rayon-core",
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
[[package]]
|
|
244
|
+
name = "rayon-core"
|
|
245
|
+
version = "1.13.0"
|
|
246
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
247
|
+
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
|
248
|
+
dependencies = [
|
|
249
|
+
"crossbeam-deque",
|
|
250
|
+
"crossbeam-utils",
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
[[package]]
|
|
254
|
+
name = "rustc-hash"
|
|
255
|
+
version = "2.1.3"
|
|
256
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
257
|
+
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
|
258
|
+
|
|
259
|
+
[[package]]
|
|
260
|
+
name = "syn"
|
|
261
|
+
version = "3.0.9"
|
|
262
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
263
|
+
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
|
264
|
+
dependencies = [
|
|
265
|
+
"proc-macro2",
|
|
266
|
+
"quote",
|
|
267
|
+
"unicode-ident",
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
[[package]]
|
|
271
|
+
name = "target-lexicon"
|
|
272
|
+
version = "0.13.5"
|
|
273
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
274
|
+
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|
275
|
+
|
|
276
|
+
[[package]]
|
|
277
|
+
name = "unicode-ident"
|
|
278
|
+
version = "1.0.27"
|
|
279
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
280
|
+
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "mass-screen-rs"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "Rust backend for the MUSE data-driven response mask (mass screen)"
|
|
6
|
+
license = "BSD-3-Clause"
|
|
7
|
+
repository = "https://github.com/LM-SAL/mass-screen-rs"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
|
|
10
|
+
[lib]
|
|
11
|
+
name = "mass_screen_rs"
|
|
12
|
+
crate-type = ["cdylib"]
|
|
13
|
+
|
|
14
|
+
[dependencies]
|
|
15
|
+
numpy = "0.30"
|
|
16
|
+
pyo3 = { version = "0.30", features = ["extension-module"] }
|
|
17
|
+
rayon = "1"
|
|
18
|
+
|
|
19
|
+
[profile.release]
|
|
20
|
+
lto = true
|
|
21
|
+
codegen-units = 1
|
|
22
|
+
strip = "symbols"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, The MUSE Instrument Team @ LMSAL
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mass-screen-rs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Programming Language :: Python :: 3
|
|
5
|
+
Classifier: Programming Language :: Rust
|
|
6
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
7
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
8
|
+
Requires-Dist: numpy>=2.0.0
|
|
9
|
+
Requires-Dist: mass-screen-rs[tests,docs] ; extra == 'dev'
|
|
10
|
+
Requires-Dist: sphinx>=8 ; extra == 'docs'
|
|
11
|
+
Requires-Dist: furo ; extra == 'docs'
|
|
12
|
+
Requires-Dist: sphinx-copybutton ; extra == 'docs'
|
|
13
|
+
Requires-Dist: pytest ; extra == 'tests'
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Provides-Extra: docs
|
|
16
|
+
Provides-Extra: tests
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Summary: Rust backend for the MUSE data-driven response mask (mass screen)
|
|
19
|
+
Author-email: Nabil Freij <nabil.freij@gmail.com>
|
|
20
|
+
License-Expression: BSD-3-Clause
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
23
|
+
Project-URL: Documentation, https://mass-screen-rs.readthedocs.io
|
|
24
|
+
Project-URL: Issues, https://github.com/LM-SAL/mass-screen-rs/issues
|
|
25
|
+
Project-URL: Source, https://github.com/LM-SAL/mass-screen-rs
|
|
26
|
+
|
|
27
|
+
# mass-screen-rs
|
|
28
|
+
|
|
29
|
+
The MUSE data-driven response mask ("mass screen") with a Rust core and a Python API.
|
|
30
|
+
|
|
31
|
+
`mass_screen(response, spectra, threshold=..., fill_value=...)` takes the response matrix
|
|
32
|
+
`[n, m]` (spectral pixel × VDEM component) and the spectra `[n, b]` (spectral pixel × image
|
|
33
|
+
pixel) and returns a float32 array `[b, m]` with 1.0 where a component can produce at least
|
|
34
|
+
`threshold` of signal for that pixel and NaN where it cannot, after replacing spectra `<= 0` by
|
|
35
|
+
`fill_value`. It computes the same mask as the PyTorch reference in `muse.inversions` and
|
|
36
|
+
replaces the C++ kernel muse used to build at install time. The tests check it against a recorded
|
|
37
|
+
PyTorch mask.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install mass-screen-rs
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Wheels are published for Linux (x86-64), macOS (arm64) and Windows, Python 3.12 to 3.15.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install maturin pytest
|
|
51
|
+
maturin develop --release # build into the active environment
|
|
52
|
+
cargo test # Rust unit tests
|
|
53
|
+
pytest --pyargs mass_screen_rs # Python tests, including parity with the recorded PyTorch mask
|
|
54
|
+
prek run --all-files # lint hooks (ruff, docformatter, cargo fmt, clippy, cargo test)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
With `torch` installed, the parity test also checks a fresh random case against PyTorch directly,
|
|
58
|
+
and `python -m mass_screen_rs.tests.make_torch_reference` re-records the reference.
|
|
59
|
+
|
|
60
|
+
Releases: bump `version` in `Cargo.toml` (the only place it is set), merge, then tag `vX.Y.Z` on
|
|
61
|
+
`main`; the workflow builds the wheels and the sdist and uploads them to PyPI.
|
|
62
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# mass-screen-rs
|
|
2
|
+
|
|
3
|
+
The MUSE data-driven response mask ("mass screen") with a Rust core and a Python API.
|
|
4
|
+
|
|
5
|
+
`mass_screen(response, spectra, threshold=..., fill_value=...)` takes the response matrix
|
|
6
|
+
`[n, m]` (spectral pixel × VDEM component) and the spectra `[n, b]` (spectral pixel × image
|
|
7
|
+
pixel) and returns a float32 array `[b, m]` with 1.0 where a component can produce at least
|
|
8
|
+
`threshold` of signal for that pixel and NaN where it cannot, after replacing spectra `<= 0` by
|
|
9
|
+
`fill_value`. It computes the same mask as the PyTorch reference in `muse.inversions` and
|
|
10
|
+
replaces the C++ kernel muse used to build at install time. The tests check it against a recorded
|
|
11
|
+
PyTorch mask.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install mass-screen-rs
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Wheels are published for Linux (x86-64), macOS (arm64) and Windows, Python 3.12 to 3.15.
|
|
20
|
+
|
|
21
|
+
## Development
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install maturin pytest
|
|
25
|
+
maturin develop --release # build into the active environment
|
|
26
|
+
cargo test # Rust unit tests
|
|
27
|
+
pytest --pyargs mass_screen_rs # Python tests, including parity with the recorded PyTorch mask
|
|
28
|
+
prek run --all-files # lint hooks (ruff, docformatter, cargo fmt, clippy, cargo test)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
With `torch` installed, the parity test also checks a fresh random case against PyTorch directly,
|
|
32
|
+
and `python -m mass_screen_rs.tests.make_torch_reference` re-records the reference.
|
|
33
|
+
|
|
34
|
+
Releases: bump `version` in `Cargo.toml` (the only place it is set), merge, then tag `vX.Y.Z` on
|
|
35
|
+
`main`; the workflow builds the wheels and the sdist and uploads them to PyPI.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"maturin>=1.8,<2.0",
|
|
4
|
+
]
|
|
5
|
+
build-backend = "maturin"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "mass-screen-rs"
|
|
9
|
+
# The version lives in Cargo.toml only; maturin reads it from there.
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
description = "Rust backend for the MUSE data-driven response mask (mass screen)"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = "BSD-3-Clause"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
authors = [
|
|
16
|
+
{ name = "Nabil Freij", email = "nabil.freij@gmail.com" },
|
|
17
|
+
]
|
|
18
|
+
requires-python = ">=3.12"
|
|
19
|
+
dependencies = [
|
|
20
|
+
"numpy>=2.0.0",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Rust",
|
|
25
|
+
"Topic :: Scientific/Engineering :: Astronomy",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
docs = [
|
|
31
|
+
"sphinx>=8",
|
|
32
|
+
"furo",
|
|
33
|
+
"sphinx-copybutton",
|
|
34
|
+
]
|
|
35
|
+
tests = [
|
|
36
|
+
"pytest",
|
|
37
|
+
]
|
|
38
|
+
dev = [
|
|
39
|
+
"mass-screen-rs[tests,docs]",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Source = "https://github.com/LM-SAL/mass-screen-rs"
|
|
44
|
+
Issues = "https://github.com/LM-SAL/mass-screen-rs/issues"
|
|
45
|
+
Documentation = "https://mass-screen-rs.readthedocs.io"
|
|
46
|
+
|
|
47
|
+
[tool.maturin]
|
|
48
|
+
python-source = "python"
|
|
49
|
+
module-name = "mass_screen_rs._mass_screen_rs"
|
|
50
|
+
exclude = [
|
|
51
|
+
".github/**",
|
|
52
|
+
"docs/**",
|
|
53
|
+
".readthedocs.yaml",
|
|
54
|
+
".gitignore",
|
|
55
|
+
".pre-commit-config.yaml",
|
|
56
|
+
".clippy.toml",
|
|
57
|
+
"rustfmt.toml",
|
|
58
|
+
"ruff.toml",
|
|
59
|
+
"tox.ini",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["python/mass_screen_rs/tests"]
|
|
64
|
+
|
|
65
|
+
[tool.cibuildwheel]
|
|
66
|
+
build-frontend = "build"
|
|
67
|
+
skip = ["pp*", "*-win32", "*-manylinux_i686"]
|
|
68
|
+
test-extras = ["tests"]
|
|
69
|
+
test-command = "pytest --pyargs mass_screen_rs"
|
|
70
|
+
|
|
71
|
+
[tool.docformatter]
|
|
72
|
+
recursive = true
|
|
73
|
+
wrap-summaries = 100
|
|
74
|
+
wrap-descriptions = 100
|
|
75
|
+
pre-summary-newline = true
|
|
76
|
+
close-quotes-on-newline = true
|
|
77
|
+
make-summary-multi-line = true
|
|
78
|
+
black = true
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The MUSE data-driven response mask (mass screen), in Rust.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version as _get_version
|
|
6
|
+
|
|
7
|
+
from .screen import mass_screen
|
|
8
|
+
|
|
9
|
+
__version__ = _get_version("mass-screen-rs")
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__", "mass_screen"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python API for the mass-screen-rs Rust backend.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ._mass_screen_rs import mass_screen as _mass_screen
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from numpy.typing import ArrayLike, NDArray
|
|
15
|
+
|
|
16
|
+
__all__ = ["mass_screen"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mass_screen(
|
|
20
|
+
response: ArrayLike, spectra: ArrayLike, *, threshold: float, fill_value: float
|
|
21
|
+
) -> NDArray[np.float32]:
|
|
22
|
+
"""
|
|
23
|
+
Mask the VDEM components a set of spectra cannot constrain.
|
|
24
|
+
|
|
25
|
+
Component ``m`` is kept for image pixel ``b`` when
|
|
26
|
+
``min_n(spectra[n, b] / response[n, m]) * max_n(response[n, m]) >= threshold``,
|
|
27
|
+
after replacing ``spectra <= 0`` by ``fill_value``. A component whose response is
|
|
28
|
+
nowhere positive is always masked.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
response : array_like
|
|
33
|
+
Response matrix of shape ``[n, m]``: spectral pixel × VDEM component.
|
|
34
|
+
spectra : array_like
|
|
35
|
+
Spectra of shape ``[n, b]``: spectral pixel × image pixel.
|
|
36
|
+
threshold : float
|
|
37
|
+
Smallest signal, in the units of ``spectra``, a component must be able to produce.
|
|
38
|
+
fill_value : float
|
|
39
|
+
Replaces the ``spectra`` values that are ``<= 0``; must be ``>= 0``.
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
numpy.ndarray
|
|
44
|
+
float32 array of shape ``[b, m]``: 1.0 where a component is kept, NaN where masked.
|
|
45
|
+
|
|
46
|
+
Notes
|
|
47
|
+
-----
|
|
48
|
+
The kernel runs on all cores through rayon; ``RAYON_NUM_THREADS`` caps it.
|
|
49
|
+
"""
|
|
50
|
+
response = np.ascontiguousarray(response, dtype=np.float32)
|
|
51
|
+
spectra = np.ascontiguousarray(spectra, dtype=np.float32)
|
|
52
|
+
if (response.ndim, spectra.ndim) != (2, 2):
|
|
53
|
+
msg = f"response and spectra must be 2-D, got shapes {response.shape} and {spectra.shape}"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
return _mass_screen(response, spectra, float(threshold), float(fill_value))
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Record the PyTorch reference mask for the cases ``test_torch_parity.py`` checks.
|
|
3
|
+
|
|
4
|
+
Run with torch installed: ``python -m mass_screen_rs.tests.make_torch_reference``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
DATA = Path(__file__).parent / "data" / "torch_reference.npz"
|
|
12
|
+
# name: (n, m, b, density, threshold, fill_value, seed). The first case spans several column blocks
|
|
13
|
+
# and pixel tiles of the kernel; the second is small and dense.
|
|
14
|
+
CASES = {
|
|
15
|
+
"blocks_and_tiles": (24, 2 * 8192 + 5, 3 * 64 + 9, 0.03, 2.24, 0.25, 1),
|
|
16
|
+
"small_dense": (40, 300, 50, 0.3, 0.5, 1.0, 2),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def torch_mask(response, spectra, threshold, fill_value):
|
|
21
|
+
"""
|
|
22
|
+
Return the mask of ``muse.inversions.inversions._mass_screen_eager``, computed the same way.
|
|
23
|
+
"""
|
|
24
|
+
import torch # noqa: PLC0415 - only the recording and the live test need torch
|
|
25
|
+
|
|
26
|
+
rg = torch.from_numpy(response)
|
|
27
|
+
ys = torch.where(torch.from_numpy(spectra) <= 0, fill_value, torch.from_numpy(spectra))
|
|
28
|
+
max_bnd = torch.amin(ys[:, :, None] / rg[:, None, :], dim=0, keepdims=True)
|
|
29
|
+
keep = torch.any(rg[:, None, :] * max_bnd >= threshold, dim=0)
|
|
30
|
+
return torch.where(keep, 1, float("nan")).numpy()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_case(n, m, b, density, seed):
|
|
34
|
+
"""
|
|
35
|
+
Build a random case.
|
|
36
|
+
|
|
37
|
+
The response is sparse with blocks of zero and of tiny components; about a quarter of the
|
|
38
|
+
spectra are ``<= 0``.
|
|
39
|
+
"""
|
|
40
|
+
rng = np.random.default_rng(seed)
|
|
41
|
+
response = np.where(rng.random((n, m)) < density, rng.random((n, m)), 0).astype(np.float32)
|
|
42
|
+
response[:, : m // 10] = 0
|
|
43
|
+
response[:, m // 10 : m // 5] *= np.float32(1e-10)
|
|
44
|
+
spectra = (rng.random((n, b)) * 4 - 1).astype(np.float32)
|
|
45
|
+
return response, spectra
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main():
|
|
49
|
+
arrays = {}
|
|
50
|
+
for name, (n, m, b, density, threshold, fill_value, seed) in CASES.items():
|
|
51
|
+
response, spectra = make_case(n, m, b, density, seed)
|
|
52
|
+
kept = torch_mask(response, spectra, threshold, fill_value) == 1
|
|
53
|
+
arrays[f"{name}/response"] = response
|
|
54
|
+
arrays[f"{name}/spectra"] = spectra
|
|
55
|
+
arrays[f"{name}/kept"] = kept
|
|
56
|
+
arrays[f"{name}/params"] = np.array([threshold, fill_value], dtype=np.float32)
|
|
57
|
+
print(f"{name}: {response.shape}, {spectra.shape}, kept {kept.mean() * 100:.1f} %") # noqa: T201
|
|
58
|
+
np.savez_compressed(DATA, **arrays)
|
|
59
|
+
print(f"wrote {DATA} ({DATA.stat().st_size / 1024:.0f} KiB)") # noqa: T201
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for mass_screen.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from mass_screen_rs import mass_screen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def reference(response, spectra, threshold, fill_value):
|
|
12
|
+
"""
|
|
13
|
+
Evaluate the mask rule in numpy, one cell at a time.
|
|
14
|
+
"""
|
|
15
|
+
response = np.asarray(response, dtype=np.float32)
|
|
16
|
+
spectra = np.asarray(spectra, dtype=np.float32)
|
|
17
|
+
spectra = np.where(spectra <= 0, np.float32(fill_value), spectra)
|
|
18
|
+
col_max = response.max(axis=0)
|
|
19
|
+
scale = np.divide(np.float32(threshold), col_max, out=np.zeros_like(col_max), where=col_max > 0)
|
|
20
|
+
rhs = scale * response # [n, m], same float32 arithmetic as the kernel
|
|
21
|
+
kept = np.all(spectra[:, :, None] >= rhs[:, None, :], axis=0) & (col_max > 0)
|
|
22
|
+
return np.where(kept, np.float32(1), np.float32(np.nan))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def random_case(rng, n, m, b, density=0.05):
|
|
26
|
+
response = np.where(rng.random((n, m)) < density, rng.random((n, m)), 0).astype(np.float32)
|
|
27
|
+
spectra = (rng.random((n, b)) * 4 - 1).astype(np.float32) # about a quarter <= 0
|
|
28
|
+
return response, spectra
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_muse_example():
|
|
32
|
+
response = np.array([[1, 0.5, 0.1], [0.2, 1, 0.3]], dtype=np.float32)
|
|
33
|
+
spectra = np.array([[3, 1], [4, 5]], dtype=np.float32)
|
|
34
|
+
keep = mass_screen(response, spectra, threshold=2.24, fill_value=1)
|
|
35
|
+
assert keep.dtype == np.float32
|
|
36
|
+
np.testing.assert_array_equal(keep, [[1, 1, 1], [np.nan, np.nan, 1]])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_zero_response_component_is_masked():
|
|
40
|
+
response = np.array([[1, 0, 0.1], [0.5, 0, 0.3]], dtype=np.float32)
|
|
41
|
+
spectra = np.array([[3, 4], [4, 5]], dtype=np.float32)
|
|
42
|
+
keep = mass_screen(response, spectra, threshold=2.24, fill_value=1)
|
|
43
|
+
np.testing.assert_array_equal(keep[:, 1], [np.nan, np.nan])
|
|
44
|
+
np.testing.assert_array_equal(keep[:, [0, 2]], 1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_fill_value_replaces_non_positive_spectra():
|
|
48
|
+
response = np.array([[1.0], [1.0]], dtype=np.float32)
|
|
49
|
+
spectra = np.array([[-5.0, 0.0], [3.0, 3.0]], dtype=np.float32)
|
|
50
|
+
kept = mass_screen(response, spectra, threshold=2.0, fill_value=2.5)
|
|
51
|
+
masked = mass_screen(response, spectra, threshold=2.0, fill_value=0.25)
|
|
52
|
+
np.testing.assert_array_equal(kept, 1)
|
|
53
|
+
np.testing.assert_array_equal(masked, np.nan)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize(
|
|
57
|
+
("n", "m", "b"), [(5, 7, 3), (40, 300, 50), (12, 8192 * 2 + 5, 64 * 3 + 9)]
|
|
58
|
+
)
|
|
59
|
+
def test_matches_reference(n, m, b):
|
|
60
|
+
rng = np.random.default_rng(n * m + b)
|
|
61
|
+
response, spectra = random_case(rng, n, m, b)
|
|
62
|
+
keep = mass_screen(response, spectra, threshold=2.24, fill_value=0.25)
|
|
63
|
+
assert keep.shape == (b, m)
|
|
64
|
+
np.testing.assert_array_equal(keep, reference(response, spectra, 2.24, 0.25))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_accepts_float64_and_non_contiguous_input():
|
|
68
|
+
rng = np.random.default_rng(0)
|
|
69
|
+
response, spectra = random_case(rng, 10, 20, 6)
|
|
70
|
+
keep = mass_screen(
|
|
71
|
+
response.astype(np.float64), spectra.T.copy().T, threshold=2.24, fill_value=1
|
|
72
|
+
)
|
|
73
|
+
np.testing.assert_array_equal(keep, reference(response, spectra, 2.24, 1))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize(("m", "b"), [(0, 3), (4, 0)])
|
|
77
|
+
def test_empty(m, b):
|
|
78
|
+
keep = mass_screen(
|
|
79
|
+
np.ones((2, m), np.float32), np.ones((2, b), np.float32), threshold=2.24, fill_value=1
|
|
80
|
+
)
|
|
81
|
+
assert keep.shape == (b, m)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.mark.parametrize(
|
|
85
|
+
("response", "spectra", "kwargs", "match"),
|
|
86
|
+
[
|
|
87
|
+
(np.ones((2, 3)), np.ones((3, 2)), {"threshold": 1, "fill_value": 1}, "rows"),
|
|
88
|
+
(np.ones((2, 3)), np.ones((2, 2)), {"threshold": 1, "fill_value": -1}, "fill_value"),
|
|
89
|
+
(np.ones((2, 3)), np.ones((2, 2)), {"threshold": np.nan, "fill_value": 1}, "threshold"),
|
|
90
|
+
(np.ones(3), np.ones((2, 2)), {"threshold": 1, "fill_value": 1}, "2-D"),
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
def test_rejects_bad_input(response, spectra, kwargs, match):
|
|
94
|
+
with pytest.raises(ValueError, match=match):
|
|
95
|
+
mass_screen(response, spectra, **kwargs)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The kernel reproduces the PyTorch reference mask of ``muse.inversions``.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from mass_screen_rs import mass_screen
|
|
9
|
+
from mass_screen_rs.tests.make_torch_reference import CASES, DATA, make_case, torch_mask
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize("name", list(CASES))
|
|
13
|
+
def test_matches_recorded_torch_mask(name):
|
|
14
|
+
with np.load(DATA) as data:
|
|
15
|
+
response, spectra = data[f"{name}/response"], data[f"{name}/spectra"]
|
|
16
|
+
kept, (threshold, fill_value) = data[f"{name}/kept"], data[f"{name}/params"]
|
|
17
|
+
|
|
18
|
+
keep = mass_screen(response, spectra, threshold=threshold, fill_value=fill_value)
|
|
19
|
+
|
|
20
|
+
assert keep.shape == kept.shape
|
|
21
|
+
np.testing.assert_array_equal(keep == 1, kept)
|
|
22
|
+
np.testing.assert_array_equal(np.isnan(keep), ~kept)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_matches_torch_live():
|
|
26
|
+
pytest.importorskip("torch")
|
|
27
|
+
response, spectra = make_case(30, 3000, 70, 0.05, seed=3)
|
|
28
|
+
|
|
29
|
+
keep = mass_screen(response, spectra, threshold=2.24, fill_value=0.25)
|
|
30
|
+
|
|
31
|
+
np.testing.assert_array_equal(keep, torch_mask(response, spectra, 2.24, 0.25))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//! Python entry point: validation, the output array, and the GIL release around the kernel.
|
|
2
|
+
use numpy::{PyArray2, PyArrayMethods, PyReadonlyArray2, PyUntypedArrayMethods};
|
|
3
|
+
use pyo3::exceptions::PyValueError;
|
|
4
|
+
use pyo3::prelude::{pyfunction, Bound, PyResult, Python};
|
|
5
|
+
|
|
6
|
+
use crate::screen::mass_screen_core;
|
|
7
|
+
|
|
8
|
+
/// Mask the VDEM components a set of spectra cannot constrain.
|
|
9
|
+
///
|
|
10
|
+
/// `response` is `[n, m]` and `spectra` is `[n, b]`, both C-contiguous float32. Returns a float32
|
|
11
|
+
/// array of shape `[b, m]` holding 1.0 where a component is kept and NaN where it is masked:
|
|
12
|
+
/// component `m` is kept for pixel `b` when `min_n(spectra[n, b] / response[n, m]) *
|
|
13
|
+
/// max_n(response[n, m]) >= threshold`, after replacing `spectra <= 0` by `fill_value`. A
|
|
14
|
+
/// component whose response is nowhere positive is always masked.
|
|
15
|
+
#[pyfunction]
|
|
16
|
+
#[pyo3(signature = (response, spectra, threshold, fill_value))]
|
|
17
|
+
pub(crate) fn mass_screen<'py>(
|
|
18
|
+
py: Python<'py>,
|
|
19
|
+
response: PyReadonlyArray2<'py, f32>,
|
|
20
|
+
spectra: PyReadonlyArray2<'py, f32>,
|
|
21
|
+
threshold: f32,
|
|
22
|
+
fill_value: f32,
|
|
23
|
+
) -> PyResult<Bound<'py, PyArray2<f32>>> {
|
|
24
|
+
if !threshold.is_finite() {
|
|
25
|
+
return Err(PyValueError::new_err("threshold must be finite"));
|
|
26
|
+
}
|
|
27
|
+
if !fill_value.is_finite() || fill_value < 0.0 {
|
|
28
|
+
return Err(PyValueError::new_err("fill_value must be finite and >= 0"));
|
|
29
|
+
}
|
|
30
|
+
let [n, m] = [response.shape()[0], response.shape()[1]];
|
|
31
|
+
let [n_ys, b] = [spectra.shape()[0], spectra.shape()[1]];
|
|
32
|
+
if n_ys != n {
|
|
33
|
+
return Err(PyValueError::new_err(format!(
|
|
34
|
+
"response has {n} rows but spectra has {n_ys}; both are indexed by spectral pixel"
|
|
35
|
+
)));
|
|
36
|
+
}
|
|
37
|
+
let rg = response
|
|
38
|
+
.as_slice()
|
|
39
|
+
.map_err(|_| PyValueError::new_err("response must be a C-contiguous float32 array"))?;
|
|
40
|
+
let ys = spectra
|
|
41
|
+
.as_slice()
|
|
42
|
+
.map_err(|_| PyValueError::new_err("spectra must be a C-contiguous float32 array"))?;
|
|
43
|
+
// SAFETY: the array is uninitialised until mass_screen_core has written every element,
|
|
44
|
+
// and nothing reads it before that.
|
|
45
|
+
let out = unsafe { PyArray2::<f32>::new(py, [b, m], false) };
|
|
46
|
+
let keep = unsafe { out.as_slice_mut() }
|
|
47
|
+
.map_err(|_| PyValueError::new_err("could not allocate the mask"))?;
|
|
48
|
+
py.detach(|| mass_screen_core(rg, ys, keep, n, m, b, threshold, fill_value));
|
|
49
|
+
Ok(out)
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//! Data-driven response mask for the MUSE inversions.
|
|
2
|
+
//!
|
|
3
|
+
//! For a response matrix `rg` of shape `[n, m]` (spectral pixel × VDEM component) and spectra `ys`
|
|
4
|
+
//! of shape `[n, b]` (spectral pixel × image pixel), the mask is
|
|
5
|
+
//!
|
|
6
|
+
//! ```text
|
|
7
|
+
//! keep[b, m] = 1 if min_n(ys[n, b] / rg[n, m]) * max_n(rg[n, m]) >= threshold
|
|
8
|
+
//! = NaN otherwise,
|
|
9
|
+
//! ```
|
|
10
|
+
//!
|
|
11
|
+
//! with `ys <= 0` replaced by `fill_value` first. Rewritten as `ys[n, b] >= rhs[n, m]` for every
|
|
12
|
+
//! `n`, with `rhs = threshold / max_n(rg) * rg`, only the entries with `rhs > 0` can mask a cell,
|
|
13
|
+
//! and a MUSE response is a few percent non-zero. The kernel keeps those entries per row, orders the
|
|
14
|
+
//! rows so the ones that mask most run first, and updates the flags of 64 image pixels per entry
|
|
15
|
+
//! with one vector compare.
|
|
16
|
+
mod api;
|
|
17
|
+
mod screen;
|
|
18
|
+
|
|
19
|
+
#[cfg(test)]
|
|
20
|
+
mod tests;
|
|
21
|
+
|
|
22
|
+
use pyo3::prelude::{pymodule, Bound, PyModule, PyResult, Python};
|
|
23
|
+
use pyo3::types::PyModuleMethods;
|
|
24
|
+
use pyo3::wrap_pyfunction;
|
|
25
|
+
|
|
26
|
+
#[pymodule]
|
|
27
|
+
#[pyo3(name = "_mass_screen_rs")]
|
|
28
|
+
fn mass_screen_rs(_py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
29
|
+
module.add_function(wrap_pyfunction!(api::mass_screen, module)?)?;
|
|
30
|
+
Ok(())
|
|
31
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
//! The mask kernel: compressed positive entries per row, flags laid out per column for 64 pixels.
|
|
2
|
+
use rayon::prelude::*;
|
|
3
|
+
use std::slice;
|
|
4
|
+
|
|
5
|
+
/// Image pixels per tile: a column's flags are one 64-byte line, one f32 compare covers them.
|
|
6
|
+
pub(crate) const BT: usize = 64;
|
|
7
|
+
/// Columns per block: the fail block is MBLK * BT = 512 KiB, which stays in L2 across all rows.
|
|
8
|
+
pub(crate) const MBLK: usize = 8192;
|
|
9
|
+
|
|
10
|
+
pub(crate) fn column_max(rg: &[f32], m: usize) -> Vec<f32> {
|
|
11
|
+
let merge = |mut a: Vec<f32>, b: &[f32]| {
|
|
12
|
+
for (x, &y) in a.iter_mut().zip(b) {
|
|
13
|
+
if y > *x {
|
|
14
|
+
*x = y;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
a
|
|
18
|
+
};
|
|
19
|
+
// A partial maximum per chunk of rows, not per row: each partial is an m-float vector.
|
|
20
|
+
let rows_per_chunk = (rg.len() / m)
|
|
21
|
+
.div_ceil(4 * rayon::current_num_threads())
|
|
22
|
+
.max(1);
|
|
23
|
+
rg.par_chunks(m * rows_per_chunk)
|
|
24
|
+
.map(|chunk| chunk.chunks(m).fold(vec![f32::NEG_INFINITY; m], merge))
|
|
25
|
+
.reduce(|| vec![f32::NEG_INFINITY; m], |a, b| merge(a, &b))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// One response row as (column, rhs) pairs for the entries with rhs > 0, plus block offsets.
|
|
29
|
+
struct CsrRow {
|
|
30
|
+
idx: Vec<u32>,
|
|
31
|
+
val: Vec<f32>,
|
|
32
|
+
/// `blk[q]..blk[q + 1]` is the slice of `idx`/`val` inside column block `q`.
|
|
33
|
+
blk: Vec<u32>,
|
|
34
|
+
/// Sum of `val`: rows with large rhs mask cells earliest, so they run first.
|
|
35
|
+
score: f64,
|
|
36
|
+
max_val: f32,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fn csr_row(rg_row: &[f32], scale: &[f32], nblocks: usize) -> CsrRow {
|
|
40
|
+
let mut idx = Vec::new();
|
|
41
|
+
let mut val = Vec::new();
|
|
42
|
+
let mut score = 0.0f64;
|
|
43
|
+
let mut max_val = 0.0f32;
|
|
44
|
+
for (j, (&r, &s)) in rg_row.iter().zip(scale).enumerate() {
|
|
45
|
+
let v = s * r;
|
|
46
|
+
if v > 0.0 {
|
|
47
|
+
idx.push(j as u32);
|
|
48
|
+
val.push(v);
|
|
49
|
+
score += f64::from(v);
|
|
50
|
+
max_val = max_val.max(v);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
let blk = (0..=nblocks)
|
|
54
|
+
.map(|q| idx.partition_point(|&j| (j as usize) < q * MBLK) as u32)
|
|
55
|
+
.collect();
|
|
56
|
+
CsrRow {
|
|
57
|
+
idx,
|
|
58
|
+
val,
|
|
59
|
+
blk,
|
|
60
|
+
score,
|
|
61
|
+
max_val,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
struct SyncPtr(*mut f32);
|
|
66
|
+
unsafe impl Sync for SyncPtr {}
|
|
67
|
+
unsafe impl Send for SyncPtr {}
|
|
68
|
+
impl SyncPtr {
|
|
69
|
+
// A method keeps the closure capturing the wrapper, not the bare pointer.
|
|
70
|
+
unsafe fn add(&self, off: usize) -> *mut f32 {
|
|
71
|
+
self.0.add(off)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Writes the mask into `keep` (`[b, m]`, fully overwritten). `rg` is `[n, m]` and `ys` is
|
|
76
|
+
/// `[n, b]`, all C order. Columns with no positive response are masked. Needs `fill_value >= 0`,
|
|
77
|
+
/// so that every screened value is non-negative and the zero entries of `rg` can be skipped.
|
|
78
|
+
#[allow(clippy::too_many_arguments)]
|
|
79
|
+
pub(crate) fn mass_screen_core(
|
|
80
|
+
rg: &[f32],
|
|
81
|
+
ys: &[f32],
|
|
82
|
+
keep: &mut [f32],
|
|
83
|
+
n: usize,
|
|
84
|
+
m: usize,
|
|
85
|
+
b: usize,
|
|
86
|
+
threshold: f32,
|
|
87
|
+
fill_value: f32,
|
|
88
|
+
) {
|
|
89
|
+
assert_eq!(rg.len(), n * m);
|
|
90
|
+
assert_eq!(ys.len(), n * b);
|
|
91
|
+
assert_eq!(keep.len(), b * m);
|
|
92
|
+
assert!(fill_value >= 0.0);
|
|
93
|
+
if b == 0 || m == 0 {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let col_max = column_max(rg, m);
|
|
97
|
+
let dead_cols: Vec<u32> = (0..m)
|
|
98
|
+
.filter(|&j| col_max[j].is_nan() || col_max[j] <= 0.0)
|
|
99
|
+
.map(|j| j as u32)
|
|
100
|
+
.collect();
|
|
101
|
+
let scale: Vec<f32> = col_max
|
|
102
|
+
.iter()
|
|
103
|
+
.map(|&mx| if mx > 0.0 { threshold / mx } else { 0.0 })
|
|
104
|
+
.collect();
|
|
105
|
+
let nblocks = m.div_ceil(MBLK);
|
|
106
|
+
let ntiles = b.div_ceil(BT);
|
|
107
|
+
let rows: Vec<CsrRow> = rg
|
|
108
|
+
.par_chunks(m)
|
|
109
|
+
.map(|r| csr_row(r, &scale, nblocks))
|
|
110
|
+
.collect();
|
|
111
|
+
let mut order: Vec<usize> = (0..n).filter(|&i| !rows[i].idx.is_empty()).collect();
|
|
112
|
+
order.sort_by(|&i, &j| rows[j].score.total_cmp(&rows[i].score));
|
|
113
|
+
let keep_ptr = SyncPtr(keep.as_mut_ptr());
|
|
114
|
+
|
|
115
|
+
// One flag block per worker, reused across its tasks: a fresh zeroed block per task costs
|
|
116
|
+
// a page fault per 4 KiB and showed as a 50 % slowdown on 32 threads.
|
|
117
|
+
(0..ntiles * nblocks).into_par_iter().for_each_init(
|
|
118
|
+
|| vec![0u8; MBLK * BT],
|
|
119
|
+
|fail, task| {
|
|
120
|
+
let (t, q) = (task / nblocks, task % nblocks);
|
|
121
|
+
let b0 = t * BT;
|
|
122
|
+
let bt = BT.min(b - b0);
|
|
123
|
+
let m0 = q * MBLK;
|
|
124
|
+
let mb = MBLK.min(m - m0);
|
|
125
|
+
// fail[j * BT + k] != 0 once column m0 + j is masked for image pixel b0 + k.
|
|
126
|
+
let fail = &mut fail[..mb * BT];
|
|
127
|
+
fail.fill(0);
|
|
128
|
+
let dead_lo = dead_cols.partition_point(|&j| (j as usize) < m0);
|
|
129
|
+
let dead_hi = dead_cols.partition_point(|&j| (j as usize) < m0 + mb);
|
|
130
|
+
for &j in &dead_cols[dead_lo..dead_hi] {
|
|
131
|
+
fail[(j as usize - m0) * BT..][..BT].fill(1);
|
|
132
|
+
}
|
|
133
|
+
let mut ybuf = [f32::INFINITY; BT]; // lanes past bt never fail
|
|
134
|
+
let mut since_recount = 0usize;
|
|
135
|
+
for &i in &order {
|
|
136
|
+
let row = &rows[i];
|
|
137
|
+
let (lo, hi) = (row.blk[q] as usize, row.blk[q + 1] as usize);
|
|
138
|
+
if lo == hi {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
let mut ymin = f32::INFINITY;
|
|
142
|
+
for (yb, &y) in ybuf[..bt].iter_mut().zip(&ys[i * b + b0..i * b + b0 + bt]) {
|
|
143
|
+
*yb = if y <= 0.0 { fill_value } else { y };
|
|
144
|
+
ymin = ymin.min(*yb);
|
|
145
|
+
}
|
|
146
|
+
if ymin >= row.max_val {
|
|
147
|
+
continue; // no pixel of this tile is below any rhs of this row
|
|
148
|
+
}
|
|
149
|
+
for (&j, &v) in row.idx[lo..hi].iter().zip(&row.val[lo..hi]) {
|
|
150
|
+
let f = &mut fail[(j as usize - m0) * BT..][..BT];
|
|
151
|
+
for (f, &y) in f.iter_mut().zip(&ybuf) {
|
|
152
|
+
*f |= u8::from(y < v);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Stop once every column of the block has failed for every pixel of the tile. The
|
|
156
|
+
// check costs one pass over fail, so it runs once per ~4 passes' worth of updates.
|
|
157
|
+
since_recount += hi - lo;
|
|
158
|
+
if since_recount >= 4 * mb {
|
|
159
|
+
since_recount = 0;
|
|
160
|
+
let mut all_failed = [1u8; BT];
|
|
161
|
+
for col in fail.chunks_exact(BT) {
|
|
162
|
+
for (a, &f) in all_failed.iter_mut().zip(col) {
|
|
163
|
+
*a &= f;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if all_failed[..bt].iter().all(|&a| a == 1) {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for k in 0..bt {
|
|
172
|
+
// SAFETY: tasks write disjoint rectangles [b0 + k, m0..m0 + mb] of keep.
|
|
173
|
+
let out = unsafe { slice::from_raw_parts_mut(keep_ptr.add((b0 + k) * m + m0), mb) };
|
|
174
|
+
for (j, o) in out.iter_mut().enumerate() {
|
|
175
|
+
*o = if fail[j * BT + k] != 0 { f32::NAN } else { 1.0 };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mod screen;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
use crate::screen::{column_max, mass_screen_core, BT, MBLK};
|
|
2
|
+
|
|
3
|
+
fn run(rg: &[f32], ys: &[f32], n: usize, m: usize, b: usize, fill: f32) -> Vec<f32> {
|
|
4
|
+
let mut keep = vec![0.0; b * m];
|
|
5
|
+
mass_screen_core(rg, ys, &mut keep, n, m, b, 2.24, fill);
|
|
6
|
+
keep
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
fn same(a: &[f32], b: &[f32]) -> bool {
|
|
10
|
+
a.len() == b.len()
|
|
11
|
+
&& a.iter()
|
|
12
|
+
.zip(b)
|
|
13
|
+
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#[test]
|
|
17
|
+
fn muse_example() {
|
|
18
|
+
// The case muse/inversions/tests/test_mass_screen.py checks.
|
|
19
|
+
let rg = [1.0, 0.5, 0.1, 0.2, 1.0, 0.3];
|
|
20
|
+
let ys = [3.0, 1.0, 4.0, 5.0];
|
|
21
|
+
let keep = run(&rg, &ys, 2, 3, 2, 1.0);
|
|
22
|
+
assert!(same(&keep, &[1.0, 1.0, 1.0, f32::NAN, f32::NAN, 1.0]));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[test]
|
|
26
|
+
fn zero_column_is_masked_and_fill_value_applies() {
|
|
27
|
+
// Column 1 has no response; ys[0, 1] <= 0 is replaced by the fill value 1.0.
|
|
28
|
+
let rg = [1.0, 0.0, 0.1, 0.5, 0.0, 0.3];
|
|
29
|
+
let ys = [3.0, -1.0, 4.0, 5.0];
|
|
30
|
+
let keep = run(&rg, &ys, 2, 3, 2, 1.0);
|
|
31
|
+
assert!(same(&keep, &[1.0, f32::NAN, 1.0, f32::NAN, f32::NAN, 1.0]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[test]
|
|
35
|
+
fn many_tiles_and_blocks_match_the_dense_rule() {
|
|
36
|
+
// Exercises partial tiles (b % BT != 0) and several column blocks.
|
|
37
|
+
let (n, m, b) = (7, 2 * MBLK + 5, 3 * BT + 9);
|
|
38
|
+
let mut seed = 12345u64;
|
|
39
|
+
let mut next = || {
|
|
40
|
+
seed = seed
|
|
41
|
+
.wrapping_mul(6364136223846793005)
|
|
42
|
+
.wrapping_add(1442695040888963407);
|
|
43
|
+
(seed >> 40) as f32 / (1u64 << 24) as f32
|
|
44
|
+
};
|
|
45
|
+
let rg: Vec<f32> = (0..n * m)
|
|
46
|
+
.map(|_| if next() < 0.05 { next() } else { 0.0 })
|
|
47
|
+
.collect();
|
|
48
|
+
let ys: Vec<f32> = (0..n * b).map(|_| next() * 4.0 - 1.0).collect();
|
|
49
|
+
let keep = run(&rg, &ys, n, m, b, 0.25);
|
|
50
|
+
let col_max = column_max(&rg, m);
|
|
51
|
+
for bi in 0..b {
|
|
52
|
+
for j in 0..m {
|
|
53
|
+
let kept = col_max[j] > 0.0
|
|
54
|
+
&& (0..n).all(|i| {
|
|
55
|
+
let y = if ys[i * b + bi] <= 0.0 {
|
|
56
|
+
0.25
|
|
57
|
+
} else {
|
|
58
|
+
ys[i * b + bi]
|
|
59
|
+
};
|
|
60
|
+
y >= 2.24 / col_max[j] * rg[i * m + j]
|
|
61
|
+
});
|
|
62
|
+
assert_eq!(keep[bi * m + j] == 1.0, kept, "cell ({bi}, {j})");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|