doetools 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. doetools/__init__.py +44 -0
  2. doetools/design/__init__.py +28 -0
  3. doetools/design/d_opt/__init__.py +7 -0
  4. doetools/design/d_opt/candidate_set.py +185 -0
  5. doetools/design/d_opt/d_opt.py +299 -0
  6. doetools/design/d_opt/d_opt_add.py +588 -0
  7. doetools/design/d_opt/optimizer.py +434 -0
  8. doetools/design/generic/__init__.py +5 -0
  9. doetools/design/generic/generic_design.py +219 -0
  10. doetools/design/mixture/__init__.py +9 -0
  11. doetools/design/mixture/constrained_mixture.py +240 -0
  12. doetools/design/mixture/mixture_cp_generator.py +665 -0
  13. doetools/design/mixture/simplex_centroid.py +184 -0
  14. doetools/design/mixture/simplex_lattice.py +191 -0
  15. doetools/design/process/__init__.py +14 -0
  16. doetools/design/process/box_behnken.py +136 -0
  17. doetools/design/process/central_composite.py +215 -0
  18. doetools/design/process/fractional_factorial.py +153 -0
  19. doetools/design/process/full_factorial.py +104 -0
  20. doetools/design/process/plackett_burman.py +207 -0
  21. doetools/graphs/__init__.py +3 -0
  22. doetools/graphs/data_builder.py +146 -0
  23. doetools/graphs/design_plot_builder.py +1242 -0
  24. doetools/graphs/plot_api_mixin.py +1110 -0
  25. doetools/graphs/renderers.py +3630 -0
  26. doetools/utils/__init__.py +26 -0
  27. doetools/utils/abstract_design.py +1085 -0
  28. doetools/utils/confirmation.py +296 -0
  29. doetools/utils/design_advisor.py +1360 -0
  30. doetools/utils/external_points.py +256 -0
  31. doetools/utils/factors.py +201 -0
  32. doetools/utils/grid_builder.py +306 -0
  33. doetools/utils/model_spec.py +375 -0
  34. doetools/utils/pareto.py +457 -0
  35. doetools/utils/pdf_report.py +918 -0
  36. doetools/utils/prediction.py +203 -0
  37. doetools/utils/regression.py +715 -0
  38. doetools/utils/summary.py +286 -0
  39. doetools/utils/upload.py +58 -0
  40. doetools-0.1.1.dist-info/METADATA +190 -0
  41. doetools-0.1.1.dist-info/RECORD +44 -0
  42. doetools-0.1.1.dist-info/WHEEL +5 -0
  43. doetools-0.1.1.dist-info/licenses/LICENSE +21 -0
  44. doetools-0.1.1.dist-info/top_level.txt +1 -0
doetools/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Public package interface for :mod:`doetools`."""
2
+
3
+ __version__ = "0.1.1"
4
+
5
+ from .design import (
6
+ BoxBehnkenDesign,
7
+ CentralCompositeDesign,
8
+ ConstrainedMixtureDesign,
9
+ DOptAddDesign,
10
+ DOptDesign,
11
+ FractionalFactorialDesign,
12
+ FullFactorialDesign,
13
+ ImportDesign,
14
+ PlackettBurmanDesign,
15
+ SimplexCentroidDesign,
16
+ SimplexLatticeDesign,
17
+ )
18
+ from .utils import (
19
+ CategoricalFactor,
20
+ ContinuousFactor,
21
+ MixtureFactor,
22
+ ModelTerms,
23
+ suggest_design,
24
+ )
25
+
26
+ __all__ = [
27
+ "BoxBehnkenDesign",
28
+ "CategoricalFactor",
29
+ "CentralCompositeDesign",
30
+ "ConstrainedMixtureDesign",
31
+ "ContinuousFactor",
32
+ "DOptAddDesign",
33
+ "DOptDesign",
34
+ "FractionalFactorialDesign",
35
+ "FullFactorialDesign",
36
+ "ImportDesign",
37
+ "MixtureFactor",
38
+ "ModelTerms",
39
+ "PlackettBurmanDesign",
40
+ "SimplexCentroidDesign",
41
+ "SimplexLatticeDesign",
42
+ "suggest_design",
43
+ "__version__",
44
+ ]
@@ -0,0 +1,28 @@
1
+ from .d_opt import DOptAddDesign, DOptDesign
2
+ from .generic import ImportDesign
3
+ from .mixture import (
4
+ ConstrainedMixtureDesign,
5
+ SimplexCentroidDesign,
6
+ SimplexLatticeDesign,
7
+ )
8
+ from .process import (
9
+ BoxBehnkenDesign,
10
+ CentralCompositeDesign,
11
+ FractionalFactorialDesign,
12
+ FullFactorialDesign,
13
+ PlackettBurmanDesign,
14
+ )
15
+
16
+ __all__ = [
17
+ "BoxBehnkenDesign",
18
+ "CentralCompositeDesign",
19
+ "ConstrainedMixtureDesign",
20
+ "DOptAddDesign",
21
+ "DOptDesign",
22
+ "FractionalFactorialDesign",
23
+ "FullFactorialDesign",
24
+ "ImportDesign",
25
+ "PlackettBurmanDesign",
26
+ "SimplexCentroidDesign",
27
+ "SimplexLatticeDesign"
28
+ ]
@@ -0,0 +1,7 @@
1
+ from .d_opt import DOptDesign
2
+ from .d_opt_add import DOptAddDesign
3
+
4
+ __all__ = [
5
+ "DOptDesign",
6
+ "DOptAddDesign"
7
+ ]
@@ -0,0 +1,185 @@
1
+ """Shared candidate-set generation for D-optimal designs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ from collections.abc import Callable, Collection, Mapping
7
+
8
+ import pandas as pd
9
+
10
+ from ...utils.grid_builder import lhs_grid
11
+ from ..mixture.mixture_cp_generator import build_candidate_points
12
+ from ..process import BoxBehnkenDesign, CentralCompositeDesign
13
+
14
+
15
+ MIXTURE_CANDIDATE_CATEGORIES = (
16
+ "vertices",
17
+ "edge_midpoints",
18
+ "face_centroids",
19
+ "global_centroid",
20
+ )
21
+
22
+
23
+ class DOptimalCandidateSetMixin:
24
+ """Generate process/mixture candidate sets for D-optimal selection."""
25
+
26
+ @staticmethod
27
+ def _normalize_mixture_include(
28
+ mixture_include: str | Collection[str] | None,
29
+ ) -> tuple[str, ...]:
30
+ if mixture_include is None:
31
+ return ()
32
+ if isinstance(mixture_include, str):
33
+ selected = (
34
+ MIXTURE_CANDIDATE_CATEGORIES
35
+ if mixture_include == "all"
36
+ else (mixture_include,)
37
+ )
38
+ else:
39
+ selected = tuple(mixture_include)
40
+
41
+ unknown = sorted(set(selected) - set(MIXTURE_CANDIDATE_CATEGORIES))
42
+ if unknown:
43
+ raise ValueError(
44
+ "Unsupported mixture candidate category: "
45
+ + ", ".join(map(str, unknown))
46
+ + ". Use 'all' or exact category names: "
47
+ + ", ".join(MIXTURE_CANDIDATE_CATEGORIES)
48
+ )
49
+ return tuple(dict.fromkeys(selected))
50
+
51
+ def _generate_d_optimal_candidates(
52
+ self,
53
+ *,
54
+ factors: Mapping[str, object],
55
+ process_strategy: str | None = None,
56
+ mixture_include: str | Collection[str] | None = (),
57
+ mixture_grid: Mapping[str, int] | None = None,
58
+ lhs_n_samples: int | None = None,
59
+ filters: list[Callable[[pd.DataFrame], pd.Series]] | None = None,
60
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
61
+ """Return coded and actual candidate matrices for every factor family."""
62
+ factor_names = list(factors)
63
+ continuous = [name for name in factor_names if factors[name].type == "cont"]
64
+ categorical = [name for name in factor_names if factors[name].type == "cat"]
65
+ process = continuous + categorical
66
+ mixture = [name for name in factor_names if factors[name].type == "mix"]
67
+
68
+ include = self._normalize_mixture_include(mixture_include)
69
+ if lhs_n_samples is not None and process_strategy != "lhs":
70
+ raise ValueError("lhs_n_samples can be specified only with 'lhs' strategy")
71
+ if process and process_strategy is None:
72
+ raise ValueError("process_strategy is required when process factors are present")
73
+ if not process and process_strategy is not None:
74
+ raise ValueError("process_strategy cannot be used without process factors")
75
+ if mixture and not include and mixture_grid is None:
76
+ raise ValueError(
77
+ "Mixture factors require a non-empty mixture_include or mixture_grid"
78
+ )
79
+ if not mixture and (include or mixture_grid is not None):
80
+ raise ValueError("Mixture candidates were requested without mixture factors")
81
+
82
+ process_coded: pd.DataFrame | None = None
83
+ if process_strategy == "lhs":
84
+ if lhs_n_samples is None:
85
+ raise ValueError("lhs_n_samples must be specified for 'lhs' strategy")
86
+ if lhs_n_samples <= 0:
87
+ raise ValueError("lhs_n_samples must be greater than zero")
88
+ if not continuous:
89
+ raise ValueError("'lhs' strategy requires at least one continuous factor")
90
+ process_coded = lhs_grid(continuous, lhs_n_samples, random_state=42)
91
+ process_coded = self._cross_categorical(
92
+ process_coded, factors, categorical
93
+ )
94
+ elif process_strategy == "grid":
95
+ levels = [factors[name].coded_levels for name in process]
96
+ process_coded = pd.DataFrame(
97
+ itertools.product(*levels), columns=process
98
+ )
99
+ elif process_strategy in {"ccc", "ccf", "cci"}:
100
+ if not continuous:
101
+ raise ValueError(
102
+ f"'{process_strategy}' strategy requires continuous factors"
103
+ )
104
+ ccd = CentralCompositeDesign(
105
+ factors={name: factors[name] for name in continuous},
106
+ design=process_strategy,
107
+ center_points=1,
108
+ replicates=0,
109
+ )
110
+ process_coded = self._cross_categorical(
111
+ ccd._coded_design_matrix, factors, categorical
112
+ )
113
+ elif process_strategy == "bb":
114
+ if not continuous:
115
+ raise ValueError("'bb' strategy requires continuous factors")
116
+ bb = BoxBehnkenDesign(
117
+ factors={name: factors[name] for name in continuous},
118
+ replicates=0,
119
+ center_points=1,
120
+ )
121
+ process_coded = self._cross_categorical(
122
+ bb._coded_design_matrix, factors, categorical
123
+ )
124
+ elif process_strategy is not None:
125
+ raise ValueError(f"Unknown process design strategy: {process_strategy!r}")
126
+
127
+ process_actual: pd.DataFrame | None = None
128
+ if process_coded is not None:
129
+ process_actual = self._decode_matrix(process_coded)
130
+ for name in continuous:
131
+ process_actual[name] = process_actual[name].round(
132
+ factors[name].decimals
133
+ )
134
+
135
+ mixture_actual: pd.DataFrame | None = None
136
+ if mixture:
137
+ mixture_actual = build_candidate_points(
138
+ lower_bounds=[factors[name].lower_bound for name in mixture],
139
+ upper_bounds=[factors[name].upper_bound for name in mixture],
140
+ component_names=mixture,
141
+ include=include,
142
+ grid=mixture_grid,
143
+ )[mixture].reset_index(drop=True)
144
+
145
+ if process_actual is not None and mixture_actual is not None:
146
+ candidates = process_actual.merge(mixture_actual, how="cross")
147
+ elif process_actual is not None:
148
+ candidates = process_actual.copy()
149
+ elif mixture_actual is not None:
150
+ candidates = mixture_actual.copy()
151
+ else:
152
+ raise ValueError("No candidate points requested")
153
+
154
+ if filters:
155
+ mask = pd.Series(True, index=candidates.index)
156
+ for domain_filter in filters:
157
+ result = domain_filter(candidates)
158
+ if not isinstance(result, pd.Series) or len(result) != len(candidates):
159
+ raise TypeError(
160
+ "Each filter must return a pandas Series with one value per candidate"
161
+ )
162
+ mask &= result.astype(bool)
163
+ candidates = candidates.loc[mask]
164
+
165
+ candidates = candidates[factor_names].drop_duplicates().reset_index(drop=True)
166
+ if candidates.empty:
167
+ raise ValueError("Candidate-point generation produced an empty set")
168
+ coded = self._code_matrix(candidates)[factor_names].reset_index(drop=True)
169
+ return coded, candidates
170
+
171
+ @staticmethod
172
+ def _cross_categorical(
173
+ process_coded: pd.DataFrame,
174
+ factors: Mapping[str, object],
175
+ categorical: list[str],
176
+ ) -> pd.DataFrame:
177
+ if not categorical:
178
+ return process_coded.reset_index(drop=True)
179
+ category_points = pd.DataFrame(
180
+ itertools.product(
181
+ *(factors[name].coded_levels for name in categorical)
182
+ ),
183
+ columns=categorical,
184
+ )
185
+ return process_coded.merge(category_points, how="cross")
@@ -0,0 +1,299 @@
1
+ """D-optimal experimental designs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Collection, Mapping
6
+ from typing import Literal
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import plotly.graph_objects as go
11
+
12
+ from ...graphs import GraphsMixin
13
+ from ...graphs.design_plot_builder import DesignPlotOptions, build_design_plot
14
+ from ...utils import Design
15
+ from ...utils.model_spec import ModelTerms, compile_model_spec
16
+ from .candidate_set import DOptimalCandidateSetMixin
17
+ from .optimizer import (
18
+ build_d_optimal_solutions_figure,
19
+ optimize_d_optimal,
20
+ )
21
+
22
+
23
+ class DOptDesign(DOptimalCandidateSetMixin, Design, GraphsMixin):
24
+ """Generate and select a D-optimal experimental design.
25
+
26
+ Candidate points are generated during initialization. Process candidates
27
+ come from ``process_strategy``; mixture candidates are explicitly selected
28
+ with ``mixture_include``, ``mixture_grid``, or both. For mixed designs, the
29
+ process and mixture candidate sets are combined by Cartesian product.
30
+
31
+ Parameters:
32
+ factors (dict[str, object]): Mapping of names to ``ContinuousFactor``,
33
+ ``CategoricalFactor``, or ``MixtureFactor`` objects.
34
+ process_strategy (str, optional): Candidate strategy for process factors.
35
+ Supported values are ``"grid"``, ``"lhs"``, ``"ccc"``, ``"ccf"``,
36
+ ``"cci"``, and ``"bb"``. Required when process factors are present
37
+ and omitted for mixture-only designs. Defaults to ``None``.
38
+ mixture_include (str | Collection[str] | None, optional): Geometric
39
+ mixture candidates. Pass ``"all"`` or select from ``"vertices"``,
40
+ ``"edge_midpoints"``, ``"face_centroids"``, and
41
+ ``"global_centroid"``. Defaults to an empty collection.
42
+ mixture_grid (Mapping[str, int] | None, optional): Optional bounded
43
+ simplex-lattice configuration. The mapping must contain ``"degree"``
44
+ and may contain ``"max_candidates"``. Defaults to ``None``.
45
+ lhs_n_samples (int | None, optional): Number of Latin-hypercube points.
46
+ Required only when ``process_strategy="lhs"``. Defaults to ``None``.
47
+ filters (list[Callable] | None, optional): Functions evaluated on the
48
+ actual-valued candidate frame. Each function must return one Boolean
49
+ value per candidate row. Defaults to ``None``.
50
+
51
+ Raises:
52
+ ValueError: If ``factors`` is empty or candidate-generation options are
53
+ missing, incompatible, or produce an empty candidate set.
54
+ TypeError: If a candidate filter does not return a pandas Series with one
55
+ value per candidate row.
56
+
57
+ Notes:
58
+ Mixture geometry is opt-in. A design containing mixture factors must
59
+ request ``mixture_include``, ``mixture_grid``, or both.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ factors: dict[str, object],
65
+ process_strategy: Literal["grid", "ccf", "cci", "ccc", "bb", "lhs"]
66
+ | None = None,
67
+ mixture_include: Literal[
68
+ "all",
69
+ "vertices",
70
+ "edge_midpoints",
71
+ "face_centroids",
72
+ "global_centroid",
73
+ ]
74
+ | Collection[str]
75
+ | None = (),
76
+ mixture_grid: Mapping[str, int] | None = None,
77
+ lhs_n_samples: int | None = None,
78
+ filters: list[Callable[[pd.DataFrame], pd.Series]] | None = None,
79
+ ) -> None:
80
+ super().__init__()
81
+ if not factors:
82
+ raise ValueError("factors must contain at least one factor")
83
+ self._factors = factors
84
+ self.set_domain_filters(filters)
85
+ self._coded_cp, self._cp = self.build_cp_matrix(
86
+ factors=factors,
87
+ process_strategy=process_strategy,
88
+ mixture_include=mixture_include,
89
+ mixture_grid=mixture_grid,
90
+ lhs_n_samples=lhs_n_samples,
91
+ filters=filters,
92
+ )
93
+ self._cp_model_matrix: pd.DataFrame | None = None
94
+ self._best_idx: dict[int, list[int]] = {}
95
+ self._log_det: pd.DataFrame | None = None
96
+ self._design_type = "D-Optimal"
97
+
98
+ def build_cp_matrix(
99
+ self,
100
+ factors: dict[str, object],
101
+ process_strategy: str | None = None,
102
+ mixture_include: str | Collection[str] | None = (),
103
+ mixture_grid: Mapping[str, int] | None = None,
104
+ lhs_n_samples: int | None = None,
105
+ filters: list[Callable[[pd.DataFrame], pd.Series]] | None = None,
106
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
107
+ """Build and return coded and actual D-optimal candidate matrices."""
108
+ return self._generate_d_optimal_candidates(
109
+ factors=factors,
110
+ process_strategy=process_strategy,
111
+ mixture_include=mixture_include,
112
+ mixture_grid=mixture_grid,
113
+ lhs_n_samples=lhs_n_samples,
114
+ filters=filters,
115
+ )
116
+
117
+ def set_model_terms(self, terms: ModelTerms = ModelTerms) -> None:
118
+ """Define the model optimized by the D-optimal search.
119
+
120
+ Args:
121
+ terms (ModelTerms): Process and mixture terms to include.
122
+
123
+ Raises:
124
+ ValueError: If the model contains more coefficients than candidate
125
+ points.
126
+ """
127
+ process = [
128
+ name
129
+ for name, factor in self._factors.items()
130
+ if factor.type in {"cont", "cat"}
131
+ ]
132
+ mixture = [
133
+ name for name, factor in self._factors.items() if factor.type == "mix"
134
+ ]
135
+ self._model_spec = compile_model_spec(terms, process, mixture)
136
+ if self._model_spec.model_terms > len(self._cp):
137
+ raise ValueError(
138
+ f"The number of model terms ({self._model_spec.model_terms}) "
139
+ f"exceeds the number of candidate points ({len(self._cp)})."
140
+ )
141
+ self._cp_model_matrix = self._build_model_matrix(
142
+ self._coded_cp, self._model_spec
143
+ )
144
+
145
+ def compute_d_optimal(
146
+ self,
147
+ n_min: int,
148
+ n_max: int,
149
+ step: int = 1,
150
+ trials: int = 50,
151
+ max_no_improve: int = 5,
152
+ graph: bool = True,
153
+ random_state: int | np.random.Generator | None = None,
154
+ ) -> go.Figure:
155
+ """Compute D-optimal subsets for the requested total run counts.
156
+
157
+ Args:
158
+ n_min (int): Smallest total run count to optimize.
159
+ n_max (int): Largest total run count to optimize.
160
+ step (int, optional): Increment between optimized run counts.
161
+ Defaults to 1.
162
+ trials (int, optional): Random exchange-search starts per run count.
163
+ Defaults to 50.
164
+ max_no_improve (int, optional): Maximum rejected improving-exchange
165
+ proposals before a trial stops. Defaults to 5.
166
+ graph (bool, optional): Retained for API compatibility. The returned
167
+ figure is never displayed automatically. Defaults to ``True``.
168
+ random_state (int | numpy.random.Generator | None, optional): Seed or
169
+ generator used by the randomized starts. Defaults to ``None``.
170
+
171
+ Returns:
172
+ plotly.graph_objects.Figure: D-optimality curve for the computed run
173
+ counts.
174
+
175
+ Raises:
176
+ ValueError: If model terms have not been set or the requested run
177
+ range is invalid for the candidate set and model.
178
+ RuntimeError: If a full-rank subset cannot be found.
179
+ """
180
+ if self._cp_model_matrix is None:
181
+ raise ValueError("No model matrix defined; call 'set_model_terms' first")
182
+ result = optimize_d_optimal(
183
+ self._cp_model_matrix,
184
+ n_min=n_min,
185
+ n_max=n_max,
186
+ step=step,
187
+ trials=trials,
188
+ max_no_improve=max_no_improve,
189
+ random_state=random_state,
190
+ )
191
+ self._best_idx = result.best_indices
192
+ self._log_det = result.log_det
193
+ return build_d_optimal_solutions_figure(self._log_det)
194
+
195
+ def plot_candidate_set(
196
+ self,
197
+ ax1: str,
198
+ ax2: str,
199
+ ax3: str | None = None,
200
+ ax4: str | None = None,
201
+ coded: bool = False,
202
+ *,
203
+ show_title: bool = True,
204
+ show_summary: bool = True,
205
+ show_legend: bool = True,
206
+ show_grid: bool = True,
207
+ marker_color: str = "#495057",
208
+ show_hover: bool = True,
209
+ show_run_labels: bool = False,
210
+ show_replicate_count: bool = True,
211
+ aggregate_projected_points: bool = True,
212
+ axis_label_mode: str = "symbol_unit",
213
+ height: int | None = None,
214
+ domain: Literal["full", "allowed"] = "full",
215
+ ) -> go.Figure:
216
+ """Visualize the candidate set before selecting a design.
217
+
218
+ Args:
219
+ ax1 (str): First factor shown in the plot.
220
+ ax2 (str): Second factor shown in the plot.
221
+ ax3 (str, optional): Third plotted factor.
222
+ ax4 (str, optional): Fourth mixture component for a tetrahedral
223
+ plot.
224
+ coded (bool): Whether to display coded rather than actual units.
225
+ show_title (bool): Whether to display the generated title.
226
+ show_summary (bool): Whether to display the candidate-set summary.
227
+ show_legend (bool): Whether to display the legend.
228
+ show_grid (bool): Whether to display grid lines.
229
+ marker_color (str): Plotly-compatible marker color.
230
+ show_hover (bool): Whether to display point information on hover.
231
+ show_run_labels (bool): Whether to label candidate points.
232
+ show_replicate_count (bool): Whether to include counts for repeated
233
+ candidate settings.
234
+ aggregate_projected_points (bool): Whether candidates with identical
235
+ plotted coordinates are combined when other factors are omitted.
236
+ axis_label_mode (str): Axis-label format. Supported values are
237
+ ``"symbol_unit"``, ``"symbol"``, and ``"name"``.
238
+ height (int, optional): Figure height in pixels.
239
+ domain (str): Mixture domain to draw. ``"full"`` draws the full
240
+ simplex or tetrahedron; ``"allowed"`` draws the domain covered
241
+ by the candidate points.
242
+
243
+ Returns:
244
+ plotly.graph_objects.Figure: Candidate-set visualization.
245
+
246
+ Notes:
247
+ Two or three non-mixture factors produce Cartesian plots. Two,
248
+ three, or four mixture components produce mixture-line, ternary, or
249
+ tetrahedral plots.
250
+ """
251
+ options = DesignPlotOptions(
252
+ coded=coded,
253
+ marker_color=marker_color,
254
+ show_title=show_title,
255
+ show_summary=show_summary,
256
+ show_legend=show_legend,
257
+ show_grid=show_grid,
258
+ show_hover=show_hover,
259
+ show_run_labels=show_run_labels,
260
+ show_replicate_count=show_replicate_count,
261
+ aggregate_projected_points=aggregate_projected_points,
262
+ axis_label_mode=axis_label_mode,
263
+ height=height,
264
+ domain=domain,
265
+ )
266
+ return build_design_plot(
267
+ design_matrix=self._cp,
268
+ coded_design_matrix=self._coded_cp,
269
+ factors=self._factors,
270
+ design_type="Candidate Set Design",
271
+ axes=[axis for axis in (ax1, ax2, ax3, ax4) if axis is not None],
272
+ options=options,
273
+ )
274
+
275
+ def select_design(self, n: int) -> None:
276
+ """Select a previously computed design containing ``n`` total runs.
277
+
278
+ Args:
279
+ n (int): Total run count returned by ``compute_d_optimal``.
280
+
281
+ Raises:
282
+ KeyError: If no solution was computed for ``n``.
283
+ """
284
+ if n not in self._best_idx:
285
+ raise KeyError(
286
+ f"No D-optimal solution was computed for {n} runs"
287
+ )
288
+ indices = self._best_idx[n]
289
+ self._coded_design_matrix = self._coded_cp.iloc[indices].reset_index(drop=True)
290
+ self._design_matrix = self._cp.iloc[indices].reset_index(drop=True)
291
+ self._model_matrix = self._build_model_matrix(
292
+ self._coded_design_matrix, self._model_spec
293
+ )
294
+ self._number_of_center_points(self._coded_design_matrix)
295
+
296
+ @property
297
+ def log_det(self) -> pd.DataFrame | None:
298
+ """Normalized log-determinant values indexed by total run count."""
299
+ return self._log_det