goad-py 0.8.5__pp39-pypy39_pp73-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.
@@ -0,0 +1,1337 @@
1
+ """
2
+ Unified convergence API for GOAD.
3
+
4
+ Provides a single entry point for all convergence types:
5
+ - Standard convergence (integrated parameters, Mueller elements)
6
+ - PHIPS detector convergence
7
+ - Single geometry or ensemble averaging
8
+
9
+ Features:
10
+ - Auto-detection of convergence mode
11
+ - Strict input validation
12
+ - Uniform output format (UnifiedResults)
13
+ - Support for parameter sweeps
14
+ - Full control over beam tracing, geometry transformations, and advanced optics
15
+ - Reproducible results via random seed control
16
+ """
17
+
18
+ import json
19
+ from abc import ABC, abstractmethod
20
+ from dataclasses import asdict, dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Tuple, Union
23
+
24
+ import numpy as np
25
+ import toml
26
+
27
+ from . import _goad_py as goad
28
+ from .convergence import (
29
+ Convergable,
30
+ Convergence,
31
+ ConvergenceResults,
32
+ EnsembleConvergence,
33
+ )
34
+ from .phips_convergence import (
35
+ PHIPSConvergable,
36
+ PHIPSConvergence,
37
+ PHIPSEnsembleConvergence,
38
+ )
39
+
40
+ # ============================================================================
41
+ # Convergence Modes
42
+ # ============================================================================
43
+
44
+
45
+ class ConvergenceMode(ABC):
46
+ """Abstract base class for convergence modes."""
47
+
48
+ @abstractmethod
49
+ def validate_targets(self, targets: List[dict]) -> None:
50
+ """Validate that targets are appropriate for this mode."""
51
+ pass
52
+
53
+ @abstractmethod
54
+ def get_binning(self) -> goad.BinningScheme:
55
+ """Get appropriate binning scheme for this mode."""
56
+ pass
57
+
58
+ @abstractmethod
59
+ def get_mode_name(self) -> str:
60
+ """Get mode name for logging/output."""
61
+ pass
62
+
63
+
64
+ class StandardMode(ConvergenceMode):
65
+ """Standard convergence mode for integrated parameters and Mueller elements."""
66
+
67
+ # Valid targets for standard mode
68
+ VALID_SCALAR_TARGETS = {"asymmetry", "scatt", "ext", "albedo"}
69
+ VALID_MUELLER_TARGETS = {f"S{i}{j}" for i in range(1, 5) for j in range(1, 5)}
70
+
71
+ def __init__(
72
+ self,
73
+ n_theta: int = 181,
74
+ n_phi: int = 181,
75
+ binning: Optional[goad.BinningScheme] = None,
76
+ ):
77
+ """
78
+ Initialize standard mode.
79
+
80
+ Args:
81
+ n_theta: Number of theta bins for simple binning (default: 181 for 0-180 degrees)
82
+ n_phi: Number of phi bins for simple binning (default: 181)
83
+ binning: Optional custom BinningScheme (interval, custom, etc.)
84
+ If provided, n_theta and n_phi are ignored.
85
+ """
86
+ self.n_theta = n_theta
87
+ self.n_phi = n_phi
88
+ self.custom_binning = binning
89
+
90
+ def validate_targets(self, targets: List[dict]) -> None:
91
+ """Validate that all targets are valid for standard mode."""
92
+ valid_targets = self.VALID_SCALAR_TARGETS | self.VALID_MUELLER_TARGETS
93
+
94
+ for target in targets:
95
+ variable = target.get("variable")
96
+ if variable is None:
97
+ raise ValueError(f"Target missing 'variable' field: {target}")
98
+
99
+ if variable not in valid_targets:
100
+ raise ValueError(
101
+ f"Invalid target '{variable}' for StandardMode. "
102
+ f"Valid targets are: {sorted(valid_targets)}"
103
+ )
104
+
105
+ # Validate theta_indices only for Mueller elements
106
+ if "theta_indices" in target:
107
+ if variable not in self.VALID_MUELLER_TARGETS:
108
+ raise ValueError(
109
+ f"theta_indices can only be used with Mueller elements, "
110
+ f"but got variable='{variable}'"
111
+ )
112
+
113
+ def get_binning(self) -> goad.BinningScheme:
114
+ """Return binning scheme (custom if provided, otherwise simple)."""
115
+ if self.custom_binning is not None:
116
+ return self.custom_binning
117
+ return goad.BinningScheme.simple(self.n_theta, self.n_phi)
118
+
119
+ def get_mode_name(self) -> str:
120
+ return "standard"
121
+
122
+
123
+ class PHIPSMode(ConvergenceMode):
124
+ """PHIPS detector convergence mode."""
125
+
126
+ def __init__(self, bins_file: str):
127
+ """
128
+ Initialize PHIPS mode.
129
+
130
+ Args:
131
+ bins_file: Path to PHIPS bins TOML file (e.g., phips_bins_edges.toml)
132
+
133
+ Raises:
134
+ FileNotFoundError: If bins_file doesn't exist
135
+ ValueError: If bins_file is not a valid TOML file
136
+ """
137
+ self.bins_file = Path(bins_file)
138
+
139
+ if not self.bins_file.exists():
140
+ raise FileNotFoundError(
141
+ f"PHIPS bins file not found: {self.bins_file}\n"
142
+ f"Please generate it using phips_detector_angles_edges.py"
143
+ )
144
+
145
+ # Load and validate bins file
146
+ try:
147
+ with open(self.bins_file, "r") as f:
148
+ bins_data = toml.load(f)
149
+
150
+ if "bins" not in bins_data:
151
+ raise ValueError(
152
+ f"PHIPS bins file missing 'bins' key: {self.bins_file}"
153
+ )
154
+
155
+ self.custom_bins = bins_data["bins"]
156
+
157
+ if not isinstance(self.custom_bins, list) or len(self.custom_bins) == 0:
158
+ raise ValueError(
159
+ f"PHIPS bins file has invalid 'bins' data: {self.bins_file}"
160
+ )
161
+
162
+ except Exception as e:
163
+ raise ValueError(f"Failed to load PHIPS bins file {self.bins_file}: {e}")
164
+
165
+ def validate_targets(self, targets: List[dict]) -> None:
166
+ """
167
+ Validate that targets are appropriate for PHIPS mode.
168
+
169
+ PHIPS mode only supports convergence on DSCS values at detector bins.
170
+ The 'variable' field is not used (always 'phips_dscs').
171
+ """
172
+ if len(targets) != 1:
173
+ raise ValueError(
174
+ f"PHIPSMode only supports a single convergence target, got {len(targets)}. "
175
+ f"Use tolerance and tolerance_type to specify convergence criteria."
176
+ )
177
+
178
+ target = targets[0]
179
+
180
+ # PHIPS doesn't use 'variable' field - it's always DSCS
181
+ # But validate other fields
182
+ tolerance_type = target.get("tolerance_type", "relative")
183
+ if tolerance_type not in {"relative", "absolute"}:
184
+ raise ValueError(
185
+ f"Invalid tolerance_type '{tolerance_type}'. Must be 'relative' or 'absolute'."
186
+ )
187
+
188
+ tolerance = target.get("tolerance")
189
+ if tolerance is None:
190
+ raise ValueError("Target missing 'tolerance' field")
191
+
192
+ if tolerance <= 0:
193
+ raise ValueError(f"tolerance must be positive, got {tolerance}")
194
+
195
+ # Validate detector_indices if present
196
+ if "detector_indices" in target:
197
+ detector_indices = target["detector_indices"]
198
+ if not isinstance(detector_indices, list):
199
+ raise ValueError("detector_indices must be a list of integers")
200
+ if not all(isinstance(i, int) and 0 <= i < 20 for i in detector_indices):
201
+ raise ValueError("detector_indices must be integers in range [0, 19]")
202
+
203
+ def get_binning(self) -> goad.BinningScheme:
204
+ """Return custom binning scheme from PHIPS bins file."""
205
+ return goad.BinningScheme.custom(self.custom_bins)
206
+
207
+ def get_mode_name(self) -> str:
208
+ return "phips"
209
+
210
+
211
+ # ============================================================================
212
+ # Configuration
213
+ # ============================================================================
214
+
215
+
216
+ @dataclass
217
+ class BeamTracingConfig:
218
+ """Beam tracing performance and accuracy parameters."""
219
+
220
+ beam_power_threshold: float = 0.005
221
+ beam_area_threshold_fac: float = 0.001
222
+ cutoff: float = 0.999
223
+ max_rec: int = 10
224
+ max_tir: int = 10
225
+
226
+ def __post_init__(self):
227
+ """Validate beam tracing parameters."""
228
+ if self.beam_power_threshold <= 0 or self.beam_power_threshold > 1:
229
+ raise ValueError(
230
+ f"beam_power_threshold must be in range (0, 1], got {self.beam_power_threshold}"
231
+ )
232
+
233
+ if self.beam_area_threshold_fac <= 0:
234
+ raise ValueError(
235
+ f"beam_area_threshold_fac must be positive, got {self.beam_area_threshold_fac}"
236
+ )
237
+
238
+ if self.cutoff < 0 or self.cutoff > 1:
239
+ raise ValueError(f"cutoff must be between 0 and 1, got {self.cutoff}")
240
+
241
+ if self.max_rec < 0:
242
+ raise ValueError(f"max_rec must be non-negative, got {self.max_rec}")
243
+
244
+ if self.max_tir < 0:
245
+ raise ValueError(f"max_tir must be non-negative, got {self.max_tir}")
246
+
247
+
248
+ @dataclass
249
+ class GeometryTransformConfig:
250
+ """Geometry transformation parameters.
251
+
252
+ Attributes:
253
+ scale: Problem scaling factor - scales the entire problem including geometry,
254
+ wavelength, and beam area thresholds (default: 1.0)
255
+ distortion: Geometry distortion factor (optional)
256
+ geom_scale: Per-axis geometry scaling [x, y, z] - scales only the geometry
257
+ in each dimension independently (optional)
258
+ """
259
+
260
+ scale: float = 1.0
261
+ distortion: Optional[float] = None
262
+ geom_scale: Optional[List[float]] = None
263
+
264
+ def __post_init__(self):
265
+ """Validate geometry transformations."""
266
+ if self.scale <= 0:
267
+ raise ValueError(f"scale must be positive, got {self.scale}")
268
+
269
+ if self.geom_scale is not None:
270
+ if len(self.geom_scale) != 3:
271
+ raise ValueError(
272
+ f"geom_scale must have exactly 3 values [x, y, z], "
273
+ f"got {len(self.geom_scale)}"
274
+ )
275
+ if any(s <= 0 for s in self.geom_scale):
276
+ raise ValueError("All geom_scale values must be positive")
277
+
278
+
279
+ @dataclass
280
+ class AdvancedConfig:
281
+ """Advanced optical calculation parameters."""
282
+
283
+ mapping: Optional[Any] = (
284
+ "ApertureDiffraction" # String that will be converted to enum in __post_init__
285
+ )
286
+ coherence: bool = True
287
+ fov_factor: Optional[float] = None
288
+
289
+ def __post_init__(self):
290
+ """Validate advanced optics settings and convert mapping string to enum."""
291
+ # Convert string mapping to enum if needed
292
+ if isinstance(self.mapping, str):
293
+ if self.mapping == "ApertureDiffraction":
294
+ self.mapping = goad.Mapping.ApertureDiffraction
295
+ elif self.mapping == "GeometricOptics":
296
+ self.mapping = goad.Mapping.GeometricOptics
297
+ else:
298
+ raise ValueError(
299
+ f"Invalid mapping '{self.mapping}'. Must be 'ApertureDiffraction' or 'GeometricOptics'"
300
+ )
301
+
302
+ if self.fov_factor is not None and self.fov_factor <= 0:
303
+ raise ValueError(f"fov_factor must be positive, got {self.fov_factor}")
304
+
305
+
306
+ @dataclass
307
+ class ConvergenceConfig:
308
+ """
309
+ Unified configuration for all convergence types.
310
+
311
+ This class provides a single configuration interface that works for:
312
+ - Standard convergence (integrated parameters, Mueller elements)
313
+ - PHIPS detector convergence
314
+ - Single geometry or ensemble averaging
315
+
316
+ Attributes:
317
+ geometry: Path to .obj file or directory of .obj files (ensemble)
318
+ mode: ConvergenceMode instance (StandardMode or PHIPSMode)
319
+ convergence_targets: List of convergence target dicts
320
+
321
+ wavelength: Wavelength in microns (default: 0.532)
322
+ particle_refr_index_re: Real part of particle refractive index (default: 1.31)
323
+ particle_refr_index_im: Imaginary part of particle refractive index (default: 0.0)
324
+ medium_refr_index_re: Real part of medium refractive index (default: 1.0)
325
+ medium_refr_index_im: Imaginary part of medium refractive index (default: 0.0)
326
+
327
+ batch_size: Orientations per batch (default: 24)
328
+ max_orientations: Maximum orientations (default: 100,000)
329
+ min_batches: Minimum batches before convergence check (default: 10)
330
+
331
+ beam_tracing: BeamTracingConfig instance for beam tracing parameters
332
+ geometry_transform: GeometryTransformConfig instance for geometry transformations
333
+ advanced_config: AdvancedConfig instance for advanced optical parameters
334
+ seed: Random seed for reproducibility (optional)
335
+
336
+ mueller_1d: Compute 1D Mueller matrix (default: True, standard mode only)
337
+ output_dir: Output directory path (optional)
338
+ """
339
+
340
+ # Required fields
341
+ geometry: Union[str, Path]
342
+ mode: ConvergenceMode
343
+ convergence_targets: List[dict]
344
+
345
+ # Optical settings
346
+ wavelength: float = 0.532
347
+ particle_refr_index_re: float = 1.31
348
+ particle_refr_index_im: float = 0.0
349
+ medium_refr_index_re: float = 1.0
350
+ medium_refr_index_im: float = 0.0
351
+
352
+ # Convergence parameters
353
+ batch_size: int = 24
354
+ max_orientations: int = 100_000
355
+ min_batches: int = 10
356
+
357
+ # Beam tracing configuration
358
+ beam_tracing: BeamTracingConfig = field(default_factory=BeamTracingConfig)
359
+
360
+ # Geometry transformations
361
+ geometry_transform: GeometryTransformConfig = field(
362
+ default_factory=GeometryTransformConfig
363
+ )
364
+
365
+ # Advanced optics
366
+ advanced_config: AdvancedConfig = field(default_factory=AdvancedConfig)
367
+
368
+ # Random seed for reproducibility
369
+ seed: Optional[int] = None
370
+
371
+ # Mueller matrix output (only for StandardMode)
372
+ mueller_1d: bool = True
373
+
374
+ # Output options
375
+ output_dir: Optional[str] = None
376
+ log_file: Optional[str] = None
377
+
378
+ def __post_init__(self):
379
+ """Validate configuration after initialization."""
380
+ # Convert geometry to Path
381
+ self.geometry = Path(self.geometry)
382
+
383
+ # Validate geometry exists
384
+ if not self.geometry.exists():
385
+ raise FileNotFoundError(f"Geometry path does not exist: {self.geometry}")
386
+
387
+ # Validate mode is a ConvergenceMode instance
388
+ if not isinstance(self.mode, ConvergenceMode):
389
+ raise TypeError(
390
+ f"mode must be a ConvergenceMode instance (StandardMode or PHIPSMode), "
391
+ f"got {type(self.mode)}"
392
+ )
393
+
394
+ # Validate convergence targets
395
+ if not isinstance(self.convergence_targets, list):
396
+ raise TypeError("convergence_targets must be a list of dicts")
397
+
398
+ if len(self.convergence_targets) == 0:
399
+ raise ValueError("convergence_targets cannot be empty")
400
+
401
+ # Let mode validate its targets
402
+ self.mode.validate_targets(self.convergence_targets)
403
+
404
+ # Validate numeric parameters
405
+ if self.wavelength <= 0:
406
+ raise ValueError(f"wavelength must be positive, got {self.wavelength}")
407
+
408
+ if self.batch_size <= 0:
409
+ raise ValueError(f"batch_size must be positive, got {self.batch_size}")
410
+
411
+ if self.max_orientations <= 0:
412
+ raise ValueError(
413
+ f"max_orientations must be positive, got {self.max_orientations}"
414
+ )
415
+
416
+ if self.min_batches <= 0:
417
+ raise ValueError(f"min_batches must be positive, got {self.min_batches}")
418
+
419
+ # Validate Mueller options for PHIPS mode
420
+ if isinstance(self.mode, PHIPSMode):
421
+ if self.mueller_1d:
422
+ raise ValueError(
423
+ "mueller_1d is not supported in PHIPSMode. "
424
+ "PHIPS custom binning does not compute integrated Mueller matrices."
425
+ )
426
+
427
+ def is_ensemble(self) -> bool:
428
+ """Check if geometry is a directory (ensemble mode)."""
429
+ return self.geometry.is_dir()
430
+
431
+ def to_dict(self) -> dict:
432
+ """Convert config to dictionary (for serialization)."""
433
+ return {
434
+ "geometry": str(self.geometry),
435
+ "mode": self.mode.get_mode_name(),
436
+ "convergence_targets": self.convergence_targets,
437
+ "wavelength": self.wavelength,
438
+ "particle_refr_index_re": self.particle_refr_index_re,
439
+ "particle_refr_index_im": self.particle_refr_index_im,
440
+ "medium_refr_index_re": self.medium_refr_index_re,
441
+ "medium_refr_index_im": self.medium_refr_index_im,
442
+ "batch_size": self.batch_size,
443
+ "max_orientations": self.max_orientations,
444
+ "min_batches": self.min_batches,
445
+ "beam_tracing": {
446
+ "beam_power_threshold": self.beam_tracing.beam_power_threshold,
447
+ "beam_area_threshold_fac": self.beam_tracing.beam_area_threshold_fac,
448
+ "cutoff": self.beam_tracing.cutoff,
449
+ "max_rec": self.beam_tracing.max_rec,
450
+ "max_tir": self.beam_tracing.max_tir,
451
+ },
452
+ "geometry_transform": {
453
+ "scale": self.geometry_transform.scale,
454
+ "distortion": self.geometry_transform.distortion,
455
+ "geom_scale": self.geometry_transform.geom_scale,
456
+ },
457
+ "advanced_config": {
458
+ "mapping": str(self.advanced_config.mapping),
459
+ "coherence": self.advanced_config.coherence,
460
+ "fov_factor": self.advanced_config.fov_factor,
461
+ },
462
+ "seed": self.seed,
463
+ "mueller_1d": self.mueller_1d,
464
+ "is_ensemble": self.is_ensemble(),
465
+ }
466
+
467
+
468
+ # ============================================================================
469
+ # Results
470
+ # ============================================================================
471
+
472
+
473
+ @dataclass
474
+ class UnifiedResults:
475
+ """
476
+ Unified results container for all convergence types.
477
+
478
+ Provides a consistent interface regardless of convergence mode,
479
+ with JSON serialization support.
480
+ """
481
+
482
+ converged: bool
483
+ n_orientations: int
484
+ mode: str # "standard" or "phips"
485
+ is_ensemble: bool
486
+
487
+ # Values and SEMs (format depends on mode)
488
+ # For standard: Dict[str, float] or Dict[str, np.ndarray] for Mueller
489
+ # For PHIPS: Dict['phips_dscs', np.ndarray] with shape (20,)
490
+ values: Dict[str, Union[float, np.ndarray]]
491
+ sem_values: Dict[str, Union[float, np.ndarray]]
492
+
493
+ # Optional Mueller matrices (standard mode only)
494
+ mueller_1d: Optional[np.ndarray] = None
495
+
496
+ # Bins and detector info
497
+ bins_1d: Optional[np.ndarray] = None # Theta bins for standard mode
498
+ detector_angles: Optional[np.ndarray] = None # Detector angles for PHIPS mode
499
+
500
+ # Metadata
501
+ convergence_history: Optional[List[Tuple[int, str, float]]] = None
502
+ warning: Optional[str] = None
503
+ config: Optional[ConvergenceConfig] = None
504
+
505
+ def summary(self) -> str:
506
+ """Generate human-readable summary."""
507
+ lines = []
508
+ lines.append("=" * 60)
509
+ lines.append("Convergence Results Summary")
510
+ lines.append("=" * 60)
511
+ lines.append(f"Mode: {self.mode}")
512
+ lines.append(f"Ensemble: {self.is_ensemble}")
513
+ lines.append(f"Converged: {self.converged}")
514
+ lines.append(f"Total orientations: {self.n_orientations}")
515
+
516
+ if self.warning:
517
+ lines.append(f"\nWarning: {self.warning}")
518
+
519
+ lines.append("\nConverged Values:")
520
+ for var, value in self.values.items():
521
+ # Skip mueller_1d_sem (it's metadata, not a result to display)
522
+ if var == "mueller_1d_sem":
523
+ continue
524
+
525
+ sem = self.sem_values.get(var)
526
+
527
+ if sem is None:
528
+ # Skip if no SEM available
529
+ continue
530
+
531
+ if isinstance(value, np.ndarray):
532
+ # Array value (Mueller element or PHIPS DSCS)
533
+ lines.append(f" {var}: array with shape {value.shape}")
534
+ lines.append(f" Mean: {np.nanmean(value):.6e}")
535
+ lines.append(f" Std: {np.nanstd(value):.6e}")
536
+ lines.append(
537
+ f" SEM range: [{np.nanmin(sem):.6e}, {np.nanmax(sem):.6e}]"
538
+ )
539
+ else:
540
+ # Scalar value
541
+ lines.append(f" {var}: {value:.6e} ± {sem:.6e}")
542
+
543
+ lines.append("=" * 60)
544
+ return "\n".join(lines)
545
+
546
+ def to_dict(self) -> dict:
547
+ """
548
+ Convert results to dictionary for serialization.
549
+
550
+ Outputs all values as [mean, sem] tuples.
551
+ Mode-specific outputs:
552
+ - Standard mode: integrated parameters + mueller_1d + bins_1d
553
+ - PHIPS mode: all 20 DSCS values + detector_angles (nulls for integrated params/mueller)
554
+ """
555
+ result = {
556
+ "converged": bool(self.converged), # Convert numpy bool to Python bool
557
+ "n_orientations": int(
558
+ self.n_orientations
559
+ ), # Convert numpy int to Python int
560
+ "mode": self.mode,
561
+ "is_ensemble": bool(self.is_ensemble), # Convert numpy bool to Python bool
562
+ "warning": self.warning,
563
+ }
564
+
565
+ # Mode-specific output
566
+ if self.mode == "standard":
567
+ # Standard mode: always output all integrated parameters + Mueller
568
+
569
+ # Integrated parameters (always output, even if not converged on)
570
+ for param in ["asymmetry", "scatt", "ext", "albedo"]:
571
+ mean_val = self.values.get(param)
572
+ sem_val = self.sem_values.get(param)
573
+
574
+ if mean_val is not None and sem_val is not None:
575
+ # Convert to Python floats for JSON serialization
576
+ result[param] = [float(mean_val), float(sem_val)]
577
+ else:
578
+ result[param] = None
579
+
580
+ # Mueller 1D (if available)
581
+ if self.mueller_1d is not None:
582
+ # mueller_1d is shape (n_theta, 16)
583
+ # Convert to list of [mean, sem] tuples per angle per element
584
+ mueller_mean = self.mueller_1d
585
+ mueller_sem = self.values.get("mueller_1d_sem")
586
+
587
+ if mueller_sem is not None:
588
+ # Create [mean, sem] format for each position
589
+ mueller_output = []
590
+ for i in range(mueller_mean.shape[0]):
591
+ angle_data = []
592
+ for j in range(mueller_mean.shape[1]):
593
+ # Convert to Python floats for JSON serialization
594
+ angle_data.append(
595
+ [float(mueller_mean[i, j]), float(mueller_sem[i, j])]
596
+ )
597
+ mueller_output.append(angle_data)
598
+ result["mueller_1d"] = mueller_output
599
+ else:
600
+ # No SEM available, just output mean
601
+ result["mueller_1d"] = mueller_mean.tolist()
602
+ else:
603
+ result["mueller_1d"] = None
604
+
605
+ # Bins (if available)
606
+ if self.bins_1d is not None:
607
+ result["bins_1d"] = self.bins_1d.tolist()
608
+ else:
609
+ result["bins_1d"] = None
610
+
611
+ # PHIPS fields are null in standard mode
612
+ result["phips_dscs"] = None
613
+ result["detector_angles"] = None
614
+
615
+ elif self.mode == "phips":
616
+ # PHIPS mode: all 20 DSCS values + detector angles
617
+
618
+ # Integrated parameters are null in PHIPS mode
619
+ result["asymmetry"] = None
620
+ result["scatt"] = None
621
+ result["ext"] = None
622
+ result["albedo"] = None
623
+ result["mueller_1d"] = None
624
+ result["bins_1d"] = None
625
+
626
+ # PHIPS DSCS values (always all 20, with [mean, sem] format)
627
+ phips_mean = self.values.get("phips_dscs")
628
+ phips_sem = self.sem_values.get("phips_dscs")
629
+
630
+ if phips_mean is not None and phips_sem is not None:
631
+ # Shape should be (20,)
632
+ phips_output = []
633
+ for i in range(len(phips_mean)):
634
+ # Convert numpy scalars to Python floats for JSON serialization
635
+ phips_output.append([float(phips_mean[i]), float(phips_sem[i])])
636
+ result["phips_dscs"] = phips_output
637
+ else:
638
+ result["phips_dscs"] = None
639
+
640
+ # Detector angles
641
+ if self.detector_angles is not None:
642
+ result["detector_angles"] = self.detector_angles.tolist()
643
+ else:
644
+ result["detector_angles"] = None
645
+
646
+ # Add convergence history
647
+ if self.convergence_history:
648
+ result["convergence_history"] = [
649
+ {"n_orientations": n, "variable": var, "sem": sem}
650
+ for n, var, sem in self.convergence_history
651
+ ]
652
+
653
+ # Add config if present
654
+ if self.config:
655
+ result["config"] = self.config.to_dict()
656
+
657
+ return result
658
+
659
+ def save(self, path: str) -> None:
660
+ """
661
+ Save results to JSON file.
662
+
663
+ Args:
664
+ path: Output file path (will append .json if not present)
665
+ """
666
+ path = Path(path)
667
+ if path.suffix != ".json":
668
+ path = path.with_suffix(".json")
669
+
670
+ with open(path, "w") as f:
671
+ json.dump(self.to_dict(), f, indent=2)
672
+
673
+ print(f"Results saved to: {path}")
674
+
675
+ @classmethod
676
+ def load(cls, path: str) -> "UnifiedResults":
677
+ """
678
+ Load results from JSON file.
679
+
680
+ Args:
681
+ path: Input file path
682
+
683
+ Returns:
684
+ UnifiedResults instance
685
+ """
686
+ with open(path, "r") as f:
687
+ data = json.load(f)
688
+
689
+ # New tuple format: each parameter is [mean, sem]
690
+ # Extract values and sems from the new format
691
+ values = {}
692
+ sem_values = {}
693
+
694
+ mode = data["mode"]
695
+
696
+ if mode == "standard":
697
+ # Standard mode: extract integrated parameters
698
+ for param in ["asymmetry", "scatt", "ext", "albedo"]:
699
+ param_data = data.get(param)
700
+ if param_data is not None:
701
+ values[param] = param_data[0] # mean
702
+ sem_values[param] = param_data[1] # sem
703
+
704
+ # Mueller 1D (if present)
705
+ mueller_1d_data = data.get("mueller_1d")
706
+ if mueller_1d_data is not None and len(mueller_1d_data) > 0:
707
+ # mueller_1d is stored as list of [mean, sem] tuples per angle per element
708
+ # Need to separate into mean array and sem array
709
+ # But actually, we need to check if it's in the new format or old format
710
+ if isinstance(mueller_1d_data[0][0], list):
711
+ # New format: [[mean, sem], [mean, sem], ...]
712
+ n_theta = len(mueller_1d_data)
713
+ n_elements = len(mueller_1d_data[0])
714
+ mueller_mean = np.zeros((n_theta, n_elements))
715
+ mueller_sem = np.zeros((n_theta, n_elements))
716
+ for i in range(n_theta):
717
+ for j in range(n_elements):
718
+ mueller_mean[i, j] = mueller_1d_data[i][j][0]
719
+ mueller_sem[i, j] = mueller_1d_data[i][j][1]
720
+ mueller_1d = mueller_mean
721
+ values["mueller_1d_sem"] = mueller_sem
722
+ else:
723
+ # Old format: just the mean array
724
+ mueller_1d = np.array(mueller_1d_data)
725
+ else:
726
+ mueller_1d = None
727
+
728
+ # Bins
729
+ bins_1d = data.get("bins_1d")
730
+ if bins_1d is not None:
731
+ bins_1d = np.array(bins_1d)
732
+
733
+ detector_angles = None
734
+
735
+ elif mode == "phips":
736
+ # PHIPS mode: extract DSCS values
737
+ phips_data = data.get("phips_dscs")
738
+ if phips_data is not None:
739
+ # phips_dscs is list of [mean, sem] tuples (20 detectors)
740
+ phips_mean = np.array([d[0] for d in phips_data])
741
+ phips_sem = np.array([d[1] for d in phips_data])
742
+ values["phips_dscs"] = phips_mean
743
+ sem_values["phips_dscs"] = phips_sem
744
+
745
+ # Detector angles
746
+ detector_angles = data.get("detector_angles")
747
+ if detector_angles is not None:
748
+ detector_angles = np.array(detector_angles)
749
+
750
+ mueller_1d = None
751
+ bins_1d = None
752
+
753
+ # Convert convergence history
754
+ convergence_history = None
755
+ if "convergence_history" in data and data["convergence_history"] is not None:
756
+ convergence_history = [
757
+ (h["n_orientations"], h["variable"], h["sem"])
758
+ for h in data["convergence_history"]
759
+ ]
760
+
761
+ return cls(
762
+ converged=data["converged"],
763
+ n_orientations=data["n_orientations"],
764
+ mode=data["mode"],
765
+ is_ensemble=data["is_ensemble"],
766
+ values=values,
767
+ sem_values=sem_values,
768
+ mueller_1d=mueller_1d,
769
+ bins_1d=bins_1d,
770
+ detector_angles=detector_angles,
771
+ convergence_history=convergence_history,
772
+ warning=data.get("warning"),
773
+ config=None, # Config not reconstructed from JSON
774
+ )
775
+
776
+
777
+ # ============================================================================
778
+ # Unified Convergence
779
+ # ============================================================================
780
+
781
+
782
+ class UnifiedConvergence:
783
+ """
784
+ Unified convergence runner that handles all modes.
785
+
786
+ Auto-selects appropriate convergence class based on configuration.
787
+ """
788
+
789
+ def __init__(self, config: ConvergenceConfig):
790
+ """
791
+ Initialize unified convergence.
792
+
793
+ Args:
794
+ config: ConvergenceConfig instance
795
+ """
796
+ self.config = config
797
+ self._convergence = None
798
+ self._setup()
799
+
800
+ def _setup(self):
801
+ """Create appropriate convergence instance based on config."""
802
+ # Determine geometry path for Settings initialization
803
+ # For ensemble mode, use the first .obj file found in the directory
804
+ if self.config.is_ensemble():
805
+ geom_path = Path(self.config.geometry)
806
+ obj_files = sorted(geom_path.glob("*.obj"))
807
+ if not obj_files:
808
+ raise ValueError(
809
+ f"No .obj files found in directory: {self.config.geometry}"
810
+ )
811
+ geom_path_str = str(obj_files[0])
812
+ else:
813
+ geom_path_str = str(self.config.geometry)
814
+
815
+ # Create GOAD settings with all parameters
816
+ settings = goad.Settings(
817
+ geom_path=geom_path_str,
818
+ wavelength=self.config.wavelength,
819
+ particle_refr_index_re=self.config.particle_refr_index_re,
820
+ particle_refr_index_im=self.config.particle_refr_index_im,
821
+ medium_refr_index_re=self.config.medium_refr_index_re,
822
+ medium_refr_index_im=self.config.medium_refr_index_im,
823
+ binning=self.config.mode.get_binning(),
824
+ # Beam tracing parameters
825
+ beam_power_threshold=self.config.beam_tracing.beam_power_threshold,
826
+ beam_area_threshold_fac=self.config.beam_tracing.beam_area_threshold_fac,
827
+ cutoff=self.config.beam_tracing.cutoff,
828
+ max_rec=self.config.beam_tracing.max_rec,
829
+ max_tir=self.config.beam_tracing.max_tir,
830
+ # Geometry transformations
831
+ scale=self.config.geometry_transform.scale,
832
+ # Advanced configuration
833
+ mapping=self.config.advanced_config.mapping,
834
+ coherence=self.config.advanced_config.coherence,
835
+ )
836
+
837
+ # Set optional parameters if provided
838
+ if self.config.seed is not None:
839
+ settings.seed = self.config.seed
840
+
841
+ if self.config.geometry_transform.distortion is not None:
842
+ settings.distortion = self.config.geometry_transform.distortion
843
+
844
+ if self.config.geometry_transform.geom_scale is not None:
845
+ settings.geom_scale = self.config.geometry_transform.geom_scale
846
+
847
+ if self.config.advanced_config.fov_factor is not None:
848
+ settings.fov_factor = self.config.advanced_config.fov_factor
849
+
850
+ # Create convergence instance based on mode
851
+ if isinstance(self.config.mode, StandardMode):
852
+ self._setup_standard(settings)
853
+ elif isinstance(self.config.mode, PHIPSMode):
854
+ self._setup_phips(settings)
855
+ else:
856
+ raise ValueError(f"Unknown mode type: {type(self.config.mode)}")
857
+
858
+ def _setup_standard(self, settings):
859
+ """Setup standard convergence."""
860
+ # Convert target dicts to Convergable instances
861
+ convergables = []
862
+ for target_dict in self.config.convergence_targets:
863
+ convergables.append(Convergable(**target_dict))
864
+
865
+ # Select ensemble or single-geometry convergence
866
+ if self.config.is_ensemble():
867
+ self._convergence = EnsembleConvergence(
868
+ settings=settings,
869
+ convergables=convergables,
870
+ geom_dir=str(self.config.geometry),
871
+ batch_size=self.config.batch_size,
872
+ max_orientations=self.config.max_orientations,
873
+ min_batches=self.config.min_batches,
874
+ mueller_1d=self.config.mueller_1d,
875
+ mueller_2d=False,
876
+ log_file=self.config.log_file,
877
+ )
878
+ else:
879
+ self._convergence = Convergence(
880
+ settings=settings,
881
+ convergables=convergables,
882
+ batch_size=self.config.batch_size,
883
+ max_orientations=self.config.max_orientations,
884
+ min_batches=self.config.min_batches,
885
+ mueller_1d=self.config.mueller_1d,
886
+ mueller_2d=False,
887
+ log_file=self.config.log_file,
888
+ )
889
+
890
+ def _setup_phips(self, settings):
891
+ """Setup PHIPS convergence."""
892
+ # PHIPS only has one convergable target
893
+ target_dict = self.config.convergence_targets[0]
894
+
895
+ # Remove 'variable' if present (not used in PHIPS)
896
+ target_dict = {k: v for k, v in target_dict.items() if k != "variable"}
897
+
898
+ convergable = PHIPSConvergable(**target_dict)
899
+
900
+ # Select ensemble or single-geometry convergence
901
+ if self.config.is_ensemble():
902
+ self._convergence = PHIPSEnsembleConvergence(
903
+ settings=settings,
904
+ convergable=convergable,
905
+ geom_dir=str(self.config.geometry),
906
+ batch_size=self.config.batch_size,
907
+ max_orientations=self.config.max_orientations,
908
+ min_batches=self.config.min_batches,
909
+ log_file=self.config.log_file,
910
+ )
911
+ else:
912
+ self._convergence = PHIPSConvergence(
913
+ settings=settings,
914
+ convergable=convergable,
915
+ batch_size=self.config.batch_size,
916
+ max_orientations=self.config.max_orientations,
917
+ min_batches=self.config.min_batches,
918
+ log_file=self.config.log_file,
919
+ )
920
+
921
+ def run(self) -> UnifiedResults:
922
+ """
923
+ Run convergence study.
924
+
925
+ Returns:
926
+ UnifiedResults instance with ALL parameters extracted (not just converged ones)
927
+ """
928
+ # Run convergence
929
+ results = self._convergence.run()
930
+
931
+ # Extract ALL integrated parameters (not just converged ones)
932
+ all_values = {}
933
+ all_sems = {}
934
+
935
+ if isinstance(self.config.mode, StandardMode):
936
+ # Standard mode: extract all 4 integrated parameters
937
+ for param in ["asymmetry", "scatt", "ext", "albedo"]:
938
+ if param in results.values:
939
+ # Already computed (was a convergence target)
940
+ all_values[param] = results.values[param]
941
+ all_sems[param] = results.sem_values[param]
942
+ else:
943
+ # Not a convergence target - compute it now
944
+ mean, sem = self._convergence._calculate_mean_and_sem(param)
945
+ all_values[param] = mean
946
+ all_sems[param] = sem
947
+
948
+ # Also include any Mueller elements that were converged on
949
+ for key in results.values:
950
+ if key.startswith("S"): # Mueller element
951
+ all_values[key] = results.values[key]
952
+ all_sems[key] = results.sem_values[key]
953
+
954
+ # Include mueller_1d_sem if available (for full SEM output)
955
+ if "mueller_1d_sem" in results.values:
956
+ all_values["mueller_1d_sem"] = results.values["mueller_1d_sem"]
957
+
958
+ elif isinstance(self.config.mode, PHIPSMode):
959
+ # PHIPS mode: extract all 20 DSCS values
960
+ if "phips_dscs" in results.values:
961
+ all_values["phips_dscs"] = results.values["phips_dscs"]
962
+ all_sems["phips_dscs"] = results.sem_values["phips_dscs"]
963
+ else:
964
+ # This shouldn't happen in PHIPS mode, but handle it
965
+ all_values["phips_dscs"] = None
966
+ all_sems["phips_dscs"] = None
967
+
968
+ # Get bins_1d or detector_angles based on mode
969
+ bins_1d = None
970
+ detector_angles = None
971
+
972
+ if isinstance(self.config.mode, StandardMode):
973
+ # Extract theta bins from Mueller 1D if available
974
+ if results.mueller_1d is not None:
975
+ # bins_1d should be theta values
976
+ # For simple binning: np.linspace(0, 180, n_theta)
977
+ # For custom binning: we need to extract from the binning scheme
978
+ binning = self.config.mode.get_binning()
979
+ if hasattr(binning, "get_theta_bins"):
980
+ bins_1d = binning.get_theta_bins()
981
+ else:
982
+ # Fallback: assume uniform spacing
983
+ n_theta = results.mueller_1d.shape[0]
984
+ bins_1d = np.linspace(0, 180, n_theta)
985
+
986
+ elif isinstance(self.config.mode, PHIPSMode):
987
+ # PHIPS detector angles (18° to 170°, 20 detectors)
988
+ detector_angles = np.array(
989
+ [
990
+ 18.0,
991
+ 26.0,
992
+ 34.0,
993
+ 42.0,
994
+ 50.0,
995
+ 58.0,
996
+ 66.0,
997
+ 74.0,
998
+ 82.0,
999
+ 90.0,
1000
+ 98.0,
1001
+ 106.0,
1002
+ 114.0,
1003
+ 122.0,
1004
+ 130.0,
1005
+ 138.0,
1006
+ 146.0,
1007
+ 154.0,
1008
+ 162.0,
1009
+ 170.0,
1010
+ ]
1011
+ )
1012
+
1013
+ # Convert to UnifiedResults
1014
+ unified = UnifiedResults(
1015
+ converged=results.converged,
1016
+ n_orientations=results.n_orientations,
1017
+ mode=self.config.mode.get_mode_name(),
1018
+ is_ensemble=self.config.is_ensemble(),
1019
+ values=all_values,
1020
+ sem_values=all_sems,
1021
+ mueller_1d=results.mueller_1d,
1022
+ bins_1d=bins_1d,
1023
+ detector_angles=detector_angles,
1024
+ convergence_history=results.convergence_history,
1025
+ warning=results.warning,
1026
+ config=self.config,
1027
+ )
1028
+
1029
+ return unified
1030
+
1031
+
1032
+ # ============================================================================
1033
+ # Convenience Functions
1034
+ # ============================================================================
1035
+
1036
+
1037
+ def run_convergence(
1038
+ geometry: Union[str, Path],
1039
+ targets: Union[str, List[str], List[dict]],
1040
+ tolerance: float = 0.05,
1041
+ tolerance_type: str = "relative",
1042
+ mode: Union[str, ConvergenceMode] = "auto",
1043
+ **kwargs,
1044
+ ) -> UnifiedResults:
1045
+ """
1046
+ Run convergence study with unified interface.
1047
+
1048
+ This is the primary entry point for most users.
1049
+
1050
+ Args:
1051
+ geometry: Path to .obj file or directory of .obj files
1052
+ targets: What to converge on:
1053
+ - Single string: "asymmetry", "scatt", "S11", "phips_dscs"
1054
+ - List of strings: ["asymmetry", "scatt"]
1055
+ - List of dicts: [{"variable": "S11", "tolerance": 0.1, ...}]
1056
+ tolerance: Default tolerance (can be overridden per target)
1057
+ tolerance_type: "relative" or "absolute"
1058
+ mode: ConvergenceMode instance, or string "auto"/"standard"/"phips"
1059
+ **kwargs: Additional settings:
1060
+ # Optical settings
1061
+ - wavelength: Wavelength in microns (default: 0.532)
1062
+ - particle_refr_index_re: Real part of particle refractive index
1063
+ - particle_refr_index_im: Imaginary part of particle refractive index
1064
+ - medium_refr_index_re: Real part of medium refractive index
1065
+ - medium_refr_index_im: Imaginary part of medium refractive index
1066
+
1067
+ # Convergence parameters
1068
+ - batch_size: Orientations per batch (default: 24)
1069
+ - max_orientations: Maximum orientations (default: 100,000)
1070
+ - min_batches: Minimum batches before convergence (default: 10)
1071
+ - mueller_1d: Compute 1D Mueller matrix (default: True, standard mode only)
1072
+
1073
+ # Mode settings
1074
+ - phips_bins_file: Path to PHIPS bins TOML (required if mode="phips")
1075
+ - n_theta: Number of theta bins for standard mode (default: 181)
1076
+ - n_phi: Number of phi bins for standard mode (default: 181)
1077
+
1078
+ # Beam tracing parameters
1079
+ - beam_power_threshold: Beam power threshold (default: 0.005)
1080
+ - beam_area_threshold_fac: Beam area threshold factor (default: 0.001)
1081
+ - cutoff: Ray power cutoff (default: 0.999)
1082
+ - max_rec: Max recursion depth (default: 10)
1083
+ - max_tir: Max TIR bounces (default: 10)
1084
+
1085
+ # Geometry transformations
1086
+ - scale: Problem scaling factor - scales entire problem including geometry,
1087
+ wavelength, and beam area thresholds (default: 1.0)
1088
+ - distortion: Geometry distortion factor (optional)
1089
+ - geom_scale: Per-axis geometry scaling [x, y, z] - scales only geometry
1090
+ in each dimension independently (optional)
1091
+
1092
+ # Advanced configuration
1093
+ - mapping: DSCS mapping scheme (default: goad.Mapping.ApertureDiffraction)
1094
+ - coherence: Enable coherent scattering (default: True)
1095
+ - fov_factor: Field of view factor (optional)
1096
+
1097
+ # Reproducibility
1098
+ - seed: Random seed for orientations (optional)
1099
+
1100
+ # Output options
1101
+ - log_file: Path to log file for convergence progress (optional)
1102
+
1103
+ Returns:
1104
+ UnifiedResults object
1105
+
1106
+ Examples:
1107
+ # Simple: converge asymmetry for single geometry
1108
+ results = run_convergence("hex.obj", "asymmetry", tolerance=0.01)
1109
+
1110
+ # Multiple targets, ensemble
1111
+ results = run_convergence(
1112
+ "./test_obj",
1113
+ ["asymmetry", "scatt"],
1114
+ tolerance=0.05,
1115
+ batch_size=48
1116
+ )
1117
+
1118
+ # PHIPS detectors
1119
+ results = run_convergence(
1120
+ "./test_obj",
1121
+ "phips_dscs",
1122
+ tolerance=0.25,
1123
+ mode="phips",
1124
+ phips_bins_file="phips_bins_edges.toml"
1125
+ )
1126
+
1127
+ # Mueller elements with specific bins
1128
+ results = run_convergence(
1129
+ "hex.obj",
1130
+ [{"variable": "S11", "tolerance": 0.1, "theta_indices": [180]}],
1131
+ batch_size=12
1132
+ )
1133
+
1134
+ # Advanced: custom beam tracing and geometry scaling
1135
+ results = run_convergence(
1136
+ "complex_particle.obj",
1137
+ "asymmetry",
1138
+ max_rec=200,
1139
+ max_tir=150,
1140
+ cutoff=0.0001,
1141
+ scale=2.0,
1142
+ seed=42
1143
+ )
1144
+ """
1145
+ # Extract beam tracing parameters from kwargs
1146
+ beam_tracing = BeamTracingConfig(
1147
+ beam_power_threshold=kwargs.pop("beam_power_threshold", 0.005),
1148
+ beam_area_threshold_fac=kwargs.pop("beam_area_threshold_fac", 0.001),
1149
+ cutoff=kwargs.pop("cutoff", 0.999),
1150
+ max_rec=kwargs.pop("max_rec", 10),
1151
+ max_tir=kwargs.pop("max_tir", 10),
1152
+ )
1153
+
1154
+ # Extract geometry transform parameters
1155
+ geometry_transform = GeometryTransformConfig(
1156
+ scale=kwargs.pop("scale", 1.0),
1157
+ distortion=kwargs.pop("distortion", None),
1158
+ geom_scale=kwargs.pop("geom_scale", None),
1159
+ )
1160
+
1161
+ # Extract advanced configuration parameters
1162
+ advanced_config = AdvancedConfig(
1163
+ mapping=kwargs.pop("mapping", "ApertureDiffraction"),
1164
+ coherence=kwargs.pop("coherence", True),
1165
+ fov_factor=kwargs.pop("fov_factor", None),
1166
+ )
1167
+
1168
+ # Extract seed and log_file
1169
+ seed = kwargs.pop("seed", None)
1170
+ log_file = kwargs.pop("log_file", None)
1171
+
1172
+ # Normalize targets to list of dicts
1173
+ target_dicts = _normalize_targets(targets, tolerance, tolerance_type)
1174
+
1175
+ # Auto-detect or create mode
1176
+ if isinstance(mode, str):
1177
+ if mode == "auto":
1178
+ mode = _auto_detect_mode(target_dicts, kwargs)
1179
+ elif mode == "standard":
1180
+ n_theta = kwargs.pop("n_theta", 181)
1181
+ n_phi = kwargs.pop("n_phi", 181)
1182
+ mode = StandardMode(n_theta=n_theta, n_phi=n_phi)
1183
+ elif mode == "phips":
1184
+ phips_bins_file = kwargs.pop("phips_bins_file", None)
1185
+ if phips_bins_file is None:
1186
+ raise ValueError("phips_bins_file must be specified when mode='phips'")
1187
+ mode = PHIPSMode(bins_file=phips_bins_file)
1188
+ else:
1189
+ raise ValueError(
1190
+ f"Invalid mode string '{mode}'. Must be 'auto', 'standard', or 'phips'"
1191
+ )
1192
+
1193
+ # Build config with new parameters
1194
+ config = ConvergenceConfig(
1195
+ geometry=geometry,
1196
+ mode=mode,
1197
+ convergence_targets=target_dicts,
1198
+ beam_tracing=beam_tracing,
1199
+ geometry_transform=geometry_transform,
1200
+ advanced_config=advanced_config,
1201
+ seed=seed,
1202
+ log_file=log_file,
1203
+ **kwargs, # Remaining kwargs (wavelength, particle_refr_index_re, etc.)
1204
+ )
1205
+
1206
+ # Run
1207
+ conv = UnifiedConvergence(config)
1208
+ return conv.run()
1209
+
1210
+
1211
+ def run_convergence_sweep(
1212
+ configs: List[ConvergenceConfig], parallel: bool = False, n_jobs: int = -1
1213
+ ) -> List[UnifiedResults]:
1214
+ """
1215
+ Run multiple convergence studies (for parameter sweeps).
1216
+
1217
+ Args:
1218
+ configs: List of ConvergenceConfig instances
1219
+ parallel: Run in parallel if True (not yet implemented)
1220
+ n_jobs: Number of parallel jobs (-1 = all cores)
1221
+
1222
+ Returns:
1223
+ List of UnifiedResults
1224
+
1225
+ Example:
1226
+ # Wavelength sweep
1227
+ wavelengths = [0.532, 0.633, 0.780]
1228
+ configs = [
1229
+ ConvergenceConfig(
1230
+ geometry="./test_obj",
1231
+ mode=PHIPSMode("phips_bins_edges.toml"),
1232
+ convergence_targets=[{"tolerance": 0.25, "tolerance_type": "relative"}],
1233
+ wavelength=wl
1234
+ )
1235
+ for wl in wavelengths
1236
+ ]
1237
+ results = run_convergence_sweep(configs)
1238
+ """
1239
+ if parallel:
1240
+ raise NotImplementedError("Parallel execution not yet implemented")
1241
+
1242
+ results = []
1243
+ for i, config in enumerate(configs):
1244
+ print(f"\n{'=' * 60}")
1245
+ print(f"Running convergence {i + 1}/{len(configs)}")
1246
+ print(f"{'=' * 60}")
1247
+
1248
+ conv = UnifiedConvergence(config)
1249
+ result = conv.run()
1250
+ results.append(result)
1251
+
1252
+ return results
1253
+
1254
+
1255
+ # ============================================================================
1256
+ # Helper Functions
1257
+ # ============================================================================
1258
+
1259
+
1260
+ def _normalize_targets(
1261
+ targets: Union[str, List[str], List[dict]],
1262
+ default_tolerance: float,
1263
+ default_tolerance_type: str,
1264
+ ) -> List[dict]:
1265
+ """
1266
+ Normalize targets to list of dicts.
1267
+
1268
+ Args:
1269
+ targets: Target specification (string, list of strings, or list of dicts)
1270
+ default_tolerance: Default tolerance value
1271
+ default_tolerance_type: Default tolerance type
1272
+
1273
+ Returns:
1274
+ List of target dicts
1275
+ """
1276
+ # Convert single string to list
1277
+ if isinstance(targets, str):
1278
+ targets = [targets]
1279
+
1280
+ # Convert to list of dicts
1281
+ target_dicts = []
1282
+ for target in targets:
1283
+ if isinstance(target, str):
1284
+ # String target - apply defaults
1285
+ target_dict = {
1286
+ "variable": target,
1287
+ "tolerance": default_tolerance,
1288
+ "tolerance_type": default_tolerance_type,
1289
+ }
1290
+ target_dicts.append(target_dict)
1291
+ elif isinstance(target, dict):
1292
+ # Dict target - fill in missing defaults
1293
+ target_dict = {
1294
+ "tolerance": default_tolerance,
1295
+ "tolerance_type": default_tolerance_type,
1296
+ **target, # Override with provided values
1297
+ }
1298
+ target_dicts.append(target_dict)
1299
+ else:
1300
+ raise TypeError(
1301
+ f"Invalid target type: {type(target)}. Must be str or dict."
1302
+ )
1303
+
1304
+ return target_dicts
1305
+
1306
+
1307
+ def _auto_detect_mode(target_dicts: List[dict], kwargs: dict) -> ConvergenceMode:
1308
+ """
1309
+ Auto-detect convergence mode from targets.
1310
+
1311
+ Args:
1312
+ target_dicts: List of target dictionaries
1313
+ kwargs: Additional keyword arguments
1314
+
1315
+ Returns:
1316
+ ConvergenceMode instance
1317
+ """
1318
+ # Check if any target is "phips_dscs" or if phips_bins_file is specified
1319
+ has_phips_target = any(
1320
+ target.get("variable") == "phips_dscs" for target in target_dicts
1321
+ )
1322
+ has_phips_bins = "phips_bins_file" in kwargs
1323
+
1324
+ if has_phips_target or has_phips_bins:
1325
+ # PHIPS mode
1326
+ phips_bins_file = kwargs.pop("phips_bins_file", None)
1327
+ if phips_bins_file is None:
1328
+ raise ValueError(
1329
+ "Auto-detected PHIPS mode but phips_bins_file not specified. "
1330
+ "Please provide phips_bins_file or set mode='phips' explicitly."
1331
+ )
1332
+ return PHIPSMode(bins_file=phips_bins_file)
1333
+ else:
1334
+ # Standard mode
1335
+ n_theta = kwargs.pop("n_theta", 181)
1336
+ n_phi = kwargs.pop("n_phi", 181)
1337
+ return StandardMode(n_theta=n_theta, n_phi=n_phi)