goad-py 0.5.1__cp38-abi3-musllinux_1_2_i686.whl → 0.5.4__cp38-abi3-musllinux_1_2_i686.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of goad-py might be problematic. Click here for more details.

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