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.
- phased_array_systems/__about__.py +4 -0
- phased_array_systems/__init__.py +10 -0
- phased_array_systems/architecture/__init__.py +15 -0
- phased_array_systems/architecture/config.py +152 -0
- phased_array_systems/cli.py +25 -0
- phased_array_systems/constants.py +55 -0
- phased_array_systems/evaluate.py +136 -0
- phased_array_systems/io/__init__.py +13 -0
- phased_array_systems/io/config_loader.py +86 -0
- phased_array_systems/io/exporters.py +171 -0
- phased_array_systems/io/schema.py +145 -0
- phased_array_systems/models/__init__.py +5 -0
- phased_array_systems/models/antenna/__init__.py +15 -0
- phased_array_systems/models/antenna/adapter.py +190 -0
- phased_array_systems/models/antenna/metrics.py +166 -0
- phased_array_systems/models/base.py +30 -0
- phased_array_systems/models/comms/__init__.py +9 -0
- phased_array_systems/models/comms/link_budget.py +171 -0
- phased_array_systems/models/comms/propagation.py +84 -0
- phased_array_systems/models/swapc/__init__.py +9 -0
- phased_array_systems/models/swapc/cost.py +98 -0
- phased_array_systems/models/swapc/power.py +102 -0
- phased_array_systems/requirements/__init__.py +15 -0
- phased_array_systems/requirements/core.py +244 -0
- phased_array_systems/scenarios/__init__.py +11 -0
- phased_array_systems/scenarios/base.py +30 -0
- phased_array_systems/scenarios/comms.py +56 -0
- phased_array_systems/scenarios/radar.py +42 -0
- phased_array_systems/trades/__init__.py +16 -0
- phased_array_systems/trades/design_space.py +241 -0
- phased_array_systems/trades/doe.py +146 -0
- phased_array_systems/trades/pareto.py +266 -0
- phased_array_systems/trades/runner.py +245 -0
- phased_array_systems/types.py +54 -0
- phased_array_systems/utils/__init__.py +8 -0
- phased_array_systems/utils/hashing.py +70 -0
- phased_array_systems/viz/__init__.py +9 -0
- phased_array_systems/viz/plots.py +324 -0
- phased_array_systems-0.1.0.dist-info/METADATA +174 -0
- phased_array_systems-0.1.0.dist-info/RECORD +43 -0
- phased_array_systems-0.1.0.dist-info/WHEEL +4 -0
- phased_array_systems-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|