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.

@@ -0,0 +1,1327 @@
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
+ from dataclasses import dataclass, field, asdict
19
+ from typing import List, Dict, Optional, Union, Any, Tuple
20
+ from pathlib import Path
21
+ import numpy as np
22
+ import json
23
+ import toml
24
+ from abc import ABC, abstractmethod
25
+
26
+ from . import _goad_py as goad
27
+ from .convergence import (
28
+ Convergence,
29
+ Convergable,
30
+ ConvergenceResults,
31
+ EnsembleConvergence,
32
+ )
33
+ from .phips_convergence import (
34
+ PHIPSConvergence,
35
+ PHIPSConvergable,
36
+ PHIPSEnsembleConvergence,
37
+ )
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
+
377
+ def __post_init__(self):
378
+ """Validate configuration after initialization."""
379
+ # Convert geometry to Path
380
+ self.geometry = Path(self.geometry)
381
+
382
+ # Validate geometry exists
383
+ if not self.geometry.exists():
384
+ raise FileNotFoundError(f"Geometry path does not exist: {self.geometry}")
385
+
386
+ # Validate mode is a ConvergenceMode instance
387
+ if not isinstance(self.mode, ConvergenceMode):
388
+ raise TypeError(
389
+ f"mode must be a ConvergenceMode instance (StandardMode or PHIPSMode), "
390
+ f"got {type(self.mode)}"
391
+ )
392
+
393
+ # Validate convergence targets
394
+ if not isinstance(self.convergence_targets, list):
395
+ raise TypeError("convergence_targets must be a list of dicts")
396
+
397
+ if len(self.convergence_targets) == 0:
398
+ raise ValueError("convergence_targets cannot be empty")
399
+
400
+ # Let mode validate its targets
401
+ self.mode.validate_targets(self.convergence_targets)
402
+
403
+ # Validate numeric parameters
404
+ if self.wavelength <= 0:
405
+ raise ValueError(f"wavelength must be positive, got {self.wavelength}")
406
+
407
+ if self.batch_size <= 0:
408
+ raise ValueError(f"batch_size must be positive, got {self.batch_size}")
409
+
410
+ if self.max_orientations <= 0:
411
+ raise ValueError(
412
+ f"max_orientations must be positive, got {self.max_orientations}"
413
+ )
414
+
415
+ if self.min_batches <= 0:
416
+ raise ValueError(f"min_batches must be positive, got {self.min_batches}")
417
+
418
+ # Validate Mueller options for PHIPS mode
419
+ if isinstance(self.mode, PHIPSMode):
420
+ if self.mueller_1d:
421
+ raise ValueError(
422
+ "mueller_1d is not supported in PHIPSMode. "
423
+ "PHIPS custom binning does not compute integrated Mueller matrices."
424
+ )
425
+
426
+ def is_ensemble(self) -> bool:
427
+ """Check if geometry is a directory (ensemble mode)."""
428
+ return self.geometry.is_dir()
429
+
430
+ def to_dict(self) -> dict:
431
+ """Convert config to dictionary (for serialization)."""
432
+ return {
433
+ "geometry": str(self.geometry),
434
+ "mode": self.mode.get_mode_name(),
435
+ "convergence_targets": self.convergence_targets,
436
+ "wavelength": self.wavelength,
437
+ "particle_refr_index_re": self.particle_refr_index_re,
438
+ "particle_refr_index_im": self.particle_refr_index_im,
439
+ "medium_refr_index_re": self.medium_refr_index_re,
440
+ "medium_refr_index_im": self.medium_refr_index_im,
441
+ "batch_size": self.batch_size,
442
+ "max_orientations": self.max_orientations,
443
+ "min_batches": self.min_batches,
444
+ "beam_tracing": {
445
+ "beam_power_threshold": self.beam_tracing.beam_power_threshold,
446
+ "beam_area_threshold_fac": self.beam_tracing.beam_area_threshold_fac,
447
+ "cutoff": self.beam_tracing.cutoff,
448
+ "max_rec": self.beam_tracing.max_rec,
449
+ "max_tir": self.beam_tracing.max_tir,
450
+ },
451
+ "geometry_transform": {
452
+ "scale": self.geometry_transform.scale,
453
+ "distortion": self.geometry_transform.distortion,
454
+ "geom_scale": self.geometry_transform.geom_scale,
455
+ },
456
+ "advanced_config": {
457
+ "mapping": str(self.advanced_config.mapping),
458
+ "coherence": self.advanced_config.coherence,
459
+ "fov_factor": self.advanced_config.fov_factor,
460
+ },
461
+ "seed": self.seed,
462
+ "mueller_1d": self.mueller_1d,
463
+ "is_ensemble": self.is_ensemble(),
464
+ }
465
+
466
+
467
+ # ============================================================================
468
+ # Results
469
+ # ============================================================================
470
+
471
+
472
+ @dataclass
473
+ class UnifiedResults:
474
+ """
475
+ Unified results container for all convergence types.
476
+
477
+ Provides a consistent interface regardless of convergence mode,
478
+ with JSON serialization support.
479
+ """
480
+
481
+ converged: bool
482
+ n_orientations: int
483
+ mode: str # "standard" or "phips"
484
+ is_ensemble: bool
485
+
486
+ # Values and SEMs (format depends on mode)
487
+ # For standard: Dict[str, float] or Dict[str, np.ndarray] for Mueller
488
+ # For PHIPS: Dict['phips_dscs', np.ndarray] with shape (20,)
489
+ values: Dict[str, Union[float, np.ndarray]]
490
+ sem_values: Dict[str, Union[float, np.ndarray]]
491
+
492
+ # Optional Mueller matrices (standard mode only)
493
+ mueller_1d: Optional[np.ndarray] = None
494
+
495
+ # Bins and detector info
496
+ bins_1d: Optional[np.ndarray] = None # Theta bins for standard mode
497
+ detector_angles: Optional[np.ndarray] = None # Detector angles for PHIPS mode
498
+
499
+ # Metadata
500
+ convergence_history: Optional[List[Tuple[int, str, float]]] = None
501
+ warning: Optional[str] = None
502
+ config: Optional[ConvergenceConfig] = None
503
+
504
+ def summary(self) -> str:
505
+ """Generate human-readable summary."""
506
+ lines = []
507
+ lines.append("=" * 60)
508
+ lines.append("Convergence Results Summary")
509
+ lines.append("=" * 60)
510
+ lines.append(f"Mode: {self.mode}")
511
+ lines.append(f"Ensemble: {self.is_ensemble}")
512
+ lines.append(f"Converged: {self.converged}")
513
+ lines.append(f"Total orientations: {self.n_orientations}")
514
+
515
+ if self.warning:
516
+ lines.append(f"\nWarning: {self.warning}")
517
+
518
+ lines.append("\nConverged Values:")
519
+ for var, value in self.values.items():
520
+ # Skip mueller_1d_sem (it's metadata, not a result to display)
521
+ if var == "mueller_1d_sem":
522
+ continue
523
+
524
+ sem = self.sem_values.get(var)
525
+
526
+ if sem is None:
527
+ # Skip if no SEM available
528
+ continue
529
+
530
+ if isinstance(value, np.ndarray):
531
+ # Array value (Mueller element or PHIPS DSCS)
532
+ lines.append(f" {var}: array with shape {value.shape}")
533
+ lines.append(f" Mean: {np.nanmean(value):.6e}")
534
+ lines.append(f" Std: {np.nanstd(value):.6e}")
535
+ lines.append(
536
+ f" SEM range: [{np.nanmin(sem):.6e}, {np.nanmax(sem):.6e}]"
537
+ )
538
+ else:
539
+ # Scalar value
540
+ lines.append(f" {var}: {value:.6e} ± {sem:.6e}")
541
+
542
+ lines.append("=" * 60)
543
+ return "\n".join(lines)
544
+
545
+ def to_dict(self) -> dict:
546
+ """
547
+ Convert results to dictionary for serialization.
548
+
549
+ Outputs all values as [mean, sem] tuples.
550
+ Mode-specific outputs:
551
+ - Standard mode: integrated parameters + mueller_1d + bins_1d
552
+ - PHIPS mode: all 20 DSCS values + detector_angles (nulls for integrated params/mueller)
553
+ """
554
+ result = {
555
+ "converged": bool(self.converged), # Convert numpy bool to Python bool
556
+ "n_orientations": int(
557
+ self.n_orientations
558
+ ), # Convert numpy int to Python int
559
+ "mode": self.mode,
560
+ "is_ensemble": bool(self.is_ensemble), # Convert numpy bool to Python bool
561
+ "warning": self.warning,
562
+ }
563
+
564
+ # Mode-specific output
565
+ if self.mode == "standard":
566
+ # Standard mode: always output all integrated parameters + Mueller
567
+
568
+ # Integrated parameters (always output, even if not converged on)
569
+ for param in ["asymmetry", "scatt", "ext", "albedo"]:
570
+ mean_val = self.values.get(param)
571
+ sem_val = self.sem_values.get(param)
572
+
573
+ if mean_val is not None and sem_val is not None:
574
+ # Convert to Python floats for JSON serialization
575
+ result[param] = [float(mean_val), float(sem_val)]
576
+ else:
577
+ result[param] = None
578
+
579
+ # Mueller 1D (if available)
580
+ if self.mueller_1d is not None:
581
+ # mueller_1d is shape (n_theta, 16)
582
+ # Convert to list of [mean, sem] tuples per angle per element
583
+ mueller_mean = self.mueller_1d
584
+ mueller_sem = self.values.get("mueller_1d_sem")
585
+
586
+ if mueller_sem is not None:
587
+ # Create [mean, sem] format for each position
588
+ mueller_output = []
589
+ for i in range(mueller_mean.shape[0]):
590
+ angle_data = []
591
+ for j in range(mueller_mean.shape[1]):
592
+ # Convert to Python floats for JSON serialization
593
+ angle_data.append(
594
+ [float(mueller_mean[i, j]), float(mueller_sem[i, j])]
595
+ )
596
+ mueller_output.append(angle_data)
597
+ result["mueller_1d"] = mueller_output
598
+ else:
599
+ # No SEM available, just output mean
600
+ result["mueller_1d"] = mueller_mean.tolist()
601
+ else:
602
+ result["mueller_1d"] = None
603
+
604
+ # Bins (if available)
605
+ if self.bins_1d is not None:
606
+ result["bins_1d"] = self.bins_1d.tolist()
607
+ else:
608
+ result["bins_1d"] = None
609
+
610
+ # PHIPS fields are null in standard mode
611
+ result["phips_dscs"] = None
612
+ result["detector_angles"] = None
613
+
614
+ elif self.mode == "phips":
615
+ # PHIPS mode: all 20 DSCS values + detector angles
616
+
617
+ # Integrated parameters are null in PHIPS mode
618
+ result["asymmetry"] = None
619
+ result["scatt"] = None
620
+ result["ext"] = None
621
+ result["albedo"] = None
622
+ result["mueller_1d"] = None
623
+ result["bins_1d"] = None
624
+
625
+ # PHIPS DSCS values (always all 20, with [mean, sem] format)
626
+ phips_mean = self.values.get("phips_dscs")
627
+ phips_sem = self.sem_values.get("phips_dscs")
628
+
629
+ if phips_mean is not None and phips_sem is not None:
630
+ # Shape should be (20,)
631
+ phips_output = []
632
+ for i in range(len(phips_mean)):
633
+ # Convert numpy scalars to Python floats for JSON serialization
634
+ phips_output.append([float(phips_mean[i]), float(phips_sem[i])])
635
+ result["phips_dscs"] = phips_output
636
+ else:
637
+ result["phips_dscs"] = None
638
+
639
+ # Detector angles
640
+ if self.detector_angles is not None:
641
+ result["detector_angles"] = self.detector_angles.tolist()
642
+ else:
643
+ result["detector_angles"] = None
644
+
645
+ # Add convergence history
646
+ if self.convergence_history:
647
+ result["convergence_history"] = [
648
+ {"n_orientations": n, "variable": var, "sem": sem}
649
+ for n, var, sem in self.convergence_history
650
+ ]
651
+
652
+ # Add config if present
653
+ if self.config:
654
+ result["config"] = self.config.to_dict()
655
+
656
+ return result
657
+
658
+ def save(self, path: str) -> None:
659
+ """
660
+ Save results to JSON file.
661
+
662
+ Args:
663
+ path: Output file path (will append .json if not present)
664
+ """
665
+ path = Path(path)
666
+ if path.suffix != ".json":
667
+ path = path.with_suffix(".json")
668
+
669
+ with open(path, "w") as f:
670
+ json.dump(self.to_dict(), f, indent=2)
671
+
672
+ print(f"Results saved to: {path}")
673
+
674
+ @classmethod
675
+ def load(cls, path: str) -> "UnifiedResults":
676
+ """
677
+ Load results from JSON file.
678
+
679
+ Args:
680
+ path: Input file path
681
+
682
+ Returns:
683
+ UnifiedResults instance
684
+ """
685
+ with open(path, "r") as f:
686
+ data = json.load(f)
687
+
688
+ # New tuple format: each parameter is [mean, sem]
689
+ # Extract values and sems from the new format
690
+ values = {}
691
+ sem_values = {}
692
+
693
+ mode = data["mode"]
694
+
695
+ if mode == "standard":
696
+ # Standard mode: extract integrated parameters
697
+ for param in ["asymmetry", "scatt", "ext", "albedo"]:
698
+ param_data = data.get(param)
699
+ if param_data is not None:
700
+ values[param] = param_data[0] # mean
701
+ sem_values[param] = param_data[1] # sem
702
+
703
+ # Mueller 1D (if present)
704
+ mueller_1d_data = data.get("mueller_1d")
705
+ if mueller_1d_data is not None and len(mueller_1d_data) > 0:
706
+ # mueller_1d is stored as list of [mean, sem] tuples per angle per element
707
+ # Need to separate into mean array and sem array
708
+ # But actually, we need to check if it's in the new format or old format
709
+ if isinstance(mueller_1d_data[0][0], list):
710
+ # New format: [[mean, sem], [mean, sem], ...]
711
+ n_theta = len(mueller_1d_data)
712
+ n_elements = len(mueller_1d_data[0])
713
+ mueller_mean = np.zeros((n_theta, n_elements))
714
+ mueller_sem = np.zeros((n_theta, n_elements))
715
+ for i in range(n_theta):
716
+ for j in range(n_elements):
717
+ mueller_mean[i, j] = mueller_1d_data[i][j][0]
718
+ mueller_sem[i, j] = mueller_1d_data[i][j][1]
719
+ mueller_1d = mueller_mean
720
+ values["mueller_1d_sem"] = mueller_sem
721
+ else:
722
+ # Old format: just the mean array
723
+ mueller_1d = np.array(mueller_1d_data)
724
+ else:
725
+ mueller_1d = None
726
+
727
+ # Bins
728
+ bins_1d = data.get("bins_1d")
729
+ if bins_1d is not None:
730
+ bins_1d = np.array(bins_1d)
731
+
732
+ detector_angles = None
733
+
734
+ elif mode == "phips":
735
+ # PHIPS mode: extract DSCS values
736
+ phips_data = data.get("phips_dscs")
737
+ if phips_data is not None:
738
+ # phips_dscs is list of [mean, sem] tuples (20 detectors)
739
+ phips_mean = np.array([d[0] for d in phips_data])
740
+ phips_sem = np.array([d[1] for d in phips_data])
741
+ values["phips_dscs"] = phips_mean
742
+ sem_values["phips_dscs"] = phips_sem
743
+
744
+ # Detector angles
745
+ detector_angles = data.get("detector_angles")
746
+ if detector_angles is not None:
747
+ detector_angles = np.array(detector_angles)
748
+
749
+ mueller_1d = None
750
+ bins_1d = None
751
+
752
+ # Convert convergence history
753
+ convergence_history = None
754
+ if "convergence_history" in data and data["convergence_history"] is not None:
755
+ convergence_history = [
756
+ (h["n_orientations"], h["variable"], h["sem"])
757
+ for h in data["convergence_history"]
758
+ ]
759
+
760
+ return cls(
761
+ converged=data["converged"],
762
+ n_orientations=data["n_orientations"],
763
+ mode=data["mode"],
764
+ is_ensemble=data["is_ensemble"],
765
+ values=values,
766
+ sem_values=sem_values,
767
+ mueller_1d=mueller_1d,
768
+ bins_1d=bins_1d,
769
+ detector_angles=detector_angles,
770
+ convergence_history=convergence_history,
771
+ warning=data.get("warning"),
772
+ config=None, # Config not reconstructed from JSON
773
+ )
774
+
775
+
776
+ # ============================================================================
777
+ # Unified Convergence
778
+ # ============================================================================
779
+
780
+
781
+ class UnifiedConvergence:
782
+ """
783
+ Unified convergence runner that handles all modes.
784
+
785
+ Auto-selects appropriate convergence class based on configuration.
786
+ """
787
+
788
+ def __init__(self, config: ConvergenceConfig):
789
+ """
790
+ Initialize unified convergence.
791
+
792
+ Args:
793
+ config: ConvergenceConfig instance
794
+ """
795
+ self.config = config
796
+ self._convergence = None
797
+ self._setup()
798
+
799
+ def _setup(self):
800
+ """Create appropriate convergence instance based on config."""
801
+ # Determine geometry path for Settings initialization
802
+ # For ensemble mode, use the first .obj file found in the directory
803
+ if self.config.is_ensemble():
804
+ geom_path = Path(self.config.geometry)
805
+ obj_files = sorted(geom_path.glob("*.obj"))
806
+ if not obj_files:
807
+ raise ValueError(
808
+ f"No .obj files found in directory: {self.config.geometry}"
809
+ )
810
+ geom_path_str = str(obj_files[0])
811
+ else:
812
+ geom_path_str = str(self.config.geometry)
813
+
814
+ # Create GOAD settings with all parameters
815
+ settings = goad.Settings(
816
+ geom_path=geom_path_str,
817
+ wavelength=self.config.wavelength,
818
+ particle_refr_index_re=self.config.particle_refr_index_re,
819
+ particle_refr_index_im=self.config.particle_refr_index_im,
820
+ medium_refr_index_re=self.config.medium_refr_index_re,
821
+ medium_refr_index_im=self.config.medium_refr_index_im,
822
+ binning=self.config.mode.get_binning(),
823
+ # Beam tracing parameters
824
+ beam_power_threshold=self.config.beam_tracing.beam_power_threshold,
825
+ beam_area_threshold_fac=self.config.beam_tracing.beam_area_threshold_fac,
826
+ cutoff=self.config.beam_tracing.cutoff,
827
+ max_rec=self.config.beam_tracing.max_rec,
828
+ max_tir=self.config.beam_tracing.max_tir,
829
+ # Geometry transformations
830
+ scale=self.config.geometry_transform.scale,
831
+ # Advanced configuration
832
+ mapping=self.config.advanced_config.mapping,
833
+ coherence=self.config.advanced_config.coherence,
834
+ )
835
+
836
+ # Set optional parameters if provided
837
+ if self.config.seed is not None:
838
+ settings.seed = self.config.seed
839
+
840
+ if self.config.geometry_transform.distortion is not None:
841
+ settings.distortion = self.config.geometry_transform.distortion
842
+
843
+ if self.config.geometry_transform.geom_scale is not None:
844
+ settings.geom_scale = self.config.geometry_transform.geom_scale
845
+
846
+ if self.config.advanced_config.fov_factor is not None:
847
+ settings.fov_factor = self.config.advanced_config.fov_factor
848
+
849
+ # Create convergence instance based on mode
850
+ if isinstance(self.config.mode, StandardMode):
851
+ self._setup_standard(settings)
852
+ elif isinstance(self.config.mode, PHIPSMode):
853
+ self._setup_phips(settings)
854
+ else:
855
+ raise ValueError(f"Unknown mode type: {type(self.config.mode)}")
856
+
857
+ def _setup_standard(self, settings):
858
+ """Setup standard convergence."""
859
+ # Convert target dicts to Convergable instances
860
+ convergables = []
861
+ for target_dict in self.config.convergence_targets:
862
+ convergables.append(Convergable(**target_dict))
863
+
864
+ # Select ensemble or single-geometry convergence
865
+ if self.config.is_ensemble():
866
+ self._convergence = EnsembleConvergence(
867
+ settings=settings,
868
+ convergables=convergables,
869
+ geom_dir=str(self.config.geometry),
870
+ batch_size=self.config.batch_size,
871
+ max_orientations=self.config.max_orientations,
872
+ min_batches=self.config.min_batches,
873
+ mueller_1d=self.config.mueller_1d,
874
+ mueller_2d=False,
875
+ )
876
+ else:
877
+ self._convergence = Convergence(
878
+ settings=settings,
879
+ convergables=convergables,
880
+ batch_size=self.config.batch_size,
881
+ max_orientations=self.config.max_orientations,
882
+ min_batches=self.config.min_batches,
883
+ mueller_1d=self.config.mueller_1d,
884
+ mueller_2d=False,
885
+ )
886
+
887
+ def _setup_phips(self, settings):
888
+ """Setup PHIPS convergence."""
889
+ # PHIPS only has one convergable target
890
+ target_dict = self.config.convergence_targets[0]
891
+
892
+ # Remove 'variable' if present (not used in PHIPS)
893
+ target_dict = {k: v for k, v in target_dict.items() if k != "variable"}
894
+
895
+ convergable = PHIPSConvergable(**target_dict)
896
+
897
+ # Select ensemble or single-geometry convergence
898
+ if self.config.is_ensemble():
899
+ self._convergence = PHIPSEnsembleConvergence(
900
+ settings=settings,
901
+ convergable=convergable,
902
+ geom_dir=str(self.config.geometry),
903
+ batch_size=self.config.batch_size,
904
+ max_orientations=self.config.max_orientations,
905
+ min_batches=self.config.min_batches,
906
+ )
907
+ else:
908
+ self._convergence = PHIPSConvergence(
909
+ settings=settings,
910
+ convergable=convergable,
911
+ batch_size=self.config.batch_size,
912
+ max_orientations=self.config.max_orientations,
913
+ min_batches=self.config.min_batches,
914
+ )
915
+
916
+ def run(self) -> UnifiedResults:
917
+ """
918
+ Run convergence study.
919
+
920
+ Returns:
921
+ UnifiedResults instance with ALL parameters extracted (not just converged ones)
922
+ """
923
+ # Run convergence
924
+ results = self._convergence.run()
925
+
926
+ # Extract ALL integrated parameters (not just converged ones)
927
+ all_values = {}
928
+ all_sems = {}
929
+
930
+ if isinstance(self.config.mode, StandardMode):
931
+ # Standard mode: extract all 4 integrated parameters
932
+ for param in ["asymmetry", "scatt", "ext", "albedo"]:
933
+ if param in results.values:
934
+ # Already computed (was a convergence target)
935
+ all_values[param] = results.values[param]
936
+ all_sems[param] = results.sem_values[param]
937
+ else:
938
+ # Not a convergence target - compute it now
939
+ mean, sem = self._convergence._calculate_mean_and_sem(param)
940
+ all_values[param] = mean
941
+ all_sems[param] = sem
942
+
943
+ # Also include any Mueller elements that were converged on
944
+ for key in results.values:
945
+ if key.startswith("S"): # Mueller element
946
+ all_values[key] = results.values[key]
947
+ all_sems[key] = results.sem_values[key]
948
+
949
+ # Include mueller_1d_sem if available (for full SEM output)
950
+ if "mueller_1d_sem" in results.values:
951
+ all_values["mueller_1d_sem"] = results.values["mueller_1d_sem"]
952
+
953
+ elif isinstance(self.config.mode, PHIPSMode):
954
+ # PHIPS mode: extract all 20 DSCS values
955
+ if "phips_dscs" in results.values:
956
+ all_values["phips_dscs"] = results.values["phips_dscs"]
957
+ all_sems["phips_dscs"] = results.sem_values["phips_dscs"]
958
+ else:
959
+ # This shouldn't happen in PHIPS mode, but handle it
960
+ all_values["phips_dscs"] = None
961
+ all_sems["phips_dscs"] = None
962
+
963
+ # Get bins_1d or detector_angles based on mode
964
+ bins_1d = None
965
+ detector_angles = None
966
+
967
+ if isinstance(self.config.mode, StandardMode):
968
+ # Extract theta bins from Mueller 1D if available
969
+ if results.mueller_1d is not None:
970
+ # bins_1d should be theta values
971
+ # For simple binning: np.linspace(0, 180, n_theta)
972
+ # For custom binning: we need to extract from the binning scheme
973
+ binning = self.config.mode.get_binning()
974
+ if hasattr(binning, "get_theta_bins"):
975
+ bins_1d = binning.get_theta_bins()
976
+ else:
977
+ # Fallback: assume uniform spacing
978
+ n_theta = results.mueller_1d.shape[0]
979
+ bins_1d = np.linspace(0, 180, n_theta)
980
+
981
+ elif isinstance(self.config.mode, PHIPSMode):
982
+ # PHIPS detector angles (18° to 170°, 20 detectors)
983
+ detector_angles = np.array(
984
+ [
985
+ 18.0,
986
+ 26.0,
987
+ 34.0,
988
+ 42.0,
989
+ 50.0,
990
+ 58.0,
991
+ 66.0,
992
+ 74.0,
993
+ 82.0,
994
+ 90.0,
995
+ 98.0,
996
+ 106.0,
997
+ 114.0,
998
+ 122.0,
999
+ 130.0,
1000
+ 138.0,
1001
+ 146.0,
1002
+ 154.0,
1003
+ 162.0,
1004
+ 170.0,
1005
+ ]
1006
+ )
1007
+
1008
+ # Convert to UnifiedResults
1009
+ unified = UnifiedResults(
1010
+ converged=results.converged,
1011
+ n_orientations=results.n_orientations,
1012
+ mode=self.config.mode.get_mode_name(),
1013
+ is_ensemble=self.config.is_ensemble(),
1014
+ values=all_values,
1015
+ sem_values=all_sems,
1016
+ mueller_1d=results.mueller_1d,
1017
+ bins_1d=bins_1d,
1018
+ detector_angles=detector_angles,
1019
+ convergence_history=results.convergence_history,
1020
+ warning=results.warning,
1021
+ config=self.config,
1022
+ )
1023
+
1024
+ return unified
1025
+
1026
+
1027
+ # ============================================================================
1028
+ # Convenience Functions
1029
+ # ============================================================================
1030
+
1031
+
1032
+ def run_convergence(
1033
+ geometry: Union[str, Path],
1034
+ targets: Union[str, List[str], List[dict]],
1035
+ tolerance: float = 0.05,
1036
+ tolerance_type: str = "relative",
1037
+ mode: Union[str, ConvergenceMode] = "auto",
1038
+ **kwargs,
1039
+ ) -> UnifiedResults:
1040
+ """
1041
+ Run convergence study with unified interface.
1042
+
1043
+ This is the primary entry point for most users.
1044
+
1045
+ Args:
1046
+ geometry: Path to .obj file or directory of .obj files
1047
+ targets: What to converge on:
1048
+ - Single string: "asymmetry", "scatt", "S11", "phips_dscs"
1049
+ - List of strings: ["asymmetry", "scatt"]
1050
+ - List of dicts: [{"variable": "S11", "tolerance": 0.1, ...}]
1051
+ tolerance: Default tolerance (can be overridden per target)
1052
+ tolerance_type: "relative" or "absolute"
1053
+ mode: ConvergenceMode instance, or string "auto"/"standard"/"phips"
1054
+ **kwargs: Additional settings:
1055
+ # Optical settings
1056
+ - wavelength: Wavelength in microns (default: 0.532)
1057
+ - particle_refr_index_re: Real part of particle refractive index
1058
+ - particle_refr_index_im: Imaginary part of particle refractive index
1059
+ - medium_refr_index_re: Real part of medium refractive index
1060
+ - medium_refr_index_im: Imaginary part of medium refractive index
1061
+
1062
+ # Convergence parameters
1063
+ - batch_size: Orientations per batch (default: 24)
1064
+ - max_orientations: Maximum orientations (default: 100,000)
1065
+ - min_batches: Minimum batches before convergence (default: 10)
1066
+ - mueller_1d: Compute 1D Mueller matrix (default: True, standard mode only)
1067
+
1068
+ # Mode settings
1069
+ - phips_bins_file: Path to PHIPS bins TOML (required if mode="phips")
1070
+ - n_theta: Number of theta bins for standard mode (default: 181)
1071
+ - n_phi: Number of phi bins for standard mode (default: 181)
1072
+
1073
+ # Beam tracing parameters
1074
+ - beam_power_threshold: Beam power threshold (default: 0.005)
1075
+ - beam_area_threshold_fac: Beam area threshold factor (default: 0.001)
1076
+ - cutoff: Ray power cutoff (default: 0.999)
1077
+ - max_rec: Max recursion depth (default: 10)
1078
+ - max_tir: Max TIR bounces (default: 10)
1079
+
1080
+ # Geometry transformations
1081
+ - scale: Problem scaling factor - scales entire problem including geometry,
1082
+ wavelength, and beam area thresholds (default: 1.0)
1083
+ - distortion: Geometry distortion factor (optional)
1084
+ - geom_scale: Per-axis geometry scaling [x, y, z] - scales only geometry
1085
+ in each dimension independently (optional)
1086
+
1087
+ # Advanced configuration
1088
+ - mapping: DSCS mapping scheme (default: goad.Mapping.ApertureDiffraction)
1089
+ - coherence: Enable coherent scattering (default: True)
1090
+ - fov_factor: Field of view factor (optional)
1091
+
1092
+ # Reproducibility
1093
+ - seed: Random seed for orientations (optional)
1094
+
1095
+ Returns:
1096
+ UnifiedResults object
1097
+
1098
+ Examples:
1099
+ # Simple: converge asymmetry for single geometry
1100
+ results = run_convergence("hex.obj", "asymmetry", tolerance=0.01)
1101
+
1102
+ # Multiple targets, ensemble
1103
+ results = run_convergence(
1104
+ "./test_obj",
1105
+ ["asymmetry", "scatt"],
1106
+ tolerance=0.05,
1107
+ batch_size=48
1108
+ )
1109
+
1110
+ # PHIPS detectors
1111
+ results = run_convergence(
1112
+ "./test_obj",
1113
+ "phips_dscs",
1114
+ tolerance=0.25,
1115
+ mode="phips",
1116
+ phips_bins_file="phips_bins_edges.toml"
1117
+ )
1118
+
1119
+ # Mueller elements with specific bins
1120
+ results = run_convergence(
1121
+ "hex.obj",
1122
+ [{"variable": "S11", "tolerance": 0.1, "theta_indices": [180]}],
1123
+ batch_size=12
1124
+ )
1125
+
1126
+ # Advanced: custom beam tracing and geometry scaling
1127
+ results = run_convergence(
1128
+ "complex_particle.obj",
1129
+ "asymmetry",
1130
+ max_rec=200,
1131
+ max_tir=150,
1132
+ cutoff=0.0001,
1133
+ scale=2.0,
1134
+ seed=42
1135
+ )
1136
+ """
1137
+ # Extract beam tracing parameters from kwargs
1138
+ beam_tracing = BeamTracingConfig(
1139
+ beam_power_threshold=kwargs.pop("beam_power_threshold", 0.005),
1140
+ beam_area_threshold_fac=kwargs.pop("beam_area_threshold_fac", 0.001),
1141
+ cutoff=kwargs.pop("cutoff", 0.999),
1142
+ max_rec=kwargs.pop("max_rec", 10),
1143
+ max_tir=kwargs.pop("max_tir", 10),
1144
+ )
1145
+
1146
+ # Extract geometry transform parameters
1147
+ geometry_transform = GeometryTransformConfig(
1148
+ scale=kwargs.pop("scale", 1.0),
1149
+ distortion=kwargs.pop("distortion", None),
1150
+ geom_scale=kwargs.pop("geom_scale", None),
1151
+ )
1152
+
1153
+ # Extract advanced configuration parameters
1154
+ advanced_config = AdvancedConfig(
1155
+ mapping=kwargs.pop("mapping", "ApertureDiffraction"),
1156
+ coherence=kwargs.pop("coherence", True),
1157
+ fov_factor=kwargs.pop("fov_factor", None),
1158
+ )
1159
+
1160
+ # Extract seed
1161
+ seed = kwargs.pop("seed", None)
1162
+
1163
+ # Normalize targets to list of dicts
1164
+ target_dicts = _normalize_targets(targets, tolerance, tolerance_type)
1165
+
1166
+ # Auto-detect or create mode
1167
+ if isinstance(mode, str):
1168
+ if mode == "auto":
1169
+ mode = _auto_detect_mode(target_dicts, kwargs)
1170
+ elif mode == "standard":
1171
+ n_theta = kwargs.pop("n_theta", 181)
1172
+ n_phi = kwargs.pop("n_phi", 181)
1173
+ mode = StandardMode(n_theta=n_theta, n_phi=n_phi)
1174
+ elif mode == "phips":
1175
+ phips_bins_file = kwargs.pop("phips_bins_file", None)
1176
+ if phips_bins_file is None:
1177
+ raise ValueError("phips_bins_file must be specified when mode='phips'")
1178
+ mode = PHIPSMode(bins_file=phips_bins_file)
1179
+ else:
1180
+ raise ValueError(
1181
+ f"Invalid mode string '{mode}'. Must be 'auto', 'standard', or 'phips'"
1182
+ )
1183
+
1184
+ # Build config with new parameters
1185
+ config = ConvergenceConfig(
1186
+ geometry=geometry,
1187
+ mode=mode,
1188
+ convergence_targets=target_dicts,
1189
+ beam_tracing=beam_tracing,
1190
+ geometry_transform=geometry_transform,
1191
+ advanced_config=advanced_config,
1192
+ seed=seed,
1193
+ **kwargs, # Remaining kwargs (wavelength, particle_refr_index_re, etc.)
1194
+ )
1195
+
1196
+ # Run
1197
+ conv = UnifiedConvergence(config)
1198
+ return conv.run()
1199
+
1200
+
1201
+ def run_convergence_sweep(
1202
+ configs: List[ConvergenceConfig], parallel: bool = False, n_jobs: int = -1
1203
+ ) -> List[UnifiedResults]:
1204
+ """
1205
+ Run multiple convergence studies (for parameter sweeps).
1206
+
1207
+ Args:
1208
+ configs: List of ConvergenceConfig instances
1209
+ parallel: Run in parallel if True (not yet implemented)
1210
+ n_jobs: Number of parallel jobs (-1 = all cores)
1211
+
1212
+ Returns:
1213
+ List of UnifiedResults
1214
+
1215
+ Example:
1216
+ # Wavelength sweep
1217
+ wavelengths = [0.532, 0.633, 0.780]
1218
+ configs = [
1219
+ ConvergenceConfig(
1220
+ geometry="./test_obj",
1221
+ mode=PHIPSMode("phips_bins_edges.toml"),
1222
+ convergence_targets=[{"tolerance": 0.25, "tolerance_type": "relative"}],
1223
+ wavelength=wl
1224
+ )
1225
+ for wl in wavelengths
1226
+ ]
1227
+ results = run_convergence_sweep(configs)
1228
+ """
1229
+ if parallel:
1230
+ raise NotImplementedError("Parallel execution not yet implemented")
1231
+
1232
+ results = []
1233
+ for i, config in enumerate(configs):
1234
+ print(f"\n{'=' * 60}")
1235
+ print(f"Running convergence {i + 1}/{len(configs)}")
1236
+ print(f"{'=' * 60}")
1237
+
1238
+ conv = UnifiedConvergence(config)
1239
+ result = conv.run()
1240
+ results.append(result)
1241
+
1242
+ return results
1243
+
1244
+
1245
+ # ============================================================================
1246
+ # Helper Functions
1247
+ # ============================================================================
1248
+
1249
+
1250
+ def _normalize_targets(
1251
+ targets: Union[str, List[str], List[dict]],
1252
+ default_tolerance: float,
1253
+ default_tolerance_type: str,
1254
+ ) -> List[dict]:
1255
+ """
1256
+ Normalize targets to list of dicts.
1257
+
1258
+ Args:
1259
+ targets: Target specification (string, list of strings, or list of dicts)
1260
+ default_tolerance: Default tolerance value
1261
+ default_tolerance_type: Default tolerance type
1262
+
1263
+ Returns:
1264
+ List of target dicts
1265
+ """
1266
+ # Convert single string to list
1267
+ if isinstance(targets, str):
1268
+ targets = [targets]
1269
+
1270
+ # Convert to list of dicts
1271
+ target_dicts = []
1272
+ for target in targets:
1273
+ if isinstance(target, str):
1274
+ # String target - apply defaults
1275
+ target_dict = {
1276
+ "variable": target,
1277
+ "tolerance": default_tolerance,
1278
+ "tolerance_type": default_tolerance_type,
1279
+ }
1280
+ target_dicts.append(target_dict)
1281
+ elif isinstance(target, dict):
1282
+ # Dict target - fill in missing defaults
1283
+ target_dict = {
1284
+ "tolerance": default_tolerance,
1285
+ "tolerance_type": default_tolerance_type,
1286
+ **target, # Override with provided values
1287
+ }
1288
+ target_dicts.append(target_dict)
1289
+ else:
1290
+ raise TypeError(
1291
+ f"Invalid target type: {type(target)}. Must be str or dict."
1292
+ )
1293
+
1294
+ return target_dicts
1295
+
1296
+
1297
+ def _auto_detect_mode(target_dicts: List[dict], kwargs: dict) -> ConvergenceMode:
1298
+ """
1299
+ Auto-detect convergence mode from targets.
1300
+
1301
+ Args:
1302
+ target_dicts: List of target dictionaries
1303
+ kwargs: Additional keyword arguments
1304
+
1305
+ Returns:
1306
+ ConvergenceMode instance
1307
+ """
1308
+ # Check if any target is "phips_dscs" or if phips_bins_file is specified
1309
+ has_phips_target = any(
1310
+ target.get("variable") == "phips_dscs" for target in target_dicts
1311
+ )
1312
+ has_phips_bins = "phips_bins_file" in kwargs
1313
+
1314
+ if has_phips_target or has_phips_bins:
1315
+ # PHIPS mode
1316
+ phips_bins_file = kwargs.pop("phips_bins_file", None)
1317
+ if phips_bins_file is None:
1318
+ raise ValueError(
1319
+ "Auto-detected PHIPS mode but phips_bins_file not specified. "
1320
+ "Please provide phips_bins_file or set mode='phips' explicitly."
1321
+ )
1322
+ return PHIPSMode(bins_file=phips_bins_file)
1323
+ else:
1324
+ # Standard mode
1325
+ n_theta = kwargs.pop("n_theta", 181)
1326
+ n_phi = kwargs.pop("n_phi", 181)
1327
+ return StandardMode(n_theta=n_theta, n_phi=n_phi)