goad-py 0.7.0__cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.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/convergence.py ADDED
@@ -0,0 +1,830 @@
1
+ import contextlib
2
+ import os
3
+ import random
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional, Tuple
8
+
9
+ import numpy as np
10
+ from rich.console import Console
11
+
12
+ from . import _goad_py as goad
13
+ from .convergence_display import (
14
+ ArrayConvergenceVariable,
15
+ ConvergenceDisplay,
16
+ ConvergenceVariable,
17
+ )
18
+
19
+
20
+ @dataclass
21
+ class Convergable:
22
+ """Represents a variable to monitor for convergence."""
23
+
24
+ variable: str # 'asymmetry', 'scatt', 'ext', 'albedo', or Mueller element like 'S11', 'S12', etc.
25
+ tolerance_type: str = "relative" # 'relative' or 'absolute'
26
+ tolerance: float = 0.01
27
+ theta_indices: Optional[List[int]] = (
28
+ None # For Mueller elements: specific theta bin indices to check (None = all bins)
29
+ )
30
+
31
+ def __post_init__(self):
32
+ # Scalar integrated parameters
33
+ valid_scalars = {"asymmetry", "scatt", "ext", "albedo"}
34
+ # Mueller matrix elements (S11, S12, ..., S44)
35
+ valid_mueller = {f"S{i}{j}" for i in range(1, 5) for j in range(1, 5)}
36
+ valid_variables = valid_scalars | valid_mueller
37
+
38
+ if self.variable not in valid_variables:
39
+ raise ValueError(
40
+ f"Invalid variable '{self.variable}'. Must be one of {valid_scalars} or Mueller element (S11-S44)"
41
+ )
42
+
43
+ valid_types = {"relative", "absolute"}
44
+ if self.tolerance_type not in valid_types:
45
+ raise ValueError(
46
+ f"Invalid tolerance_type '{self.tolerance_type}'. Must be one of {valid_types}"
47
+ )
48
+
49
+ if self.tolerance <= 0:
50
+ raise ValueError(f"Tolerance must be positive, got {self.tolerance}")
51
+
52
+ # Validate theta_indices only for Mueller elements
53
+ if self.theta_indices is not None:
54
+ if not self.is_mueller():
55
+ raise ValueError("theta_indices can only be used with Mueller elements")
56
+ # Convert range to list if needed
57
+ if isinstance(self.theta_indices, range):
58
+ self.theta_indices = list(self.theta_indices)
59
+ if not isinstance(self.theta_indices, list):
60
+ raise ValueError("theta_indices must be a list or range of integers")
61
+
62
+ def is_mueller(self) -> bool:
63
+ """Check if this convergable is a Mueller matrix element."""
64
+ return self.variable.startswith("S") and len(self.variable) == 3
65
+
66
+
67
+ @dataclass
68
+ class ConvergenceResults:
69
+ """Results from a convergence study."""
70
+
71
+ converged: bool
72
+ n_orientations: int
73
+ values: Dict[str, float] # Final mean values for each tracked variable
74
+ sem_values: Dict[str, float] # Final SEM values for each tracked variable
75
+ mueller_1d: Optional[np.ndarray] = None
76
+ mueller_2d: Optional[np.ndarray] = None
77
+ convergence_history: List[Tuple[int, str, float]] = (
78
+ None # (n_orientations, variable, sem)
79
+ )
80
+ warning: Optional[str] = None
81
+
82
+
83
+ class Convergence:
84
+ """Runs multiple MultiProblems until convergence criteria are met."""
85
+
86
+ def __init__(
87
+ self,
88
+ settings: goad.Settings,
89
+ convergables: List[Convergable],
90
+ batch_size: int = 24,
91
+ max_orientations: int = 100_000,
92
+ min_batches: int = 10,
93
+ mueller_1d: bool = True,
94
+ mueller_2d: bool = False,
95
+ ):
96
+ """
97
+ Initialize a convergence study.
98
+
99
+ Args:
100
+ settings: GOAD settings for the simulation
101
+ convergables: List of variables to monitor for convergence
102
+ batch_size: Number of orientations per iteration
103
+ max_orientations: Maximum total orientations before stopping
104
+ min_batches: Minimum number of batches before allowing convergence
105
+ mueller_1d: Whether to collect 1D Mueller matrices
106
+ mueller_2d: Whether to collect 2D Mueller matrices
107
+ """
108
+ self.settings = settings
109
+ # Enable quiet mode to suppress Rust progress bars
110
+ self.settings.quiet = True
111
+ self.convergables = convergables
112
+ self.batch_size = batch_size
113
+ self.max_orientations = max_orientations
114
+ self.min_batches = min_batches
115
+ self.mueller_1d = mueller_1d
116
+ self.mueller_2d = mueller_2d
117
+
118
+ # Validate inputs
119
+ if not convergables:
120
+ raise ValueError("Must specify at least one convergable")
121
+
122
+ if batch_size <= 0:
123
+ raise ValueError(f"batch_size must be positive, got {batch_size}")
124
+
125
+ if max_orientations <= 0:
126
+ raise ValueError(
127
+ f"max_orientations must be positive, got {max_orientations}"
128
+ )
129
+
130
+ if min_batches <= 0:
131
+ raise ValueError(f"min_batches must be positive, got {min_batches}")
132
+
133
+ # Initialize tracking variables
134
+ self.n_orientations = 0
135
+ self.convergence_history = []
136
+
137
+ # Batch-based statistics tracking for rigorous SEM calculation
138
+ self.batch_data = [] # List of batch statistics
139
+
140
+ # Mueller matrix accumulation
141
+ self.mueller_1d_sum = None
142
+ self.mueller_2d_sum = None
143
+
144
+ # Rich console
145
+ self._console = Console()
146
+
147
+ # Create display variables for the new display system
148
+ display_variables = []
149
+ for conv in self.convergables:
150
+ if conv.is_mueller():
151
+ display_variables.append(
152
+ ArrayConvergenceVariable(
153
+ name=conv.variable,
154
+ tolerance=conv.tolerance,
155
+ tolerance_type=conv.tolerance_type,
156
+ indices=conv.theta_indices,
157
+ )
158
+ )
159
+ else:
160
+ display_variables.append(
161
+ ConvergenceVariable(
162
+ name=conv.variable,
163
+ tolerance=conv.tolerance,
164
+ tolerance_type=conv.tolerance_type,
165
+ )
166
+ )
167
+
168
+ # Initialize display system
169
+ self._display = ConvergenceDisplay(
170
+ variables=display_variables,
171
+ batch_size=self.batch_size,
172
+ min_batches=self.min_batches,
173
+ convergence_type=self._get_convergence_type(),
174
+ console=self._console,
175
+ )
176
+
177
+ def _update_statistics(self, results: goad.Results, batch_size: int):
178
+ """Update statistics with new batch results.
179
+
180
+ Args:
181
+ results: Results from a MultiProblem run (pre-averaged over batch_size orientations)
182
+ batch_size: Number of orientations in this batch
183
+ """
184
+ # Check for None values indicating Custom binning
185
+ if (
186
+ results.asymmetry is None
187
+ or results.scat_cross is None
188
+ or results.ext_cross is None
189
+ or results.albedo is None
190
+ ):
191
+ raise ValueError(
192
+ "Received None values for integrated properties. "
193
+ "This likely means Custom binning scheme is being used. "
194
+ "Convergence requires Simple or Interval binning schemes."
195
+ )
196
+
197
+ # Store batch data for proper statistical analysis
198
+ batch_info = {"batch_size": batch_size, "values": {}, "weights": {}}
199
+
200
+ # Always store all 4 integrated parameters (for unified API output)
201
+ batch_info["values"]["asymmetry"] = results.asymmetry
202
+ batch_info["weights"]["asymmetry"] = results.scat_cross
203
+ batch_info["values"]["scatt"] = results.scat_cross
204
+ batch_info["weights"]["scatt"] = 1.0 # Equal weighting
205
+ batch_info["values"]["ext"] = results.ext_cross
206
+ batch_info["weights"]["ext"] = 1.0 # Equal weighting
207
+ batch_info["values"]["albedo"] = results.albedo
208
+ batch_info["weights"]["albedo"] = results.ext_cross + results.scat_cross
209
+
210
+ # Always store ALL 16 Mueller elements (for unified API output with full SEM)
211
+ if self.mueller_1d and results.mueller_1d is not None:
212
+ mueller_1d_array = np.array(results.mueller_1d) # Shape: (n_theta, 16)
213
+
214
+ # Store all 16 Mueller elements (S11, S12, ..., S44)
215
+ for row in range(1, 5):
216
+ for col in range(1, 5):
217
+ element_name = f"S{row}{col}"
218
+ mueller_idx = (row - 1) * 4 + (col - 1)
219
+ mueller_element = mueller_1d_array[
220
+ :, mueller_idx
221
+ ] # Shape: (n_theta,)
222
+
223
+ batch_info["values"][element_name] = mueller_element
224
+ batch_info["weights"][element_name] = 1.0 # Equal weighting
225
+
226
+ # Store theta bins if not already stored (for display purposes)
227
+ if "mueller_theta_bins" not in batch_info and results.bins_1d is not None:
228
+ batch_info["mueller_theta_bins"] = np.array(results.bins_1d)
229
+
230
+ self.batch_data.append(batch_info)
231
+
232
+ # Update Mueller matrices if enabled
233
+ if self.mueller_1d and results.mueller_1d is not None:
234
+ mueller_1d_array = np.array(results.mueller_1d)
235
+ if self.mueller_1d_sum is None:
236
+ self.mueller_1d_sum = mueller_1d_array * batch_size
237
+ else:
238
+ self.mueller_1d_sum += mueller_1d_array * batch_size
239
+
240
+ if self.mueller_2d and results.mueller is not None:
241
+ mueller_2d_array = np.array(results.mueller)
242
+ if self.mueller_2d_sum is None:
243
+ self.mueller_2d_sum = mueller_2d_array * batch_size
244
+ else:
245
+ self.mueller_2d_sum += mueller_2d_array * batch_size
246
+
247
+ # Update total orientation count
248
+ self.n_orientations += batch_size
249
+
250
+ def _calculate_mean_and_sem_array(
251
+ self, variable: str
252
+ ) -> Tuple[np.ndarray, np.ndarray]:
253
+ """Calculate mean and SEM arrays for Mueller matrix elements across theta bins.
254
+
255
+ Args:
256
+ variable: Mueller element name (e.g., 'S11')
257
+
258
+ Returns:
259
+ Tuple of (mean_array, sem_array) where each is shape (n_theta,)
260
+ """
261
+ if not self.batch_data:
262
+ return np.array([]), np.array([])
263
+
264
+ # Extract batch values (each is an array of theta values)
265
+ batch_arrays = []
266
+ batch_sizes = []
267
+
268
+ for batch in self.batch_data:
269
+ if variable in batch["values"]:
270
+ batch_arrays.append(batch["values"][variable]) # Shape: (n_theta,)
271
+ batch_sizes.append(batch["batch_size"])
272
+
273
+ if not batch_arrays:
274
+ return np.array([]), np.array([])
275
+
276
+ # Stack batches: shape (n_batches, n_theta)
277
+ batch_arrays = np.array(batch_arrays)
278
+ batch_sizes = np.array(batch_sizes)
279
+ n_theta = batch_arrays.shape[1]
280
+
281
+ if len(batch_arrays) < 2:
282
+ # Can't estimate variance with < 2 batches
283
+ mean_array = batch_arrays[0]
284
+ sem_array = np.full(n_theta, float("inf"))
285
+ return mean_array, sem_array
286
+
287
+ # Calculate mean and SEM independently for each theta bin
288
+ # Mean: weighted by batch size
289
+ mean_array = np.average(
290
+ batch_arrays, axis=0, weights=batch_sizes
291
+ ) # Shape: (n_theta,)
292
+
293
+ # Variance between batches at each theta
294
+ batch_means_variance = np.var(batch_arrays, axis=0, ddof=1) # Shape: (n_theta,)
295
+
296
+ # Scale up to estimate population variance
297
+ avg_batch_size = np.mean(batch_sizes)
298
+ estimated_population_variance = batch_means_variance * avg_batch_size
299
+
300
+ # Calculate SEM for total sample
301
+ total_n = np.sum(batch_sizes)
302
+ sem_array = np.sqrt(
303
+ estimated_population_variance / (total_n - 1)
304
+ ) # Shape: (n_theta,)
305
+
306
+ return mean_array, sem_array
307
+
308
+ def _calculate_mean_and_sem(self, variable: str) -> Tuple[float, float]:
309
+ """Calculate mean and standard error of the mean for a variable using batch data.
310
+
311
+ Args:
312
+ variable: Variable name
313
+
314
+ Returns:
315
+ Tuple of (mean, sem)
316
+ """
317
+ if not self.batch_data:
318
+ return 0.0, float("inf")
319
+
320
+ # Extract batch values and weights
321
+ batch_values = []
322
+ batch_weights = []
323
+ batch_sizes = []
324
+
325
+ for batch in self.batch_data:
326
+ if variable in batch["values"]:
327
+ batch_values.append(batch["values"][variable])
328
+ batch_weights.append(batch["weights"][variable])
329
+ batch_sizes.append(batch["batch_size"])
330
+
331
+ if not batch_values:
332
+ return 0.0, float("inf")
333
+
334
+ batch_values = np.array(batch_values)
335
+ batch_weights = np.array(batch_weights)
336
+ batch_sizes = np.array(batch_sizes)
337
+
338
+ # For weighted variables (asymmetry, albedo), use weighted statistics
339
+ if variable in ["asymmetry", "albedo"]:
340
+ # Calculate weighted mean across batches
341
+ # Each batch contributes: weight * batch_size * value
342
+ total_weighted_sum = np.sum(batch_weights * batch_sizes * batch_values)
343
+ total_weight = np.sum(batch_weights * batch_sizes)
344
+ weighted_mean = total_weighted_sum / total_weight
345
+
346
+ # Calculate weighted variance between batches
347
+ if len(batch_values) < 2:
348
+ return weighted_mean, float(
349
+ "inf"
350
+ ) # Cannot estimate variance with < 2 batches
351
+
352
+ # For batch means, we need to account for the effective weight of each batch
353
+ effective_weights = batch_weights * batch_sizes
354
+ weighted_variance_batch_means = np.sum(
355
+ effective_weights * (batch_values - weighted_mean) ** 2
356
+ ) / np.sum(effective_weights)
357
+
358
+ # Scale up to estimate population variance
359
+ # Batch means have variance = population_variance / average_batch_size
360
+ # So population_variance ≈ batch_means_variance * average_batch_size
361
+ avg_batch_size = np.average(batch_sizes, weights=effective_weights)
362
+ estimated_population_variance = (
363
+ weighted_variance_batch_means * avg_batch_size
364
+ )
365
+
366
+ # Calculate SEM for the total sample (using n-1 for sample standard error)
367
+ total_n = np.sum(batch_sizes)
368
+ sem = np.sqrt(estimated_population_variance / (total_n - 1))
369
+
370
+ return weighted_mean, sem
371
+
372
+ else:
373
+ # For unweighted variables (scatt, ext), use simple batch statistics
374
+ # Calculate mean of batch means, weighted by batch size
375
+ total_sum = np.sum(batch_sizes * batch_values)
376
+ total_n = np.sum(batch_sizes)
377
+ mean = total_sum / total_n
378
+
379
+ # Calculate variance between batch means
380
+ if len(batch_values) < 2:
381
+ return mean, float("inf")
382
+
383
+ batch_means_variance = np.var(batch_values, ddof=1)
384
+
385
+ # Scale up to estimate population variance
386
+ # Batch means have variance = population_variance / average_batch_size
387
+ # So population_variance ≈ batch_means_variance * average_batch_size
388
+ avg_batch_size = np.mean(batch_sizes)
389
+ estimated_population_variance = batch_means_variance * avg_batch_size
390
+
391
+ # Calculate SEM for the total sample (using n-1 for sample standard error)
392
+ sem = np.sqrt(estimated_population_variance / (total_n - 1))
393
+
394
+ return mean, sem
395
+
396
+ def _check_convergence(self) -> Dict[str, bool]:
397
+ """Check if all convergence criteria are met.
398
+
399
+ Returns:
400
+ Dict mapping variable names to convergence status
401
+ """
402
+ converged = {}
403
+
404
+ for conv in self.convergables:
405
+ if conv.is_mueller():
406
+ # Mueller element - check theta bins (all or specific indices)
407
+ mean_array, sem_array = self._calculate_mean_and_sem_array(
408
+ conv.variable
409
+ )
410
+
411
+ if len(mean_array) == 0:
412
+ converged[conv.variable] = False
413
+ continue
414
+
415
+ # Select theta bins to check
416
+ if conv.theta_indices is not None:
417
+ # Check only specified indices
418
+ indices = [i for i in conv.theta_indices if i < len(mean_array)]
419
+ if not indices:
420
+ converged[conv.variable] = False
421
+ continue
422
+ mean_subset = mean_array[indices]
423
+ sem_subset = sem_array[indices]
424
+ else:
425
+ # Check all bins
426
+ mean_subset = mean_array
427
+ sem_subset = sem_array
428
+
429
+ # Check convergence at selected theta bins
430
+ if conv.tolerance_type == "relative":
431
+ # Relative tolerance: SEM / |mean| < tolerance
432
+ relative_sem = np.where(
433
+ mean_subset != 0,
434
+ sem_subset / np.abs(mean_subset),
435
+ sem_subset / conv.tolerance,
436
+ )
437
+ converged[conv.variable] = np.all(relative_sem < conv.tolerance)
438
+ else:
439
+ # Absolute tolerance: SEM < tolerance
440
+ converged[conv.variable] = np.all(sem_subset < conv.tolerance)
441
+ else:
442
+ # Scalar variable
443
+ mean, sem = self._calculate_mean_and_sem(conv.variable)
444
+
445
+ # Calculate tolerance based on type
446
+ if conv.tolerance_type == "relative":
447
+ # Relative tolerance: SEM / |mean| < tolerance
448
+ if mean != 0:
449
+ relative_sem = sem / abs(mean)
450
+ converged[conv.variable] = relative_sem < conv.tolerance
451
+ else:
452
+ # If mean is zero, use absolute comparison
453
+ converged[conv.variable] = sem < conv.tolerance
454
+ else:
455
+ # Absolute tolerance: SEM < tolerance
456
+ converged[conv.variable] = sem < conv.tolerance
457
+
458
+ return converged
459
+
460
+ def _all_converged(self) -> bool:
461
+ """Check if all variables have converged.
462
+
463
+ Returns:
464
+ True if all variables meet their convergence criteria and minimum batches completed
465
+ """
466
+ # Check minimum batches requirement first
467
+ if len(self.batch_data) < self.min_batches:
468
+ return False
469
+
470
+ converged_status = self._check_convergence()
471
+ return all(converged_status.values())
472
+
473
+ def _get_convergence_type(self) -> str:
474
+ """Get the convergence type name for display."""
475
+ class_name = self.__class__.__name__
476
+ if class_name == "EnsembleConvergence":
477
+ return "Ensemble"
478
+ elif class_name == "Convergence":
479
+ return "Standard"
480
+ else:
481
+ return class_name
482
+
483
+ def _get_next_geometry(self, iteration: int) -> Tuple[str, Optional[str]]:
484
+ """Hook method to get geometry for next batch.
485
+
486
+ Args:
487
+ iteration: Current iteration number
488
+
489
+ Returns:
490
+ Tuple of (geom_path, optional_display_info)
491
+ - geom_path: Path to geometry file to use
492
+ - optional_display_info: Optional string to display (e.g., "Geometry: hex.obj")
493
+ """
494
+ # Default implementation: use fixed geometry from settings
495
+ return self.settings.geom_path, None
496
+
497
+ def _handle_geometry_error(self, error: Exception, geom_path: str) -> bool:
498
+ """Hook method to handle geometry loading errors.
499
+
500
+ Args:
501
+ error: The exception that occurred
502
+ geom_path: Path to the geometry that failed
503
+
504
+ Returns:
505
+ True to skip this geometry and continue, False to raise the error
506
+ """
507
+ # Default implementation: re-raise error (fail fast for single geometry)
508
+ return False
509
+
510
+ def _get_theta_bins(self, variable: str) -> Optional[np.ndarray]:
511
+ """Get theta bins for Mueller elements from batch data."""
512
+ for batch in self.batch_data:
513
+ if "mueller_theta_bins" in batch:
514
+ return batch["mueller_theta_bins"]
515
+
516
+ # Fallback: infer from array length
517
+ mean_array, _ = self._calculate_mean_and_sem_array(variable)
518
+ if len(mean_array) > 0:
519
+ return np.linspace(0, 180, len(mean_array))
520
+
521
+ return None
522
+
523
+ def _update_convergence_history(self):
524
+ """Update convergence history with current SEM values."""
525
+ for conv in self.convergables:
526
+ if conv.is_mueller():
527
+ # Mueller element - track worst SEM
528
+ mean_array, sem_array = self._calculate_mean_and_sem_array(
529
+ conv.variable
530
+ )
531
+ if len(mean_array) > 0:
532
+ if conv.tolerance_type == "relative":
533
+ relative_sem_array = np.where(
534
+ mean_array != 0,
535
+ sem_array / np.abs(mean_array),
536
+ float("inf"),
537
+ )
538
+ worst_sem = np.max(relative_sem_array)
539
+ else:
540
+ worst_sem = np.max(sem_array)
541
+
542
+ self.convergence_history.append(
543
+ (self.n_orientations, conv.variable, worst_sem)
544
+ )
545
+ else:
546
+ # Scalar variable
547
+ mean, sem = self._calculate_mean_and_sem(conv.variable)
548
+ if conv.tolerance_type == "relative" and mean != 0:
549
+ sem = sem / abs(mean)
550
+
551
+ self.convergence_history.append(
552
+ (self.n_orientations, conv.variable, sem)
553
+ )
554
+
555
+ def run(self) -> ConvergenceResults:
556
+ """Run the convergence study.
557
+
558
+ Returns:
559
+ ConvergenceResults containing final values and convergence status
560
+ """
561
+ iteration = 0
562
+ converged = False
563
+ warning = None
564
+
565
+ # Create Live context for smooth updating display
566
+ with self._display.create_live_context() as live:
567
+ # Show initial display before first batch
568
+ initial_display = self._display.build_display(
569
+ iteration=0,
570
+ n_orientations=self.n_orientations,
571
+ get_stats=self._calculate_mean_and_sem,
572
+ get_array_stats=self._calculate_mean_and_sem_array,
573
+ get_bin_labels=self._get_theta_bins,
574
+ power_ratio=None,
575
+ geom_info=None,
576
+ )
577
+ live.update(initial_display)
578
+
579
+ while not converged and self.n_orientations < self.max_orientations:
580
+ iteration += 1
581
+
582
+ # Get geometry for this batch (hook method - can be overridden)
583
+ geom_path, geom_info = self._get_next_geometry(iteration)
584
+
585
+ # Determine batch size for this iteration
586
+ remaining = self.max_orientations - self.n_orientations
587
+ batch_size = min(self.batch_size, remaining)
588
+
589
+ # Set batch size
590
+ orientations = goad.create_uniform_orientation(batch_size)
591
+
592
+ # Set the geometry path and orientations for the settings
593
+ self.settings.geom_path = geom_path
594
+ self.settings.orientation = orientations
595
+
596
+ # Run MultiProblem with error handling for bad geometries
597
+ # Suppress Rust progress bars by redirecting stderr at fd level
598
+ try:
599
+ mp = goad.MultiProblem(self.settings)
600
+ # Redirect stderr file descriptor to suppress Rust progress bars
601
+ stderr_fd = sys.stderr.fileno()
602
+ with open(os.devnull, "w") as devnull:
603
+ old_stderr_fd = os.dup(stderr_fd)
604
+ try:
605
+ os.dup2(devnull.fileno(), stderr_fd)
606
+ mp.py_solve()
607
+ finally:
608
+ os.dup2(old_stderr_fd, stderr_fd)
609
+ os.close(old_stderr_fd)
610
+ except Exception as e:
611
+ # Geometry loading failed (bad faces, degenerate geometry, etc.)
612
+ # Check if subclass wants to handle this error (e.g., skip for ensemble)
613
+ if self._handle_geometry_error(e, geom_path):
614
+ # Skip this geometry and continue
615
+ continue
616
+ else:
617
+ # For single-geometry convergence, we can't skip - must raise error
618
+ error_msg = (
619
+ f"Failed to initialize MultiProblem with geometry '{geom_path}': {e}\n"
620
+ f"Please check geometry file for:\n"
621
+ f" - Degenerate faces (area = 0)\n"
622
+ f" - Non-planar geometry\n"
623
+ f" - Faces that are too small\n"
624
+ f" - Invalid mesh topology\n"
625
+ f" - Geometry file corruption"
626
+ )
627
+ raise type(e)(error_msg) from e
628
+
629
+ # Update statistics
630
+ self._update_statistics(mp.results, batch_size)
631
+
632
+ # Extract power ratio from results
633
+ try:
634
+ powers = mp.results.powers # It's a property, not a method
635
+ power_in = powers.get("input", 1.0)
636
+ power_out = powers.get("output", 0.0)
637
+ power_ratio = power_out / power_in if power_in > 0 else 0.0
638
+ except Exception:
639
+ power_ratio = None
640
+
641
+ # Update convergence history
642
+ self._update_convergence_history()
643
+
644
+ # Update live display with optional geometry info
645
+ display = self._display.build_display(
646
+ iteration=iteration,
647
+ n_orientations=self.n_orientations,
648
+ get_stats=self._calculate_mean_and_sem,
649
+ get_array_stats=self._calculate_mean_and_sem_array,
650
+ get_bin_labels=self._get_theta_bins,
651
+ power_ratio=power_ratio,
652
+ geom_info=geom_info,
653
+ )
654
+ live.update(display)
655
+
656
+ # Check convergence
657
+ converged = self._all_converged()
658
+
659
+ # Prepare final results
660
+ if converged:
661
+ print(f"\nConverged after {self.n_orientations} orientations.")
662
+ else:
663
+ warning = f"Maximum orientations ({self.max_orientations}) reached without convergence"
664
+ print(f"\nWarning: {warning}")
665
+
666
+ # Calculate final values and SEMs
667
+ final_values = {}
668
+ final_sems = {}
669
+ for conv in self.convergables:
670
+ if conv.is_mueller():
671
+ mean_array, sem_array = self._calculate_mean_and_sem_array(
672
+ conv.variable
673
+ )
674
+ final_values[conv.variable] = mean_array
675
+ final_sems[conv.variable] = sem_array
676
+ else:
677
+ mean, sem = self._calculate_mean_and_sem(conv.variable)
678
+ final_values[conv.variable] = mean
679
+ final_sems[conv.variable] = sem
680
+
681
+ # Prepare Mueller matrices with SEM
682
+ mueller_1d = None
683
+ mueller_1d_sem = None
684
+ mueller_2d = None
685
+
686
+ if self.mueller_1d and self.mueller_1d_sum is not None:
687
+ mueller_1d = self.mueller_1d_sum / self.n_orientations
688
+
689
+ # Compute SEM for all 16 Mueller elements
690
+ # mueller_1d shape: (n_theta, 16)
691
+ n_theta = mueller_1d.shape[0]
692
+ mueller_1d_sem = np.zeros_like(mueller_1d)
693
+
694
+ for row in range(1, 5):
695
+ for col in range(1, 5):
696
+ element_name = f"S{row}{col}"
697
+ mueller_idx = (row - 1) * 4 + (col - 1)
698
+
699
+ # Calculate mean and SEM for this element across all theta bins
700
+ mean_array, sem_array = self._calculate_mean_and_sem_array(
701
+ element_name
702
+ )
703
+
704
+ if len(sem_array) > 0:
705
+ mueller_1d_sem[:, mueller_idx] = sem_array
706
+
707
+ # Store mueller_1d_sem in final_values for unified API access
708
+ final_values["mueller_1d_sem"] = mueller_1d_sem
709
+
710
+ if self.mueller_2d and self.mueller_2d_sum is not None:
711
+ mueller_2d = self.mueller_2d_sum / self.n_orientations
712
+
713
+ return ConvergenceResults(
714
+ converged=converged,
715
+ n_orientations=self.n_orientations,
716
+ values=final_values,
717
+ sem_values=final_sems,
718
+ mueller_1d=mueller_1d,
719
+ mueller_2d=mueller_2d,
720
+ convergence_history=self.convergence_history,
721
+ warning=warning,
722
+ )
723
+
724
+
725
+ class EnsembleConvergence(Convergence):
726
+ """Runs convergence study over an ensemble of particle geometries.
727
+
728
+ Each batch randomly samples from a directory of geometry files,
729
+ allowing convergence analysis of orientation-averaged and
730
+ geometry-averaged scattering properties.
731
+ """
732
+
733
+ def __init__(
734
+ self,
735
+ settings: goad.Settings,
736
+ convergables: List[Convergable],
737
+ geom_dir: str,
738
+ batch_size: int = 24,
739
+ max_orientations: int = 100_000,
740
+ min_batches: int = 10,
741
+ mueller_1d: bool = True,
742
+ mueller_2d: bool = False,
743
+ ):
744
+ """
745
+ Initialize an ensemble convergence study.
746
+
747
+ Args:
748
+ settings: GOAD settings for the simulation (geom_path will be overridden)
749
+ convergables: List of variables to monitor for convergence
750
+ geom_dir: Directory containing .obj geometry files
751
+ batch_size: Number of orientations per iteration
752
+ max_orientations: Maximum total orientations before stopping
753
+ min_batches: Minimum number of batches before allowing convergence
754
+ mueller_1d: Whether to collect 1D Mueller matrices
755
+ mueller_2d: Whether to collect 2D Mueller matrices
756
+ """
757
+ # Discover all .obj files in directory
758
+ geom_path = Path(geom_dir)
759
+ if not geom_path.exists():
760
+ raise ValueError(f"Geometry directory does not exist: {geom_dir}")
761
+
762
+ if not geom_path.is_dir():
763
+ raise ValueError(f"Path is not a directory: {geom_dir}")
764
+
765
+ self.geom_files = sorted([f.name for f in geom_path.glob("*.obj")])
766
+
767
+ if not self.geom_files:
768
+ raise ValueError(f"No .obj files found in directory: {geom_dir}")
769
+
770
+ self.geom_dir = str(geom_path.resolve())
771
+
772
+ print(f"Found {len(self.geom_files)} geometry files in {self.geom_dir}")
773
+
774
+ # Call parent constructor
775
+ super().__init__(
776
+ settings=settings,
777
+ convergables=convergables,
778
+ batch_size=batch_size,
779
+ max_orientations=max_orientations,
780
+ min_batches=min_batches,
781
+ mueller_1d=mueller_1d,
782
+ mueller_2d=mueller_2d,
783
+ )
784
+
785
+ # Track skipped geometries for error handling
786
+ self.skipped_geometries = []
787
+
788
+ def _get_next_geometry(self, iteration: int) -> Tuple[str, Optional[str]]:
789
+ """Override to randomly select geometry from ensemble.
790
+
791
+ Args:
792
+ iteration: Current iteration number
793
+
794
+ Returns:
795
+ Tuple of (geom_path, display_info)
796
+ """
797
+ # Randomly select a geometry file for this batch
798
+ geom_file = random.choice(self.geom_files)
799
+ geom_path = os.path.join(self.geom_dir, geom_file)
800
+ geom_info = f"Geom: {geom_file}"
801
+
802
+ return geom_path, geom_info
803
+
804
+ def _handle_geometry_error(self, error: Exception, geom_path: str) -> bool:
805
+ """Override to skip bad geometries in ensemble mode.
806
+
807
+ Args:
808
+ error: The exception that occurred
809
+ geom_path: Path to the geometry that failed
810
+
811
+ Returns:
812
+ True to skip this geometry and continue
813
+ """
814
+ # Extract just the filename
815
+ geom_file = os.path.basename(geom_path)
816
+
817
+ # Print warning and track skipped geometry
818
+ print(f"\nWarning: Skipping geometry '{geom_file}': {error}")
819
+ self.skipped_geometries.append(geom_file)
820
+
821
+ # Check if all geometries have been skipped
822
+ if len(self.skipped_geometries) >= len(self.geom_files):
823
+ raise ValueError(
824
+ f"All {len(self.geom_files)} geometry files failed to load. "
825
+ "Please check geometry files for degenerate faces, non-planar geometry, "
826
+ "or faces that are too small."
827
+ )
828
+
829
+ # Skip this geometry and continue
830
+ return True