phased-array-systems 0.1.0__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 (43) hide show
  1. phased_array_systems/__about__.py +4 -0
  2. phased_array_systems/__init__.py +10 -0
  3. phased_array_systems/architecture/__init__.py +15 -0
  4. phased_array_systems/architecture/config.py +152 -0
  5. phased_array_systems/cli.py +25 -0
  6. phased_array_systems/constants.py +55 -0
  7. phased_array_systems/evaluate.py +136 -0
  8. phased_array_systems/io/__init__.py +13 -0
  9. phased_array_systems/io/config_loader.py +86 -0
  10. phased_array_systems/io/exporters.py +171 -0
  11. phased_array_systems/io/schema.py +145 -0
  12. phased_array_systems/models/__init__.py +5 -0
  13. phased_array_systems/models/antenna/__init__.py +15 -0
  14. phased_array_systems/models/antenna/adapter.py +190 -0
  15. phased_array_systems/models/antenna/metrics.py +166 -0
  16. phased_array_systems/models/base.py +30 -0
  17. phased_array_systems/models/comms/__init__.py +9 -0
  18. phased_array_systems/models/comms/link_budget.py +171 -0
  19. phased_array_systems/models/comms/propagation.py +84 -0
  20. phased_array_systems/models/swapc/__init__.py +9 -0
  21. phased_array_systems/models/swapc/cost.py +98 -0
  22. phased_array_systems/models/swapc/power.py +102 -0
  23. phased_array_systems/requirements/__init__.py +15 -0
  24. phased_array_systems/requirements/core.py +244 -0
  25. phased_array_systems/scenarios/__init__.py +11 -0
  26. phased_array_systems/scenarios/base.py +30 -0
  27. phased_array_systems/scenarios/comms.py +56 -0
  28. phased_array_systems/scenarios/radar.py +42 -0
  29. phased_array_systems/trades/__init__.py +16 -0
  30. phased_array_systems/trades/design_space.py +241 -0
  31. phased_array_systems/trades/doe.py +146 -0
  32. phased_array_systems/trades/pareto.py +266 -0
  33. phased_array_systems/trades/runner.py +245 -0
  34. phased_array_systems/types.py +54 -0
  35. phased_array_systems/utils/__init__.py +8 -0
  36. phased_array_systems/utils/hashing.py +70 -0
  37. phased_array_systems/viz/__init__.py +9 -0
  38. phased_array_systems/viz/plots.py +324 -0
  39. phased_array_systems-0.1.0.dist-info/METADATA +174 -0
  40. phased_array_systems-0.1.0.dist-info/RECORD +43 -0
  41. phased_array_systems-0.1.0.dist-info/WHEEL +4 -0
  42. phased_array_systems-0.1.0.dist-info/entry_points.txt +2 -0
  43. phased_array_systems-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,146 @@
1
+ """Design of Experiments (DOE) generation utilities."""
2
+
3
+ from typing import Literal
4
+
5
+ import pandas as pd
6
+
7
+ from phased_array_systems.trades.design_space import DesignSpace
8
+
9
+
10
+ def generate_doe(
11
+ design_space: DesignSpace,
12
+ method: Literal["grid", "random", "lhs"] = "lhs",
13
+ n_samples: int = 100,
14
+ seed: int | None = None,
15
+ grid_levels: int | list[int] | None = None,
16
+ ) -> pd.DataFrame:
17
+ """Generate a Design of Experiments from a design space.
18
+
19
+ This is a convenience function that wraps DesignSpace.sample().
20
+
21
+ Args:
22
+ design_space: DesignSpace defining the variables and bounds
23
+ method: Sampling method
24
+ - "grid": Full factorial grid (n_samples ignored)
25
+ - "random": Uniform random sampling
26
+ - "lhs": Latin Hypercube Sampling (space-filling)
27
+ n_samples: Number of samples (for random/lhs methods)
28
+ seed: Random seed for reproducibility
29
+ grid_levels: Number of levels per variable for grid method
30
+
31
+ Returns:
32
+ DataFrame with columns:
33
+ - case_id: Unique identifier for each case
34
+ - One column per design variable
35
+
36
+ Examples:
37
+ >>> space = DesignSpace()
38
+ >>> space.add_variable("array.nx", "int", low=4, high=16)
39
+ >>> space.add_variable("array.ny", "int", low=4, high=16)
40
+ >>> space.add_variable("rf.tx_power_w_per_elem", "float", low=0.5, high=2.0)
41
+ >>> doe = generate_doe(space, method="lhs", n_samples=50, seed=42)
42
+ """
43
+ return design_space.sample(
44
+ method=method,
45
+ n_samples=n_samples,
46
+ seed=seed,
47
+ grid_levels=grid_levels,
48
+ )
49
+
50
+
51
+ def generate_doe_from_dict(
52
+ variables: dict,
53
+ method: Literal["grid", "random", "lhs"] = "lhs",
54
+ n_samples: int = 100,
55
+ seed: int | None = None,
56
+ ) -> pd.DataFrame:
57
+ """Generate DOE from a simplified dictionary specification.
58
+
59
+ Convenience function for quick DOE generation without creating
60
+ explicit DesignVariable objects.
61
+
62
+ Args:
63
+ variables: Dictionary mapping variable names to specs:
64
+ - For continuous: {"name": (low, high)} or {"name": (low, high, "float")}
65
+ - For discrete: {"name": (low, high, "int")}
66
+ - For categorical: {"name": ["value1", "value2", ...]}
67
+ method: Sampling method
68
+ n_samples: Number of samples
69
+ seed: Random seed
70
+
71
+ Returns:
72
+ DataFrame with DOE cases
73
+
74
+ Examples:
75
+ >>> doe = generate_doe_from_dict({
76
+ ... "array.nx": (4, 16, "int"),
77
+ ... "array.ny": (4, 16, "int"),
78
+ ... "rf.tx_power_w_per_elem": (0.5, 2.0),
79
+ ... "array.geometry": ["rectangular", "triangular"],
80
+ ... }, n_samples=50)
81
+ """
82
+ space = DesignSpace()
83
+
84
+ for name, spec in variables.items():
85
+ if isinstance(spec, list):
86
+ # Categorical
87
+ space.add_variable(name, type="categorical", values=spec)
88
+ elif isinstance(spec, tuple):
89
+ if len(spec) == 2:
90
+ # (low, high) -> float
91
+ space.add_variable(name, type="float", low=spec[0], high=spec[1])
92
+ elif len(spec) == 3:
93
+ # (low, high, type)
94
+ space.add_variable(name, type=spec[2], low=spec[0], high=spec[1])
95
+ else:
96
+ raise ValueError(f"Invalid spec for '{name}': {spec}")
97
+ else:
98
+ raise ValueError(f"Invalid spec type for '{name}': {type(spec)}")
99
+
100
+ return generate_doe(space, method=method, n_samples=n_samples, seed=seed)
101
+
102
+
103
+ def augment_doe(
104
+ existing_doe: pd.DataFrame,
105
+ design_space: DesignSpace,
106
+ n_additional: int,
107
+ method: Literal["random", "lhs"] = "lhs",
108
+ seed: int | None = None,
109
+ ) -> pd.DataFrame:
110
+ """Add additional samples to an existing DOE.
111
+
112
+ Useful for adaptive sampling or expanding a study.
113
+
114
+ Args:
115
+ existing_doe: Existing DOE DataFrame
116
+ design_space: DesignSpace defining the variables
117
+ n_additional: Number of additional samples to add
118
+ method: Sampling method for new samples
119
+ seed: Random seed
120
+
121
+ Returns:
122
+ Combined DataFrame with original + new cases
123
+ """
124
+ # Generate new samples
125
+ new_doe = generate_doe(
126
+ design_space,
127
+ method=method,
128
+ n_samples=n_additional,
129
+ seed=seed,
130
+ )
131
+
132
+ # Renumber case IDs to avoid collision
133
+ max_existing_id = 0
134
+ for case_id in existing_doe["case_id"]:
135
+ if case_id.startswith("case_"):
136
+ try:
137
+ num = int(case_id.replace("case_", ""))
138
+ max_existing_id = max(max_existing_id, num)
139
+ except ValueError:
140
+ pass
141
+
142
+ new_ids = [f"case_{i:05d}" for i in range(max_existing_id + 1, max_existing_id + 1 + n_additional)]
143
+ new_doe["case_id"] = new_ids
144
+
145
+ # Combine
146
+ return pd.concat([existing_doe, new_doe], ignore_index=True)
@@ -0,0 +1,266 @@
1
+ """Pareto frontier extraction and analysis utilities."""
2
+
3
+ from typing import Literal
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from phased_array_systems.requirements import RequirementSet
9
+ from phased_array_systems.types import OptimizeDirection
10
+
11
+
12
+ def filter_feasible(
13
+ results: pd.DataFrame,
14
+ requirements: RequirementSet | None = None,
15
+ verification_column: str = "verification.passes",
16
+ ) -> pd.DataFrame:
17
+ """Filter results to only feasible (requirement-passing) designs.
18
+
19
+ Args:
20
+ results: DataFrame with evaluation results
21
+ requirements: Optional RequirementSet to verify against
22
+ verification_column: Column name for verification status
23
+
24
+ Returns:
25
+ DataFrame containing only feasible designs
26
+ """
27
+ if requirements is not None and len(requirements) > 0:
28
+ # Re-verify against requirements
29
+ mask = []
30
+ for _, row in results.iterrows():
31
+ metrics = row.to_dict()
32
+ report = requirements.verify(metrics)
33
+ mask.append(report.passes)
34
+ return results[mask].copy()
35
+
36
+ elif verification_column in results.columns:
37
+ # Use pre-computed verification
38
+ return results[results[verification_column] == 1.0].copy()
39
+
40
+ else:
41
+ # No filtering - return all
42
+ return results.copy()
43
+
44
+
45
+ def extract_pareto(
46
+ results: pd.DataFrame,
47
+ objectives: list[tuple[str, OptimizeDirection]],
48
+ include_dominated: bool = False,
49
+ ) -> pd.DataFrame:
50
+ """Extract Pareto-optimal designs from results.
51
+
52
+ A design is Pareto-optimal if no other design is better in all objectives.
53
+
54
+ Args:
55
+ results: DataFrame with evaluation results
56
+ objectives: List of (column_name, direction) tuples where direction
57
+ is "minimize" or "maximize"
58
+ include_dominated: If True, include a 'pareto_optimal' column marking
59
+ Pareto-optimal rows
60
+
61
+ Returns:
62
+ DataFrame containing only Pareto-optimal designs (or all designs
63
+ with pareto_optimal column if include_dominated=True)
64
+
65
+ Examples:
66
+ >>> pareto = extract_pareto(results, [
67
+ ... ("cost_usd", "minimize"),
68
+ ... ("eirp_dbw", "maximize"),
69
+ ... ])
70
+ """
71
+ if len(results) == 0:
72
+ return results.copy()
73
+
74
+ # Convert to minimization (negate maximization objectives)
75
+ obj_matrix = np.zeros((len(results), len(objectives)))
76
+ for i, (name, direction) in enumerate(objectives):
77
+ values = results[name].values
78
+ if direction == "maximize":
79
+ obj_matrix[:, i] = -values
80
+ else:
81
+ obj_matrix[:, i] = values
82
+
83
+ # Find Pareto-optimal points
84
+ is_pareto = np.ones(len(results), dtype=bool)
85
+
86
+ for i in range(len(results)):
87
+ if not is_pareto[i]:
88
+ continue
89
+
90
+ # Check if any other point dominates point i
91
+ for j in range(len(results)):
92
+ if i == j or not is_pareto[j]:
93
+ continue
94
+
95
+ # j dominates i if j is <= in all objectives and < in at least one
96
+ all_leq = np.all(obj_matrix[j] <= obj_matrix[i])
97
+ any_lt = np.any(obj_matrix[j] < obj_matrix[i])
98
+
99
+ if all_leq and any_lt:
100
+ is_pareto[i] = False
101
+ break
102
+
103
+ if include_dominated:
104
+ result_df = results.copy()
105
+ result_df["pareto_optimal"] = is_pareto
106
+ return result_df
107
+ else:
108
+ return results[is_pareto].copy()
109
+
110
+
111
+ def rank_pareto(
112
+ pareto: pd.DataFrame,
113
+ objectives: list[tuple[str, OptimizeDirection]],
114
+ weights: list[float] | None = None,
115
+ method: Literal["weighted_sum", "topsis"] = "weighted_sum",
116
+ ) -> pd.DataFrame:
117
+ """Rank Pareto-optimal designs using weighted objectives.
118
+
119
+ Args:
120
+ pareto: DataFrame with Pareto-optimal designs
121
+ objectives: List of (column_name, direction) tuples
122
+ weights: Weights for each objective (default: equal weights)
123
+ method: Ranking method ("weighted_sum" or "topsis")
124
+
125
+ Returns:
126
+ DataFrame with added 'rank' and 'score' columns, sorted by rank
127
+ """
128
+ if len(pareto) == 0:
129
+ return pareto.copy()
130
+
131
+ n_obj = len(objectives)
132
+ if weights is None:
133
+ weights = [1.0 / n_obj] * n_obj
134
+ else:
135
+ # Normalize weights
136
+ total = sum(weights)
137
+ weights = [w / total for w in weights]
138
+
139
+ # Extract and normalize objective values
140
+ obj_matrix = np.zeros((len(pareto), n_obj))
141
+ for i, (name, direction) in enumerate(objectives):
142
+ values = pareto[name].values.astype(float)
143
+ # Normalize to [0, 1]
144
+ min_val, max_val = values.min(), values.max()
145
+ if max_val > min_val:
146
+ normalized = (values - min_val) / (max_val - min_val)
147
+ else:
148
+ normalized = np.zeros_like(values)
149
+
150
+ # Flip for maximization (higher is better -> lower normalized score)
151
+ if direction == "maximize":
152
+ normalized = 1 - normalized
153
+
154
+ obj_matrix[:, i] = normalized
155
+
156
+ if method == "weighted_sum":
157
+ # Weighted sum (lower is better)
158
+ scores = np.sum(obj_matrix * weights, axis=1)
159
+
160
+ elif method == "topsis":
161
+ # TOPSIS method
162
+ # Ideal point: min of all (already normalized to minimization)
163
+ ideal = np.zeros(n_obj)
164
+ # Anti-ideal: max of all
165
+ anti_ideal = np.ones(n_obj)
166
+
167
+ # Distance to ideal and anti-ideal
168
+ d_ideal = np.sqrt(np.sum(weights * (obj_matrix - ideal) ** 2, axis=1))
169
+ d_anti = np.sqrt(np.sum(weights * (obj_matrix - anti_ideal) ** 2, axis=1))
170
+
171
+ # TOPSIS score (higher is better, so negate for ranking)
172
+ with np.errstate(divide="ignore", invalid="ignore"):
173
+ scores = d_ideal / (d_ideal + d_anti)
174
+ scores = np.nan_to_num(scores, nan=1.0)
175
+
176
+ else:
177
+ raise ValueError(f"Unknown ranking method: {method}")
178
+
179
+ # Add scores and rank
180
+ result_df = pareto.copy()
181
+ result_df["pareto_score"] = scores
182
+ result_df["pareto_rank"] = result_df["pareto_score"].rank(method="min").astype(int)
183
+
184
+ return result_df.sort_values("pareto_rank")
185
+
186
+
187
+ def compute_hypervolume(
188
+ pareto: pd.DataFrame,
189
+ objectives: list[tuple[str, OptimizeDirection]],
190
+ reference_point: list[float] | None = None,
191
+ ) -> float:
192
+ """Compute hypervolume indicator for a Pareto front.
193
+
194
+ The hypervolume is the volume of objective space dominated by the
195
+ Pareto front, bounded by a reference point. Higher is better.
196
+
197
+ Args:
198
+ pareto: DataFrame with Pareto-optimal designs
199
+ objectives: List of (column_name, direction) tuples
200
+ reference_point: Reference point in objective space (default: worst point + 10%)
201
+
202
+ Returns:
203
+ Hypervolume value
204
+
205
+ Note:
206
+ For >3 objectives, this uses a simple approximation.
207
+ """
208
+ if len(pareto) == 0:
209
+ return 0.0
210
+
211
+ n_obj = len(objectives)
212
+
213
+ # Extract objective values (convert to minimization)
214
+ obj_matrix = np.zeros((len(pareto), n_obj))
215
+ for i, (name, direction) in enumerate(objectives):
216
+ values = pareto[name].values.astype(float)
217
+ if direction == "maximize":
218
+ obj_matrix[:, i] = -values
219
+ else:
220
+ obj_matrix[:, i] = values
221
+
222
+ # Set reference point if not provided
223
+ if reference_point is None:
224
+ worst = obj_matrix.max(axis=0)
225
+ reference_point = worst * 1.1 + 0.1 # 10% beyond worst
226
+
227
+ ref = np.array(reference_point)
228
+
229
+ # For 2D, compute exact hypervolume
230
+ if n_obj == 2:
231
+ # Sort by first objective
232
+ sorted_idx = np.argsort(obj_matrix[:, 0])
233
+ sorted_obj = obj_matrix[sorted_idx]
234
+
235
+ hv = 0.0
236
+ prev_y = ref[1]
237
+ for i in range(len(sorted_obj)):
238
+ x, y = sorted_obj[i]
239
+ if x < ref[0] and y < ref[1]:
240
+ hv += (ref[0] - x) * (prev_y - y)
241
+ prev_y = y
242
+
243
+ return hv
244
+
245
+ else:
246
+ # Monte Carlo approximation for higher dimensions
247
+ n_samples = 10000
248
+ rng = np.random.default_rng(42)
249
+
250
+ # Sample random points in hyperbox
251
+ samples = np.zeros((n_samples, n_obj))
252
+ for i in range(n_obj):
253
+ min_val = obj_matrix[:, i].min()
254
+ samples[:, i] = rng.uniform(min_val, ref[i], n_samples)
255
+
256
+ # Count points dominated by at least one Pareto point
257
+ dominated = np.zeros(n_samples, dtype=bool)
258
+ for pareto_point in obj_matrix:
259
+ is_dominated = np.all(samples >= pareto_point, axis=1)
260
+ dominated |= is_dominated
261
+
262
+ # Estimate hypervolume
263
+ box_volume = np.prod(ref - obj_matrix.min(axis=0))
264
+ hv = box_volume * dominated.sum() / n_samples
265
+
266
+ return hv
@@ -0,0 +1,245 @@
1
+ """Batch evaluation runner for DOE trade studies."""
2
+
3
+ import time
4
+ import traceback
5
+ from collections.abc import Callable
6
+ from concurrent.futures import ProcessPoolExecutor, as_completed
7
+ from pathlib import Path
8
+
9
+ import pandas as pd
10
+
11
+ from phased_array_systems.architecture import Architecture
12
+ from phased_array_systems.evaluate import evaluate_case
13
+ from phased_array_systems.requirements import RequirementSet
14
+ from phased_array_systems.types import Scenario
15
+
16
+
17
+ def _evaluate_single_case(
18
+ case_row: dict,
19
+ scenario: Scenario,
20
+ requirements: RequirementSet | None,
21
+ architecture_builder: Callable[[dict], Architecture],
22
+ ) -> dict:
23
+ """Worker function to evaluate a single case.
24
+
25
+ Args:
26
+ case_row: Dictionary of design variable values
27
+ scenario: Scenario to evaluate against
28
+ requirements: Optional requirements for verification
29
+ architecture_builder: Function to build Architecture from case dict
30
+
31
+ Returns:
32
+ Dictionary with case_id, all design variables, and all metrics
33
+ """
34
+ case_id = case_row.get("case_id", "unknown")
35
+
36
+ try:
37
+ # Build architecture from case parameters
38
+ arch = architecture_builder(case_row)
39
+
40
+ # Evaluate
41
+ metrics = evaluate_case(arch, scenario, requirements, case_id=case_id)
42
+
43
+ # Merge case params with metrics
44
+ result = dict(case_row)
45
+ result.update(metrics)
46
+ result["meta.error"] = None
47
+
48
+ except Exception as e:
49
+ # Case-level error handling - don't crash the batch
50
+ result = dict(case_row)
51
+ result["meta.error"] = f"{type(e).__name__}: {e}"
52
+ result["meta.traceback"] = traceback.format_exc()
53
+ result["meta.runtime_s"] = 0.0
54
+
55
+ return result
56
+
57
+
58
+ class BatchRunner:
59
+ """Parallel batch evaluation of DOE cases.
60
+
61
+ Evaluates multiple architecture/scenario combinations with
62
+ case-level error handling, progress reporting, and resume capability.
63
+
64
+ Attributes:
65
+ scenario: Scenario to evaluate against
66
+ requirements: Optional requirements for verification
67
+ architecture_builder: Function to build Architecture from case dict
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ scenario: Scenario,
73
+ requirements: RequirementSet | None = None,
74
+ architecture_builder: Callable[[dict], Architecture] | None = None,
75
+ ):
76
+ """Initialize the batch runner.
77
+
78
+ Args:
79
+ scenario: Scenario to evaluate
80
+ requirements: Optional requirements for verification
81
+ architecture_builder: Function to convert case dict to Architecture.
82
+ If None, uses default_architecture_builder.
83
+ """
84
+ self.scenario = scenario
85
+ self.requirements = requirements
86
+ self.architecture_builder = architecture_builder or default_architecture_builder
87
+
88
+ def run(
89
+ self,
90
+ cases: pd.DataFrame,
91
+ n_workers: int = 1,
92
+ cache_path: Path | None = None,
93
+ progress_callback: Callable[[int, int], None] | None = None,
94
+ ) -> pd.DataFrame:
95
+ """Run batch evaluation.
96
+
97
+ Args:
98
+ cases: DataFrame with design variable columns + case_id
99
+ n_workers: Number of parallel workers (1 = sequential)
100
+ cache_path: Optional path to save/load partial results
101
+ progress_callback: Optional callback(completed, total) for progress
102
+
103
+ Returns:
104
+ DataFrame with all input columns + all metric columns
105
+ """
106
+ # Load cached results if available
107
+ completed_ids = set()
108
+ cached_results = []
109
+
110
+ if cache_path is not None and cache_path.exists():
111
+ try:
112
+ cached_df = pd.read_parquet(cache_path)
113
+ completed_ids = set(cached_df["case_id"])
114
+ cached_results = cached_df.to_dict("records")
115
+ print(f"Resuming: {len(completed_ids)} cases already completed")
116
+ except Exception:
117
+ pass # Ignore cache errors
118
+
119
+ # Filter to uncompleted cases
120
+ cases_to_run = cases[~cases["case_id"].isin(completed_ids)]
121
+ total_cases = len(cases)
122
+ remaining = len(cases_to_run)
123
+
124
+ if remaining == 0:
125
+ print("All cases already completed")
126
+ return pd.DataFrame(cached_results)
127
+
128
+ print(f"Running {remaining} cases ({len(completed_ids)} cached)")
129
+
130
+ # Convert to list of dicts for processing
131
+ case_dicts = cases_to_run.to_dict("records")
132
+
133
+ results = list(cached_results)
134
+ start_time = time.perf_counter()
135
+
136
+ if n_workers == 1:
137
+ # Sequential execution
138
+ for i, case_row in enumerate(case_dicts):
139
+ result = _evaluate_single_case(
140
+ case_row,
141
+ self.scenario,
142
+ self.requirements,
143
+ self.architecture_builder,
144
+ )
145
+ results.append(result)
146
+
147
+ if progress_callback:
148
+ progress_callback(len(results), total_cases)
149
+
150
+ # Save intermediate results
151
+ if cache_path is not None and (i + 1) % 10 == 0:
152
+ self._save_cache(results, cache_path)
153
+
154
+ else:
155
+ # Parallel execution
156
+ with ProcessPoolExecutor(max_workers=n_workers) as executor:
157
+ futures = {
158
+ executor.submit(
159
+ _evaluate_single_case,
160
+ case_row,
161
+ self.scenario,
162
+ self.requirements,
163
+ self.architecture_builder,
164
+ ): case_row["case_id"]
165
+ for case_row in case_dicts
166
+ }
167
+
168
+ for future in as_completed(futures):
169
+ result = future.result()
170
+ results.append(result)
171
+
172
+ if progress_callback:
173
+ progress_callback(len(results), total_cases)
174
+
175
+ # Save intermediate results periodically
176
+ if cache_path is not None and len(results) % 10 == 0:
177
+ self._save_cache(results, cache_path)
178
+
179
+ elapsed = time.perf_counter() - start_time
180
+ print(f"Completed {remaining} cases in {elapsed:.1f}s ({elapsed/remaining:.3f}s/case)")
181
+
182
+ # Final save
183
+ if cache_path is not None:
184
+ self._save_cache(results, cache_path)
185
+
186
+ # Build result DataFrame
187
+ result_df = pd.DataFrame(results)
188
+
189
+ # Ensure consistent column order
190
+ cols = list(cases.columns) + [
191
+ c for c in result_df.columns if c not in cases.columns
192
+ ]
193
+ result_df = result_df[[c for c in cols if c in result_df.columns]]
194
+
195
+ return result_df
196
+
197
+ def _save_cache(self, results: list[dict], cache_path: Path) -> None:
198
+ """Save results to cache file."""
199
+ try:
200
+ df = pd.DataFrame(results)
201
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
202
+ df.to_parquet(cache_path)
203
+ except Exception as e:
204
+ print(f"Warning: Failed to save cache: {e}")
205
+
206
+
207
+ def default_architecture_builder(case_row: dict) -> Architecture:
208
+ """Default function to build Architecture from DOE case dictionary.
209
+
210
+ Expects keys like "array.nx", "rf.tx_power_w_per_elem", etc.
211
+
212
+ Args:
213
+ case_row: Dictionary with dot-notation keys
214
+
215
+ Returns:
216
+ Architecture object
217
+ """
218
+ # Filter out non-architecture keys
219
+ arch_keys = {k: v for k, v in case_row.items()
220
+ if k.startswith(("array.", "rf.", "cost.")) or k == "name"}
221
+
222
+ return Architecture.from_flat(arch_keys)
223
+
224
+
225
+ def run_batch_simple(
226
+ cases: pd.DataFrame,
227
+ scenario: Scenario,
228
+ requirements: RequirementSet | None = None,
229
+ n_workers: int = 1,
230
+ ) -> pd.DataFrame:
231
+ """Simple batch run without caching or progress.
232
+
233
+ Convenience function for basic batch evaluation.
234
+
235
+ Args:
236
+ cases: DataFrame with design variable columns
237
+ scenario: Scenario to evaluate
238
+ requirements: Optional requirements
239
+ n_workers: Number of parallel workers
240
+
241
+ Returns:
242
+ Results DataFrame
243
+ """
244
+ runner = BatchRunner(scenario, requirements)
245
+ return runner.run(cases, n_workers=n_workers)