goad-py 0.8.0__cp38-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of goad-py might be problematic. Click here for more details.
- goad_py/__init__.py +51 -0
- goad_py/_goad_py.pyd +0 -0
- goad_py/convergence.py +833 -0
- goad_py/convergence_display.py +532 -0
- goad_py/goad_py.pyi +453 -0
- goad_py/phips_convergence.py +614 -0
- goad_py/unified_convergence.py +1337 -0
- goad_py-0.8.0.dist-info/METADATA +99 -0
- goad_py-0.8.0.dist-info/RECORD +10 -0
- goad_py-0.8.0.dist-info/WHEEL +4 -0
|
@@ -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)
|