softDOE 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.
softdoe-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wei-Yang Yu and V. Roshan Joseph
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
softdoe-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: softDOE
3
+ Version: 0.1.0
4
+ Summary: Space-filling one-factor-at-a-time designs.
5
+ Author: Wei-Yang Yu and V. Roshan Joseph
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy
10
+ Requires-Dist: scipy
11
+ Requires-Dist: pyDOE
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Dynamic: license-file
15
+ Dynamic: requires-python
16
+
17
+ # softDOE
18
+
19
+ `softDOE` is a Python package for constructing space-filling
20
+ one-factor-at-a-time (SOFT) designs.
21
+
22
+ ## What is included
23
+
24
+ `softDOE` currently includes:
25
+
26
+ * `soft()`: generate SOFT designs.
27
+ * `measure()`: compute screening measures.
28
+
29
+ Generated designs are returned as NumPy arrays with entries in `(0, 1)`.
30
+
31
+ ## Installation
32
+
33
+ Install `softDOE` from PyPI:
34
+
35
+ ```bash
36
+ pip install softDOE
37
+ ```
38
+
39
+
40
+ `softDOE` requires Python 3.9 or later. The package depends on NumPy, SciPy,
41
+ and pyDOE, and uses a compiled C++ extension built with pybind11.
42
+
43
+ ## Quick example
44
+
45
+ ```python
46
+ import numpy as np
47
+ import softDOE
48
+
49
+ # SOFT design
50
+ design = softDOE.soft(p=10, l=4)
51
+
52
+ # Friedman function
53
+ def friedman(x):
54
+ x = np.asarray(x)
55
+ return (
56
+ 10 * np.sin(np.pi * x[0] * x[1])
57
+ + 20 * (x[2] - 0.5) ** 2
58
+ + 10 * x[3]
59
+ + 5 * x[4]
60
+ )
61
+
62
+ # Evaluate Friedman function at each design point.
63
+ y = np.array([friedman(row) for row in design])
64
+
65
+ # Sensitivity measures
66
+ result = softDOE.measure(design, y)
67
+
68
+ print(result["mustar"])
69
+ print(result["t"])
70
+ ```
71
+
72
+ ## Generating SOFT designs
73
+
74
+ Use `softDOE.soft()` to generate a SOFT design:
75
+
76
+ ```python
77
+ design = softDOE.soft(p=5, l=4, structure="standard", m=500, seed=123)
78
+ ```
79
+
80
+ Arguments:
81
+
82
+ * `p`: number of factors.
83
+ * `l`: number of base runs. This value must be even.
84
+ * `structure`: either `"standard"` or `"strict"`. The default is
85
+ `"standard"`.
86
+ * `m`: number of Monte Carlo samples used when evaluating the design
87
+ objective.
88
+ * `seed`: optional seed for reproducible design generation.
89
+
90
+ The returned design has shape:
91
+
92
+ ```python
93
+ (l * (p + 1), p)
94
+ ```
95
+
96
+ ## Computing screening measures
97
+
98
+ Use `softDOE.measure()` with a design matrix and response vector:
99
+
100
+ ```python
101
+ result = softDOE.measure(design, y)
102
+ ```
103
+
104
+ The response vector `y` must have one value for each row of the design. The
105
+ function returns a dictionary with two entries:
106
+
107
+ * `mustar`: $\mu^*$ measure of Campolongo et al. (2007).
108
+ * `t`: total Sobol' index of Sobol' (1993).
109
+
110
+ ## Testing
111
+
112
+ After installing the test dependencies, run the test suite with:
113
+
114
+ ```bash
115
+ python -m pytest
116
+ ```
117
+
118
+ ## Citation
119
+
120
+ If you use `softDOE`, please cite:
121
+
122
+ ```bibtex
123
+ @article{yu2026space,
124
+ title={Space-Filling One-Factor-At-A-Time Designs},
125
+ author={Yu, Wei-Yang and Joseph, V Roshan},
126
+ journal={arXiv preprint arXiv:2606.02533},
127
+ year={2026}
128
+ }
129
+ ```
130
+
131
+ ## References
132
+
133
+ * Sobol', I. M. (1993), "On sensitivity estimation for nonlinear mathematical
134
+ models," Mathematical Modeling and Computational Experiments, 1, 407-414.
135
+ * Campolongo, F., Cariboni, J., and Saltelli, A. (2007), "An effective
136
+ screening design for sensitivity analysis of large models," Environmental
137
+ Modelling and Software, 22, 1509-1518.
138
+
139
+ ## Authors
140
+
141
+ Wei-Yang Yu and V. Roshan Joseph.
142
+
143
+ ## License
144
+
145
+ `softDOE` is licensed under the MIT License.
@@ -0,0 +1,129 @@
1
+ # softDOE
2
+
3
+ `softDOE` is a Python package for constructing space-filling
4
+ one-factor-at-a-time (SOFT) designs.
5
+
6
+ ## What is included
7
+
8
+ `softDOE` currently includes:
9
+
10
+ * `soft()`: generate SOFT designs.
11
+ * `measure()`: compute screening measures.
12
+
13
+ Generated designs are returned as NumPy arrays with entries in `(0, 1)`.
14
+
15
+ ## Installation
16
+
17
+ Install `softDOE` from PyPI:
18
+
19
+ ```bash
20
+ pip install softDOE
21
+ ```
22
+
23
+
24
+ `softDOE` requires Python 3.9 or later. The package depends on NumPy, SciPy,
25
+ and pyDOE, and uses a compiled C++ extension built with pybind11.
26
+
27
+ ## Quick example
28
+
29
+ ```python
30
+ import numpy as np
31
+ import softDOE
32
+
33
+ # SOFT design
34
+ design = softDOE.soft(p=10, l=4)
35
+
36
+ # Friedman function
37
+ def friedman(x):
38
+ x = np.asarray(x)
39
+ return (
40
+ 10 * np.sin(np.pi * x[0] * x[1])
41
+ + 20 * (x[2] - 0.5) ** 2
42
+ + 10 * x[3]
43
+ + 5 * x[4]
44
+ )
45
+
46
+ # Evaluate Friedman function at each design point.
47
+ y = np.array([friedman(row) for row in design])
48
+
49
+ # Sensitivity measures
50
+ result = softDOE.measure(design, y)
51
+
52
+ print(result["mustar"])
53
+ print(result["t"])
54
+ ```
55
+
56
+ ## Generating SOFT designs
57
+
58
+ Use `softDOE.soft()` to generate a SOFT design:
59
+
60
+ ```python
61
+ design = softDOE.soft(p=5, l=4, structure="standard", m=500, seed=123)
62
+ ```
63
+
64
+ Arguments:
65
+
66
+ * `p`: number of factors.
67
+ * `l`: number of base runs. This value must be even.
68
+ * `structure`: either `"standard"` or `"strict"`. The default is
69
+ `"standard"`.
70
+ * `m`: number of Monte Carlo samples used when evaluating the design
71
+ objective.
72
+ * `seed`: optional seed for reproducible design generation.
73
+
74
+ The returned design has shape:
75
+
76
+ ```python
77
+ (l * (p + 1), p)
78
+ ```
79
+
80
+ ## Computing screening measures
81
+
82
+ Use `softDOE.measure()` with a design matrix and response vector:
83
+
84
+ ```python
85
+ result = softDOE.measure(design, y)
86
+ ```
87
+
88
+ The response vector `y` must have one value for each row of the design. The
89
+ function returns a dictionary with two entries:
90
+
91
+ * `mustar`: $\mu^*$ measure of Campolongo et al. (2007).
92
+ * `t`: total Sobol' index of Sobol' (1993).
93
+
94
+ ## Testing
95
+
96
+ After installing the test dependencies, run the test suite with:
97
+
98
+ ```bash
99
+ python -m pytest
100
+ ```
101
+
102
+ ## Citation
103
+
104
+ If you use `softDOE`, please cite:
105
+
106
+ ```bibtex
107
+ @article{yu2026space,
108
+ title={Space-Filling One-Factor-At-A-Time Designs},
109
+ author={Yu, Wei-Yang and Joseph, V Roshan},
110
+ journal={arXiv preprint arXiv:2606.02533},
111
+ year={2026}
112
+ }
113
+ ```
114
+
115
+ ## References
116
+
117
+ * Sobol', I. M. (1993), "On sensitivity estimation for nonlinear mathematical
118
+ models," Mathematical Modeling and Computational Experiments, 1, 407-414.
119
+ * Campolongo, F., Cariboni, J., and Saltelli, A. (2007), "An effective
120
+ screening design for sensitivity analysis of large models," Environmental
121
+ Modelling and Software, 22, 1509-1518.
122
+
123
+ ## Authors
124
+
125
+ Wei-Yang Yu and V. Roshan Joseph.
126
+
127
+ ## License
128
+
129
+ `softDOE` is licensed under the MIT License.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel", "pybind11>=2.10", "numpy"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "softDOE"
7
+ version = "0.1.0"
8
+ description = "Space-filling one-factor-at-a-time designs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [
12
+ {name = "Wei-Yang Yu and V. Roshan Joseph"}
13
+ ]
14
+ dependencies = [
15
+ "numpy",
16
+ "scipy",
17
+ "pyDOE"
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ test = ["pytest"]
22
+
23
+ [tool.setuptools]
24
+ package-dir = {"" = "src"}
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
softdoe-0.1.0/setup.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from setuptools import Extension, setup
4
+ from setuptools.command.build_ext import build_ext
5
+
6
+
7
+ class BuildExt(build_ext):
8
+ def build_extensions(self) -> None:
9
+ import pybind11
10
+
11
+ for ext in self.extensions:
12
+ ext.include_dirs.append(pybind11.get_include())
13
+ ext.extra_compile_args = ["-std=c++17"]
14
+ super().build_extensions()
15
+
16
+
17
+ ext_modules = [
18
+ Extension(
19
+ "softDOE._core",
20
+ ["src/softDOE/_core.cpp"],
21
+ language="c++",
22
+ )
23
+ ]
24
+
25
+
26
+ setup(
27
+ name="softDOE",
28
+ version="0.1.0",
29
+ description="Space-filling one-factor-at-a-time designs.",
30
+ author="Wei-Yang Yu and V. Roshan Joseph",
31
+ package_dir={"": "src"},
32
+ packages=["softDOE"],
33
+ python_requires=">=3.9",
34
+ install_requires=["numpy", "scipy", "pyDOE"],
35
+ extras_require={"test": ["pytest"]},
36
+ ext_modules=ext_modules,
37
+ cmdclass={"build_ext": BuildExt},
38
+ )
@@ -0,0 +1,6 @@
1
+ """Space-filling one-factor-at-a-time designs."""
2
+
3
+ from ._measure import measure
4
+ from ._soft import soft
5
+
6
+ __all__ = ["soft", "measure"]
@@ -0,0 +1,239 @@
1
+ #include <cmath>
2
+ #include <stdexcept>
3
+
4
+ #include <pybind11/numpy.h>
5
+ #include <pybind11/pybind11.h>
6
+
7
+ namespace py = pybind11;
8
+
9
+ namespace {
10
+
11
+ using Array = py::array_t<double, py::array::c_style | py::array::forcecast>;
12
+
13
+ // Evaluate the MIM correlation kernel between row ia of A and row ib of B.
14
+ double corr_idx(const double* A, py::ssize_t ia, const double* B, py::ssize_t ib,
15
+ py::ssize_t p, double alpha, double beta) {
16
+ const double invbeta = 1.0 / beta;
17
+
18
+ if (alpha == 1.0) {
19
+ double c = 1.0;
20
+ for (py::ssize_t k = 0; k < p; ++k) {
21
+ const double d = A[ia * p + k] - B[ib * p + k];
22
+ c *= 1.0 / (1.0 + (d * d) * invbeta);
23
+ }
24
+ return c;
25
+ }
26
+
27
+ const double negalpha = -alpha;
28
+ double logc = 0.0;
29
+ for (py::ssize_t k = 0; k < p; ++k) {
30
+ const double d = A[ia * p + k] - B[ib * p + k];
31
+ logc += negalpha * std::log1p((d * d) * invbeta);
32
+ }
33
+ return std::exp(logc);
34
+ }
35
+
36
+ // Validate that a Python/NumPy input is a two-dimensional matrix.
37
+ void require_matrix(const py::buffer_info& info, const char* name) {
38
+ if (info.ndim != 2) {
39
+ throw std::invalid_argument(std::string(name) + " must be two-dimensional.");
40
+ }
41
+ }
42
+
43
+ } // namespace
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Testing helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ // Expose corr_idx to Python for parity tests against R's internal corr_idx_fast.
50
+ // This is intentionally private (_corr_idx) and is not part of the public API.
51
+ double corr_idx_binding(Array A, py::ssize_t ia, Array B, py::ssize_t ib,
52
+ double alpha, double beta) {
53
+ const py::buffer_info a_info = A.request();
54
+ const py::buffer_info b_info = B.request();
55
+ require_matrix(a_info, "A");
56
+ require_matrix(b_info, "B");
57
+
58
+ if (a_info.shape[1] != b_info.shape[1]) {
59
+ throw std::invalid_argument("A and B must have the same number of columns.");
60
+ }
61
+ if (ia < 0 || ia >= a_info.shape[0]) {
62
+ throw std::out_of_range("ia is out of range for A.");
63
+ }
64
+ if (ib < 0 || ib >= b_info.shape[0]) {
65
+ throw std::out_of_range("ib is out of range for B.");
66
+ }
67
+
68
+ const auto* a = static_cast<const double*>(a_info.ptr);
69
+ const auto* b = static_cast<const double*>(b_info.ptr);
70
+ return corr_idx(a, ia, b, ib, a_info.shape[1], alpha, beta);
71
+ }
72
+
73
+ // Build the MIM correlation matrix for X, or append new_X to a precomputed
74
+ // old_R block when incrementally growing a design.
75
+ Array corr_matrix(Array X, py::object new_X_obj, py::object old_R_obj,
76
+ double alpha, double beta) {
77
+ const py::buffer_info x_info = X.request();
78
+ require_matrix(x_info, "X");
79
+ const auto n = x_info.shape[0];
80
+ const auto p = x_info.shape[1];
81
+ const auto* x = static_cast<const double*>(x_info.ptr);
82
+
83
+ if (new_X_obj.is_none()) {
84
+ Array out({n, n});
85
+ auto out_info = out.mutable_unchecked<2>();
86
+ for (py::ssize_t i = 0; i < n; ++i) {
87
+ out_info(i, i) = 1.0;
88
+ for (py::ssize_t j = i + 1; j < n; ++j) {
89
+ const double c = corr_idx(x, i, x, j, p, alpha, beta);
90
+ out_info(i, j) = c;
91
+ out_info(j, i) = c;
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ if (old_R_obj.is_none()) {
98
+ throw std::invalid_argument("old_R must be provided when new_X is not None.");
99
+ }
100
+
101
+ Array new_X = py::cast<Array>(new_X_obj);
102
+ Array old_R = py::cast<Array>(old_R_obj);
103
+ const py::buffer_info x2_info = new_X.request();
104
+ const py::buffer_info r0_info = old_R.request();
105
+ require_matrix(x2_info, "new_X");
106
+ require_matrix(r0_info, "old_R");
107
+
108
+ const auto k = x2_info.shape[0];
109
+ if (x2_info.shape[1] != p) {
110
+ throw std::invalid_argument("new_X must have the same number of columns as X.");
111
+ }
112
+ if (r0_info.shape[0] != n || r0_info.shape[1] != n) {
113
+ throw std::invalid_argument("old_R must have shape (X rows, X rows).");
114
+ }
115
+
116
+ const auto* x2 = static_cast<const double*>(x2_info.ptr);
117
+ const auto* r0 = static_cast<const double*>(r0_info.ptr);
118
+ const auto new_n = n + k;
119
+ Array out({new_n, new_n});
120
+ auto out_info = out.mutable_unchecked<2>();
121
+
122
+ for (py::ssize_t i = 0; i < new_n; ++i) {
123
+ for (py::ssize_t j = 0; j < new_n; ++j) {
124
+ out_info(i, j) = 0.0;
125
+ }
126
+ out_info(i, i) = 1.0;
127
+ }
128
+
129
+ for (py::ssize_t i = 0; i < n; ++i) {
130
+ for (py::ssize_t j = 0; j < n; ++j) {
131
+ out_info(i, j) = r0[i * n + j];
132
+ }
133
+ }
134
+
135
+ for (py::ssize_t i = 0; i < k; ++i) {
136
+ const py::ssize_t ii = n + i;
137
+ for (py::ssize_t j = 0; j < n; ++j) {
138
+ const double c = corr_idx(x2, i, x, j, p, alpha, beta);
139
+ out_info(ii, j) = c;
140
+ out_info(j, ii) = c;
141
+ }
142
+ }
143
+
144
+ for (py::ssize_t i = 0; i < k; ++i) {
145
+ const py::ssize_t ii = n + i;
146
+ for (py::ssize_t j = i + 1; j < k; ++j) {
147
+ const py::ssize_t jj = n + j;
148
+ const double c = corr_idx(x2, i, x2, j, p, alpha, beta);
149
+ out_info(ii, jj) = c;
150
+ out_info(jj, ii) = c;
151
+ }
152
+ }
153
+
154
+ return out;
155
+ }
156
+
157
+ // Build correlations between QMC/evaluation points and design points X, or
158
+ // append rows for new_X to a precomputed design-vs-QMC correlation block.
159
+ Array q_matrix(Array qmc, Array X, py::object old_R_obj, py::object new_X_obj,
160
+ double alpha, double beta) {
161
+ const py::buffer_info q_info = qmc.request();
162
+ const py::buffer_info x_info = X.request();
163
+ require_matrix(q_info, "qmc");
164
+ require_matrix(x_info, "X");
165
+
166
+ const auto m = q_info.shape[0];
167
+ const auto p = q_info.shape[1];
168
+ const auto n = x_info.shape[0];
169
+ if (x_info.shape[1] != p) {
170
+ throw std::invalid_argument("qmc and X must have the same number of columns.");
171
+ }
172
+
173
+ const auto* q = static_cast<const double*>(q_info.ptr);
174
+ const auto* x = static_cast<const double*>(x_info.ptr);
175
+
176
+ if (new_X_obj.is_none()) {
177
+ Array out({n, m});
178
+ auto out_info = out.mutable_unchecked<2>();
179
+ for (py::ssize_t j = 0; j < m; ++j) {
180
+ for (py::ssize_t i = 0; i < n; ++i) {
181
+ out_info(i, j) = corr_idx(q, j, x, i, p, alpha, beta);
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ if (old_R_obj.is_none()) {
188
+ throw std::invalid_argument("old_R must be provided when new_X is not None.");
189
+ }
190
+
191
+ Array old_R = py::cast<Array>(old_R_obj);
192
+ Array new_X = py::cast<Array>(new_X_obj);
193
+ const py::buffer_info r0_info = old_R.request();
194
+ const py::buffer_info x2_info = new_X.request();
195
+ require_matrix(r0_info, "old_R");
196
+ require_matrix(x2_info, "new_X");
197
+
198
+ const auto k = x2_info.shape[0];
199
+ if (x2_info.shape[1] != p) {
200
+ throw std::invalid_argument("new_X must have the same number of columns as X.");
201
+ }
202
+ if (r0_info.shape[0] != n || r0_info.shape[1] != m) {
203
+ throw std::invalid_argument("old_R must have shape (X rows, qmc rows).");
204
+ }
205
+
206
+ const auto* r0 = static_cast<const double*>(r0_info.ptr);
207
+ const auto* x2 = static_cast<const double*>(x2_info.ptr);
208
+ Array out({n + k, m});
209
+ auto out_info = out.mutable_unchecked<2>();
210
+
211
+ for (py::ssize_t i = 0; i < n; ++i) {
212
+ for (py::ssize_t j = 0; j < m; ++j) {
213
+ out_info(i, j) = r0[i * m + j];
214
+ }
215
+ }
216
+
217
+ for (py::ssize_t j = 0; j < m; ++j) {
218
+ for (py::ssize_t i = 0; i < k; ++i) {
219
+ out_info(n + i, j) = corr_idx(q, j, x2, i, p, alpha, beta);
220
+ }
221
+ }
222
+
223
+ return out;
224
+ }
225
+
226
+ // Register the compiled backend functions exposed to the Python module.
227
+ PYBIND11_MODULE(_core, m) {
228
+ m.doc() = "Compiled correlation kernels for softDOE.";
229
+ m.def("_corr_idx", &corr_idx_binding, py::arg("A"), py::arg("ia"),
230
+ py::arg("B"), py::arg("ib"), py::arg("alpha") = 1.0,
231
+ py::arg("beta") = 1.0,
232
+ "Evaluate the MIM kernel between row ia of matrix A and row ib of matrix B.");
233
+ m.def("corr_matrix", &corr_matrix, py::arg("X"), py::arg("new_X") = py::none(),
234
+ py::arg("old_R") = py::none(), py::arg("alpha") = 1.0,
235
+ py::arg("beta") = 1.0);
236
+ m.def("q_matrix", &q_matrix, py::arg("qmc"), py::arg("X"),
237
+ py::arg("old_R") = py::none(), py::arg("new_X") = py::none(),
238
+ py::arg("alpha") = 1.0, py::arg("beta") = 1.0);
239
+ }
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def measure(design, y):
7
+ """Compute screening measures for a SOFT/Sobol'-structured design.
8
+
9
+ Parameters
10
+ ----------
11
+ design : array_like, shape (l * (p + 1), p)
12
+ Design matrix with one base block followed by one-factor-at-a-time
13
+ blocks.
14
+ y : array_like, shape (l * (p + 1),)
15
+ Response vector.
16
+
17
+ Returns
18
+ -------
19
+ dict
20
+ ``{"mustar": array, "t": array}``, matching the R function output.
21
+ """
22
+ design = np.asarray(design, dtype=float)
23
+ y = np.asarray(y, dtype=float).reshape(-1)
24
+
25
+ if design.ndim != 2:
26
+ raise ValueError("design must be a two-dimensional array.")
27
+
28
+ n, p = design.shape
29
+ if p < 1:
30
+ raise ValueError("design must contain at least one factor.")
31
+ if n % (p + 1) != 0:
32
+ raise ValueError("design must have l * (p + 1) rows.")
33
+ if y.shape[0] != n:
34
+ raise ValueError("y must have the same length as design rows.")
35
+
36
+ l = n // (p + 1)
37
+ design_blocks = [design[i * l : (i + 1) * l, :] for i in range(p + 1)]
38
+ y_blocks = [y[i * l : (i + 1) * l] for i in range(p + 1)]
39
+
40
+ y_diff = [None] * p
41
+ delta = [None] * p
42
+
43
+ for i in range(p):
44
+ matrix_idx, factor_idx = _find_a(design_blocks, i + 1)
45
+ y_diff[factor_idx] = np.abs(y_blocks[i + 1] - y_blocks[matrix_idx])
46
+ delta[factor_idx] = np.abs(
47
+ design_blocks[i + 1][:, factor_idx]
48
+ - design_blocks[matrix_idx][:, factor_idx]
49
+ )
50
+
51
+ if any(v is None for v in y_diff) or any(v is None for v in delta):
52
+ raise ValueError("design does not have a valid one-factor-at-a-time block structure.")
53
+
54
+ with np.errstate(divide="raise", invalid="raise"):
55
+ try:
56
+ mustar = np.array(
57
+ [np.sum(diff / step) / l for diff, step in zip(y_diff, delta)],
58
+ dtype=float,
59
+ )
60
+ except FloatingPointError as exc:
61
+ raise ValueError("design contains zero OFAT step sizes.") from exc
62
+
63
+ var_y = np.var(y, ddof=1)
64
+ if var_y == 0:
65
+ t = np.full(p, np.nan)
66
+ else:
67
+ vtot = np.array([np.sum(diff**2) for diff in y_diff], dtype=float) / (2 * l)
68
+ t = vtot / var_y
69
+
70
+ return {"mustar": mustar, "t": t}
71
+
72
+
73
+ def _find_a(blocks, idx):
74
+ b = blocks[idx]
75
+ p = b.shape[1]
76
+ for j in range(idx):
77
+ a = blocks[j]
78
+ col_same = np.array(
79
+ [np.allclose(a[:, k], b[:, k], rtol=0.0, atol=1e-12) for k in range(p)]
80
+ )
81
+ changed = np.flatnonzero(~col_same)
82
+ if changed.size == 1:
83
+ return j, int(changed[0])
84
+ raise ValueError("could not find the matching prior OFAT block.")