goad-py 0.5.1__cp38-abi3-musllinux_1_2_i686.whl → 0.5.5__cp38-abi3-musllinux_1_2_i686.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.

@@ -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