goad-py 0.5.1__cp38-abi3-win_amd64.whl → 0.5.5__cp38-abi3-win_amd64.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.
Potentially problematic release.
This version of goad-py might be problematic. Click here for more details.
- goad_py/__init__.py +46 -2
- goad_py/_goad_py.pyd +0 -0
- goad_py/convergence.py +583 -83
- goad_py/phips_convergence.py +580 -0
- goad_py/unified_convergence.py +1085 -0
- {goad_py-0.5.1.dist-info → goad_py-0.5.5.dist-info}/METADATA +3 -1
- goad_py-0.5.5.dist-info/RECORD +9 -0
- {goad_py-0.5.1.dist-info → goad_py-0.5.5.dist-info}/WHEEL +1 -1
- goad_py-0.5.1.dist-info/RECORD +0 -7
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PHIPS-specific convergence extension for GOAD.
|
|
3
|
+
|
|
4
|
+
This module provides convergence tracking for PHIPS detector DSCS values,
|
|
5
|
+
which requires Custom binning with PHIPS detector geometry and post-processing
|
|
6
|
+
to compute mean DSCS at each of the 20 PHIPS detectors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import List, Dict, Optional, Tuple
|
|
11
|
+
import numpy as np
|
|
12
|
+
import os
|
|
13
|
+
import random
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from . import _goad_py as goad
|
|
16
|
+
from .convergence import ConvergenceResults
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PHIPSConvergable:
|
|
21
|
+
"""Convergence criteria for PHIPS detector DSCS values."""
|
|
22
|
+
|
|
23
|
+
tolerance_type: str = "relative" # 'relative' or 'absolute'
|
|
24
|
+
tolerance: float = 0.25 # Default 25% relative tolerance
|
|
25
|
+
detector_indices: Optional[List[int]] = (
|
|
26
|
+
None # Specific detectors to check (None = all)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
valid_types = {"relative", "absolute"}
|
|
31
|
+
if self.tolerance_type not in valid_types:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Invalid tolerance_type '{self.tolerance_type}'. Must be one of {valid_types}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if self.tolerance <= 0:
|
|
37
|
+
raise ValueError(f"Tolerance must be positive, got {self.tolerance}")
|
|
38
|
+
|
|
39
|
+
if self.detector_indices is not None:
|
|
40
|
+
if not isinstance(self.detector_indices, list):
|
|
41
|
+
raise ValueError("detector_indices must be a list of integers")
|
|
42
|
+
if not all(0 <= idx < 20 for idx in self.detector_indices):
|
|
43
|
+
raise ValueError("detector_indices must be in range [0, 19]")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PHIPSConvergence:
|
|
47
|
+
"""
|
|
48
|
+
Convergence study for PHIPS detector DSCS values.
|
|
49
|
+
|
|
50
|
+
Requires Custom binning with PHIPS detector geometry (phips_bins_edges.toml).
|
|
51
|
+
Computes mean DSCS at each of 20 PHIPS detectors and tracks convergence.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# PHIPS detector parameters (from phips_detector_angles.py)
|
|
55
|
+
NUM_DETECTORS = 20
|
|
56
|
+
THETA_START = 18.0 # degrees
|
|
57
|
+
THETA_END = 170.0 # degrees
|
|
58
|
+
DETECTOR_WIDTH = 7.0 # degrees (aperture)
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
settings: goad.Settings,
|
|
63
|
+
convergable: PHIPSConvergable,
|
|
64
|
+
batch_size: int = 24,
|
|
65
|
+
max_orientations: int = 100_000,
|
|
66
|
+
min_batches: int = 10,
|
|
67
|
+
):
|
|
68
|
+
"""
|
|
69
|
+
Initialize a PHIPS convergence study.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
settings: GOAD settings with Custom binning scheme
|
|
73
|
+
convergable: PHIPS convergence criteria
|
|
74
|
+
batch_size: Number of orientations per iteration
|
|
75
|
+
max_orientations: Maximum total orientations before stopping
|
|
76
|
+
min_batches: Minimum number of batches before allowing convergence
|
|
77
|
+
"""
|
|
78
|
+
self.settings = settings
|
|
79
|
+
self.convergable = convergable
|
|
80
|
+
self.batch_size = batch_size
|
|
81
|
+
self.max_orientations = max_orientations
|
|
82
|
+
self.min_batches = min_batches
|
|
83
|
+
|
|
84
|
+
# Validate inputs
|
|
85
|
+
if batch_size <= 0:
|
|
86
|
+
raise ValueError(f"batch_size must be positive, got {batch_size}")
|
|
87
|
+
|
|
88
|
+
if max_orientations <= 0:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"max_orientations must be positive, got {max_orientations}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if min_batches <= 0:
|
|
94
|
+
raise ValueError(f"min_batches must be positive, got {min_batches}")
|
|
95
|
+
|
|
96
|
+
# Initialize tracking variables
|
|
97
|
+
self.n_orientations = 0
|
|
98
|
+
self.convergence_history = []
|
|
99
|
+
|
|
100
|
+
# Batch-based statistics tracking
|
|
101
|
+
self.batch_data = [] # List of batch statistics
|
|
102
|
+
|
|
103
|
+
# PHIPS detector centers (20 detectors from 18° to 170°)
|
|
104
|
+
self.detector_centers = np.linspace(
|
|
105
|
+
self.THETA_START, self.THETA_END, self.NUM_DETECTORS
|
|
106
|
+
)
|
|
107
|
+
self.half_width = self.DETECTOR_WIDTH / 2.0
|
|
108
|
+
|
|
109
|
+
# Accumulated PHIPS DSCS for final average
|
|
110
|
+
self.phips_dscs_sum = None
|
|
111
|
+
|
|
112
|
+
def _compute_phips_dscs_from_mueller2d(self, results: goad.Results) -> np.ndarray:
|
|
113
|
+
"""
|
|
114
|
+
Compute mean DSCS at each of 20 PHIPS detectors from Custom binning results.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
results: Results from MultiProblem with Custom binning
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Array of shape (20,) with mean DSCS per detector (NaN if no bins in detector)
|
|
121
|
+
"""
|
|
122
|
+
# Get mueller_2d from Custom binning
|
|
123
|
+
mueller_2d = np.array(results.mueller) # Shape: (n_custom_bins, 16)
|
|
124
|
+
bins_2d = results.bins # List of (theta_center, phi_center) tuples
|
|
125
|
+
|
|
126
|
+
# Extract theta angles from bin centers
|
|
127
|
+
theta_angles = np.array([bin_tuple[0] for bin_tuple in bins_2d])
|
|
128
|
+
|
|
129
|
+
# Extract S11 and convert to DSCS
|
|
130
|
+
s11_values = mueller_2d[:, 0]
|
|
131
|
+
k = 2 * np.pi / self.settings.wavelength
|
|
132
|
+
dscs_conversion_factor = 1e-12 / k**2
|
|
133
|
+
dscs_values = s11_values * dscs_conversion_factor
|
|
134
|
+
|
|
135
|
+
# Compute mean DSCS for each detector
|
|
136
|
+
detector_dscs = []
|
|
137
|
+
for bin_center_theta in self.detector_centers:
|
|
138
|
+
lower_bound = bin_center_theta - self.half_width
|
|
139
|
+
upper_bound = bin_center_theta + self.half_width
|
|
140
|
+
|
|
141
|
+
# Find custom bins within this detector's angular window
|
|
142
|
+
indices = np.where(
|
|
143
|
+
(theta_angles >= lower_bound) & (theta_angles < upper_bound)
|
|
144
|
+
)[0]
|
|
145
|
+
|
|
146
|
+
if len(indices) > 0:
|
|
147
|
+
# Mean DSCS over bins in this detector window
|
|
148
|
+
mean_dscs = np.mean(dscs_values[indices])
|
|
149
|
+
detector_dscs.append(mean_dscs)
|
|
150
|
+
else:
|
|
151
|
+
# No bins in this detector window
|
|
152
|
+
detector_dscs.append(np.nan)
|
|
153
|
+
|
|
154
|
+
return np.array(detector_dscs) # Shape: (20,)
|
|
155
|
+
|
|
156
|
+
def _update_statistics(self, results: goad.Results, batch_size: int):
|
|
157
|
+
"""
|
|
158
|
+
Update statistics with new batch results.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
results: Results from a MultiProblem run
|
|
162
|
+
batch_size: Number of orientations in this batch
|
|
163
|
+
"""
|
|
164
|
+
# Compute PHIPS DSCS for this batch
|
|
165
|
+
phips_dscs = self._compute_phips_dscs_from_mueller2d(results)
|
|
166
|
+
|
|
167
|
+
# Store batch data
|
|
168
|
+
batch_info = {
|
|
169
|
+
"batch_size": batch_size,
|
|
170
|
+
"phips_dscs": phips_dscs, # Shape: (20,)
|
|
171
|
+
}
|
|
172
|
+
self.batch_data.append(batch_info)
|
|
173
|
+
|
|
174
|
+
# Accumulate for final average
|
|
175
|
+
if self.phips_dscs_sum is None:
|
|
176
|
+
self.phips_dscs_sum = phips_dscs * batch_size
|
|
177
|
+
else:
|
|
178
|
+
self.phips_dscs_sum += phips_dscs * batch_size
|
|
179
|
+
|
|
180
|
+
# Update total orientation count
|
|
181
|
+
self.n_orientations += batch_size
|
|
182
|
+
|
|
183
|
+
def _calculate_phips_mean_and_sem(self) -> Tuple[np.ndarray, np.ndarray]:
|
|
184
|
+
"""
|
|
185
|
+
Calculate mean and SEM arrays for PHIPS DSCS across detectors.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Tuple of (mean_array, sem_array) where each is shape (20,)
|
|
189
|
+
"""
|
|
190
|
+
if not self.batch_data:
|
|
191
|
+
return np.full(self.NUM_DETECTORS, np.nan), np.full(
|
|
192
|
+
self.NUM_DETECTORS, np.inf
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Extract batch values: shape (n_batches, 20)
|
|
196
|
+
batch_arrays = np.array([batch["phips_dscs"] for batch in self.batch_data])
|
|
197
|
+
batch_sizes = np.array([batch["batch_size"] for batch in self.batch_data])
|
|
198
|
+
|
|
199
|
+
if len(batch_arrays) < 2:
|
|
200
|
+
# Can't estimate variance with < 2 batches
|
|
201
|
+
mean_array = batch_arrays[0]
|
|
202
|
+
sem_array = np.full(self.NUM_DETECTORS, np.inf)
|
|
203
|
+
return mean_array, sem_array
|
|
204
|
+
|
|
205
|
+
# Calculate mean and SEM independently for each detector
|
|
206
|
+
# Use nanmean to handle NaN values (detectors with no data)
|
|
207
|
+
mean_array = np.average(
|
|
208
|
+
batch_arrays, axis=0, weights=batch_sizes
|
|
209
|
+
) # Shape: (20,)
|
|
210
|
+
|
|
211
|
+
# Variance between batches at each detector (ignoring NaNs)
|
|
212
|
+
batch_means_variance = np.nanvar(batch_arrays, axis=0, ddof=1) # Shape: (20,)
|
|
213
|
+
|
|
214
|
+
# Scale up to estimate population variance
|
|
215
|
+
avg_batch_size = np.mean(batch_sizes)
|
|
216
|
+
estimated_population_variance = batch_means_variance * avg_batch_size
|
|
217
|
+
|
|
218
|
+
# Calculate SEM for total sample
|
|
219
|
+
total_n = np.sum(batch_sizes)
|
|
220
|
+
sem_array = np.sqrt(
|
|
221
|
+
estimated_population_variance / (total_n - 1)
|
|
222
|
+
) # Shape: (20,)
|
|
223
|
+
|
|
224
|
+
return mean_array, sem_array
|
|
225
|
+
|
|
226
|
+
def _check_convergence(self) -> bool:
|
|
227
|
+
"""
|
|
228
|
+
Check if PHIPS DSCS values have converged.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if converged, False otherwise
|
|
232
|
+
"""
|
|
233
|
+
if len(self.batch_data) < self.min_batches:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
mean_dscs, sem_dscs = self._calculate_phips_mean_and_sem()
|
|
237
|
+
|
|
238
|
+
# Determine which detectors to check
|
|
239
|
+
if self.convergable.detector_indices is not None:
|
|
240
|
+
check_indices = self.convergable.detector_indices
|
|
241
|
+
else:
|
|
242
|
+
# Check all detectors that have data (not NaN)
|
|
243
|
+
check_indices = np.where(~np.isnan(mean_dscs))[0]
|
|
244
|
+
|
|
245
|
+
if len(check_indices) == 0:
|
|
246
|
+
return False # No valid detectors to check
|
|
247
|
+
|
|
248
|
+
# Extract values for detectors to check
|
|
249
|
+
mean_subset = mean_dscs[check_indices]
|
|
250
|
+
sem_subset = sem_dscs[check_indices]
|
|
251
|
+
|
|
252
|
+
# Check convergence based on tolerance type
|
|
253
|
+
if self.convergable.tolerance_type == "relative":
|
|
254
|
+
# Relative SEM
|
|
255
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
256
|
+
relative_sem = np.where(
|
|
257
|
+
mean_subset != 0, sem_subset / np.abs(mean_subset), np.inf
|
|
258
|
+
)
|
|
259
|
+
converged = np.all(relative_sem < self.convergable.tolerance)
|
|
260
|
+
else: # absolute
|
|
261
|
+
converged = np.all(sem_subset < self.convergable.tolerance)
|
|
262
|
+
|
|
263
|
+
return converged
|
|
264
|
+
|
|
265
|
+
def _print_progress(self, converged: bool):
|
|
266
|
+
"""Print convergence progress."""
|
|
267
|
+
mean_dscs, sem_dscs = self._calculate_phips_mean_and_sem()
|
|
268
|
+
|
|
269
|
+
# Determine which detectors to display
|
|
270
|
+
if self.convergable.detector_indices is not None:
|
|
271
|
+
check_indices = self.convergable.detector_indices
|
|
272
|
+
else:
|
|
273
|
+
check_indices = np.where(~np.isnan(mean_dscs))[0]
|
|
274
|
+
|
|
275
|
+
if len(check_indices) == 0:
|
|
276
|
+
print(f" PHIPS DSCS: No valid detectors")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
# Find worst-case detector (highest relative SEM)
|
|
280
|
+
mean_subset = mean_dscs[check_indices]
|
|
281
|
+
sem_subset = sem_dscs[check_indices]
|
|
282
|
+
|
|
283
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
284
|
+
relative_sem = np.where(
|
|
285
|
+
mean_subset != 0, sem_subset / np.abs(mean_subset), np.inf
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
worst_idx_in_subset = np.argmax(relative_sem)
|
|
289
|
+
worst_detector_idx = check_indices[worst_idx_in_subset]
|
|
290
|
+
worst_theta = self.detector_centers[worst_detector_idx]
|
|
291
|
+
worst_mean = mean_subset[worst_idx_in_subset]
|
|
292
|
+
worst_sem = sem_subset[worst_idx_in_subset]
|
|
293
|
+
worst_rel_sem = relative_sem[worst_idx_in_subset]
|
|
294
|
+
|
|
295
|
+
# Count converged detectors
|
|
296
|
+
if self.convergable.tolerance_type == "relative":
|
|
297
|
+
converged_mask = relative_sem < self.convergable.tolerance
|
|
298
|
+
else:
|
|
299
|
+
converged_mask = sem_subset < self.convergable.tolerance
|
|
300
|
+
|
|
301
|
+
n_converged = np.sum(converged_mask)
|
|
302
|
+
n_total = len(check_indices)
|
|
303
|
+
|
|
304
|
+
status = "✓" if converged else "..."
|
|
305
|
+
|
|
306
|
+
if self.convergable.tolerance_type == "relative":
|
|
307
|
+
current_str = f"{worst_rel_sem * 100:.2f}%"
|
|
308
|
+
target_str = f"{self.convergable.tolerance * 100:.2f}%"
|
|
309
|
+
else:
|
|
310
|
+
current_str = f"{worst_sem:.4g}"
|
|
311
|
+
target_str = f"{self.convergable.tolerance:.4g}"
|
|
312
|
+
|
|
313
|
+
print(
|
|
314
|
+
f" PHIPS DSCS: {n_converged}/{n_total} detectors converged | "
|
|
315
|
+
f"Worst θ={worst_theta:.1f}°: {worst_mean:.4g} | "
|
|
316
|
+
f"SEM: {current_str} (target: {target_str}) {status}"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def run(self) -> ConvergenceResults:
|
|
320
|
+
"""
|
|
321
|
+
Run convergence study until criteria are met or max orientations reached.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
ConvergenceResults with PHIPS DSCS values
|
|
325
|
+
"""
|
|
326
|
+
print(f"\nStarting PHIPS convergence study:")
|
|
327
|
+
print(f" Batch size: {self.batch_size}")
|
|
328
|
+
print(f" Max orientations: {self.max_orientations}")
|
|
329
|
+
print(
|
|
330
|
+
f" Tolerance: {self.convergable.tolerance * 100:.1f}% ({self.convergable.tolerance_type})"
|
|
331
|
+
)
|
|
332
|
+
print(f" Min batches: {self.min_batches}")
|
|
333
|
+
|
|
334
|
+
converged = False
|
|
335
|
+
|
|
336
|
+
while not converged and self.n_orientations < self.max_orientations:
|
|
337
|
+
# Create orientations for this batch
|
|
338
|
+
orientations = goad.create_uniform_orientation(self.batch_size)
|
|
339
|
+
self.settings.orientation = orientations
|
|
340
|
+
|
|
341
|
+
# Run MultiProblem
|
|
342
|
+
mp = goad.MultiProblem(self.settings)
|
|
343
|
+
mp.py_solve()
|
|
344
|
+
|
|
345
|
+
# Update statistics
|
|
346
|
+
self._update_statistics(mp.results, self.batch_size)
|
|
347
|
+
|
|
348
|
+
# Check convergence
|
|
349
|
+
converged = self._check_convergence()
|
|
350
|
+
|
|
351
|
+
# Print progress
|
|
352
|
+
min_required = self.min_batches * self.batch_size
|
|
353
|
+
if self.n_orientations < min_required:
|
|
354
|
+
print(
|
|
355
|
+
f"\nBatch {len(self.batch_data)} ({self.n_orientations}/{min_required} total orientations, min not reached):"
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
print(
|
|
359
|
+
f"\nBatch {len(self.batch_data)} ({self.n_orientations} total orientations, min {min_required} reached):"
|
|
360
|
+
)
|
|
361
|
+
self._print_progress(converged)
|
|
362
|
+
|
|
363
|
+
# Store history
|
|
364
|
+
mean_dscs, sem_dscs = self._calculate_phips_mean_and_sem()
|
|
365
|
+
# Use worst-case SEM for history
|
|
366
|
+
valid_mask = ~np.isnan(mean_dscs)
|
|
367
|
+
if np.any(valid_mask):
|
|
368
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
369
|
+
relative_sem = sem_dscs[valid_mask] / np.abs(mean_dscs[valid_mask])
|
|
370
|
+
worst_sem = np.max(relative_sem)
|
|
371
|
+
self.convergence_history.append(
|
|
372
|
+
(self.n_orientations, "phips_dscs", worst_sem)
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Compute final results
|
|
376
|
+
mean_dscs, sem_dscs = self._calculate_phips_mean_and_sem()
|
|
377
|
+
|
|
378
|
+
# Create results
|
|
379
|
+
results = ConvergenceResults(
|
|
380
|
+
converged=converged,
|
|
381
|
+
n_orientations=self.n_orientations,
|
|
382
|
+
values={"phips_dscs": mean_dscs}, # Array of 20 values
|
|
383
|
+
sem_values={"phips_dscs": sem_dscs}, # Array of 20 SEMs
|
|
384
|
+
mueller_1d=None,
|
|
385
|
+
mueller_2d=None,
|
|
386
|
+
convergence_history=self.convergence_history,
|
|
387
|
+
warning=None
|
|
388
|
+
if converged
|
|
389
|
+
else f"Did not converge within {self.max_orientations} orientations",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Print summary
|
|
393
|
+
print(f"\n{'=' * 60}")
|
|
394
|
+
if converged:
|
|
395
|
+
print(f"✓ Converged after {self.n_orientations} orientations")
|
|
396
|
+
else:
|
|
397
|
+
print(f"✗ Did not converge (reached {self.n_orientations} orientations)")
|
|
398
|
+
print(f"{'=' * 60}")
|
|
399
|
+
|
|
400
|
+
# Print detector summary
|
|
401
|
+
print(f"\nPHIPS Detector DSCS Summary:")
|
|
402
|
+
valid_mask = ~np.isnan(mean_dscs)
|
|
403
|
+
for i in range(self.NUM_DETECTORS):
|
|
404
|
+
theta = self.detector_centers[i]
|
|
405
|
+
if valid_mask[i]:
|
|
406
|
+
mean_val = mean_dscs[i]
|
|
407
|
+
sem_val = sem_dscs[i]
|
|
408
|
+
rel_sem = sem_val / abs(mean_val) * 100
|
|
409
|
+
print(
|
|
410
|
+
f" Detector {i:2d} (θ={theta:6.1f}°): {mean_val:.6e} ± {sem_val:.6e} ({rel_sem:.2f}%)"
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
413
|
+
print(f" Detector {i:2d} (θ={theta:6.1f}°): No data")
|
|
414
|
+
|
|
415
|
+
return results
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class PHIPSEnsembleConvergence(PHIPSConvergence):
|
|
419
|
+
"""
|
|
420
|
+
Ensemble convergence study for PHIPS detector DSCS values.
|
|
421
|
+
|
|
422
|
+
Combines PHIPS detector DSCS tracking with ensemble geometry averaging.
|
|
423
|
+
Each batch randomly selects a geometry file and runs orientation averaging,
|
|
424
|
+
allowing convergence of DSCS values averaged over both orientations and geometries.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(
|
|
428
|
+
self,
|
|
429
|
+
settings: goad.Settings,
|
|
430
|
+
convergable: PHIPSConvergable,
|
|
431
|
+
geom_dir: str,
|
|
432
|
+
batch_size: int = 24,
|
|
433
|
+
max_orientations: int = 100_000,
|
|
434
|
+
min_batches: int = 10,
|
|
435
|
+
):
|
|
436
|
+
"""
|
|
437
|
+
Initialize a PHIPS ensemble convergence study.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
settings: GOAD settings with Custom binning (geom_path will be overridden)
|
|
441
|
+
convergable: PHIPS convergence criteria
|
|
442
|
+
geom_dir: Directory containing .obj geometry files
|
|
443
|
+
batch_size: Number of orientations per iteration
|
|
444
|
+
max_orientations: Maximum total orientations before stopping
|
|
445
|
+
min_batches: Minimum number of batches before allowing convergence
|
|
446
|
+
"""
|
|
447
|
+
# Discover all .obj files in directory
|
|
448
|
+
geom_path = Path(geom_dir)
|
|
449
|
+
if not geom_path.exists():
|
|
450
|
+
raise ValueError(f"Geometry directory does not exist: {geom_dir}")
|
|
451
|
+
|
|
452
|
+
if not geom_path.is_dir():
|
|
453
|
+
raise ValueError(f"Path is not a directory: {geom_dir}")
|
|
454
|
+
|
|
455
|
+
self.geom_files = sorted([f.name for f in geom_path.glob("*.obj")])
|
|
456
|
+
|
|
457
|
+
if not self.geom_files:
|
|
458
|
+
raise ValueError(f"No .obj files found in directory: {geom_dir}")
|
|
459
|
+
|
|
460
|
+
self.geom_dir = str(geom_path.resolve())
|
|
461
|
+
|
|
462
|
+
print(f"Found {len(self.geom_files)} geometry files in {self.geom_dir}")
|
|
463
|
+
|
|
464
|
+
# Call parent constructor
|
|
465
|
+
super().__init__(
|
|
466
|
+
settings=settings,
|
|
467
|
+
convergable=convergable,
|
|
468
|
+
batch_size=batch_size,
|
|
469
|
+
max_orientations=max_orientations,
|
|
470
|
+
min_batches=min_batches,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def run(self) -> ConvergenceResults:
|
|
474
|
+
"""
|
|
475
|
+
Run ensemble convergence study.
|
|
476
|
+
|
|
477
|
+
Each batch iteration randomly selects a geometry file from the
|
|
478
|
+
ensemble directory before running the orientation averaging.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
ConvergenceResults with ensemble-averaged PHIPS DSCS values
|
|
482
|
+
"""
|
|
483
|
+
print(f"\nStarting PHIPS Ensemble convergence study:")
|
|
484
|
+
print(f" Geometry files: {len(self.geom_files)}")
|
|
485
|
+
print(f" Batch size: {self.batch_size}")
|
|
486
|
+
print(f" Max orientations: {self.max_orientations}")
|
|
487
|
+
print(
|
|
488
|
+
f" Tolerance: {self.convergable.tolerance * 100:.1f}% ({self.convergable.tolerance_type})"
|
|
489
|
+
)
|
|
490
|
+
print(f" Min batches: {self.min_batches}")
|
|
491
|
+
|
|
492
|
+
converged = False
|
|
493
|
+
|
|
494
|
+
while not converged and self.n_orientations < self.max_orientations:
|
|
495
|
+
# Randomly select a geometry file for this batch
|
|
496
|
+
geom_file = random.choice(self.geom_files)
|
|
497
|
+
geom_path = os.path.join(self.geom_dir, geom_file)
|
|
498
|
+
|
|
499
|
+
# Create orientations for this batch
|
|
500
|
+
orientations = goad.create_uniform_orientation(self.batch_size)
|
|
501
|
+
|
|
502
|
+
# Update settings with selected geometry and orientations
|
|
503
|
+
self.settings.geom_path = geom_path
|
|
504
|
+
self.settings.orientation = orientations
|
|
505
|
+
|
|
506
|
+
# Run MultiProblem
|
|
507
|
+
mp = goad.MultiProblem(self.settings)
|
|
508
|
+
mp.py_solve()
|
|
509
|
+
|
|
510
|
+
# Update statistics
|
|
511
|
+
self._update_statistics(mp.results, self.batch_size)
|
|
512
|
+
|
|
513
|
+
# Check convergence
|
|
514
|
+
converged = self._check_convergence()
|
|
515
|
+
|
|
516
|
+
# Print progress (with geometry info)
|
|
517
|
+
min_required = self.min_batches * self.batch_size
|
|
518
|
+
if self.n_orientations < min_required:
|
|
519
|
+
print(
|
|
520
|
+
f"\nBatch {len(self.batch_data)} ({self.n_orientations}/{min_required} total orientations, min not reached) - Geometry: {geom_file}"
|
|
521
|
+
)
|
|
522
|
+
else:
|
|
523
|
+
print(
|
|
524
|
+
f"\nBatch {len(self.batch_data)} ({self.n_orientations} total orientations, min {min_required} reached) - Geometry: {geom_file}"
|
|
525
|
+
)
|
|
526
|
+
self._print_progress(converged)
|
|
527
|
+
|
|
528
|
+
# Store history
|
|
529
|
+
mean_dscs, sem_dscs = self._calculate_phips_mean_and_sem()
|
|
530
|
+
valid_mask = ~np.isnan(mean_dscs)
|
|
531
|
+
if np.any(valid_mask):
|
|
532
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
533
|
+
relative_sem = sem_dscs[valid_mask] / np.abs(mean_dscs[valid_mask])
|
|
534
|
+
worst_sem = np.max(relative_sem)
|
|
535
|
+
self.convergence_history.append(
|
|
536
|
+
(self.n_orientations, "phips_dscs", worst_sem)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Compute final results
|
|
540
|
+
mean_dscs, sem_dscs = self._calculate_phips_mean_and_sem()
|
|
541
|
+
|
|
542
|
+
# Create results
|
|
543
|
+
results = ConvergenceResults(
|
|
544
|
+
converged=converged,
|
|
545
|
+
n_orientations=self.n_orientations,
|
|
546
|
+
values={"phips_dscs": mean_dscs},
|
|
547
|
+
sem_values={"phips_dscs": sem_dscs},
|
|
548
|
+
mueller_1d=None,
|
|
549
|
+
mueller_2d=None,
|
|
550
|
+
convergence_history=self.convergence_history,
|
|
551
|
+
warning=None
|
|
552
|
+
if converged
|
|
553
|
+
else f"Did not converge within {self.max_orientations} orientations",
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# Print summary
|
|
557
|
+
print(f"\n{'=' * 60}")
|
|
558
|
+
if converged:
|
|
559
|
+
print(f"✓ Ensemble Converged after {self.n_orientations} orientations")
|
|
560
|
+
else:
|
|
561
|
+
print(f"✗ Did not converge (reached {self.n_orientations} orientations)")
|
|
562
|
+
print(f" Geometries sampled: {len(self.geom_files)}")
|
|
563
|
+
print(f"{'=' * 60}")
|
|
564
|
+
|
|
565
|
+
# Print detector summary
|
|
566
|
+
print(f"\nPHIPS Detector DSCS Summary (Ensemble-Averaged):")
|
|
567
|
+
valid_mask = ~np.isnan(mean_dscs)
|
|
568
|
+
for i in range(self.NUM_DETECTORS):
|
|
569
|
+
theta = self.detector_centers[i]
|
|
570
|
+
if valid_mask[i]:
|
|
571
|
+
mean_val = mean_dscs[i]
|
|
572
|
+
sem_val = sem_dscs[i]
|
|
573
|
+
rel_sem = sem_val / abs(mean_val) * 100
|
|
574
|
+
print(
|
|
575
|
+
f" Detector {i:2d} (θ={theta:6.1f}°): {mean_val:.6e} ± {sem_val:.6e} ({rel_sem:.2f}%)"
|
|
576
|
+
)
|
|
577
|
+
else:
|
|
578
|
+
print(f" Detector {i:2d} (θ={theta:6.1f}°): No data")
|
|
579
|
+
|
|
580
|
+
return results
|