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.
@@ -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))
@@ -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
+ }