goad-py 0.6.0__cp38-abi3-musllinux_1_2_aarch64.whl → 0.7.0__cp38-abi3-musllinux_1_2_aarch64.whl

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

Potentially problematic release.


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

goad_py/_goad_py.abi3.so CHANGED
Binary file
goad_py/convergence.py CHANGED
@@ -1,10 +1,20 @@
1
- from dataclasses import dataclass
2
- from typing import List, Dict, Optional, Tuple
3
- import numpy as np
4
- from . import _goad_py as goad
1
+ import contextlib
5
2
  import os
6
3
  import random
4
+ import sys
5
+ from dataclasses import dataclass
7
6
  from pathlib import Path
7
+ from typing import Dict, List, Optional, Tuple
8
+
9
+ import numpy as np
10
+ from rich.console import Console
11
+
12
+ from . import _goad_py as goad
13
+ from .convergence_display import (
14
+ ArrayConvergenceVariable,
15
+ ConvergenceDisplay,
16
+ ConvergenceVariable,
17
+ )
8
18
 
9
19
 
10
20
  @dataclass
@@ -96,6 +106,8 @@ class Convergence:
96
106
  mueller_2d: Whether to collect 2D Mueller matrices
97
107
  """
98
108
  self.settings = settings
109
+ # Enable quiet mode to suppress Rust progress bars
110
+ self.settings.quiet = True
99
111
  self.convergables = convergables
100
112
  self.batch_size = batch_size
101
113
  self.max_orientations = max_orientations
@@ -129,6 +141,39 @@ class Convergence:
129
141
  self.mueller_1d_sum = None
130
142
  self.mueller_2d_sum = None
131
143
 
144
+ # Rich console
145
+ self._console = Console()
146
+
147
+ # Create display variables for the new display system
148
+ display_variables = []
149
+ for conv in self.convergables:
150
+ if conv.is_mueller():
151
+ display_variables.append(
152
+ ArrayConvergenceVariable(
153
+ name=conv.variable,
154
+ tolerance=conv.tolerance,
155
+ tolerance_type=conv.tolerance_type,
156
+ indices=conv.theta_indices,
157
+ )
158
+ )
159
+ else:
160
+ display_variables.append(
161
+ ConvergenceVariable(
162
+ name=conv.variable,
163
+ tolerance=conv.tolerance,
164
+ tolerance_type=conv.tolerance_type,
165
+ )
166
+ )
167
+
168
+ # Initialize display system
169
+ self._display = ConvergenceDisplay(
170
+ variables=display_variables,
171
+ batch_size=self.batch_size,
172
+ min_batches=self.min_batches,
173
+ convergence_type=self._get_convergence_type(),
174
+ console=self._console,
175
+ )
176
+
132
177
  def _update_statistics(self, results: goad.Results, batch_size: int):
133
178
  """Update statistics with new batch results.
134
179
 
@@ -425,144 +470,84 @@ class Convergence:
425
470
  converged_status = self._check_convergence()
426
471
  return all(converged_status.values())
427
472
 
428
- def _print_progress(self, iteration: int):
429
- """Print convergence progress.
473
+ def _get_convergence_type(self) -> str:
474
+ """Get the convergence type name for display."""
475
+ class_name = self.__class__.__name__
476
+ if class_name == "EnsembleConvergence":
477
+ return "Ensemble"
478
+ elif class_name == "Convergence":
479
+ return "Standard"
480
+ else:
481
+ return class_name
482
+
483
+ def _get_next_geometry(self, iteration: int) -> Tuple[str, Optional[str]]:
484
+ """Hook method to get geometry for next batch.
430
485
 
431
486
  Args:
432
487
  iteration: Current iteration number
488
+
489
+ Returns:
490
+ Tuple of (geom_path, optional_display_info)
491
+ - geom_path: Path to geometry file to use
492
+ - optional_display_info: Optional string to display (e.g., "Geometry: hex.obj")
433
493
  """
434
- # Calculate minimum required orientations
435
- min_required = self.min_batches * self.batch_size
494
+ # Default implementation: use fixed geometry from settings
495
+ return self.settings.geom_path, None
436
496
 
437
- # Show progress with min orientations requirement
438
- if self.n_orientations < min_required:
439
- print(
440
- f"\nIteration {iteration} ({self.n_orientations}/{min_required} orientations, min not reached):"
441
- )
442
- else:
443
- print(
444
- f"\nIteration {iteration} ({self.n_orientations} orientations, min {min_required} reached):"
445
- )
497
+ def _handle_geometry_error(self, error: Exception, geom_path: str) -> bool:
498
+ """Hook method to handle geometry loading errors.
446
499
 
447
- converged_status = self._check_convergence()
500
+ Args:
501
+ error: The exception that occurred
502
+ geom_path: Path to the geometry that failed
503
+
504
+ Returns:
505
+ True to skip this geometry and continue, False to raise the error
506
+ """
507
+ # Default implementation: re-raise error (fail fast for single geometry)
508
+ return False
509
+
510
+ def _get_theta_bins(self, variable: str) -> Optional[np.ndarray]:
511
+ """Get theta bins for Mueller elements from batch data."""
512
+ for batch in self.batch_data:
513
+ if "mueller_theta_bins" in batch:
514
+ return batch["mueller_theta_bins"]
448
515
 
516
+ # Fallback: infer from array length
517
+ mean_array, _ = self._calculate_mean_and_sem_array(variable)
518
+ if len(mean_array) > 0:
519
+ return np.linspace(0, 180, len(mean_array))
520
+
521
+ return None
522
+
523
+ def _update_convergence_history(self):
524
+ """Update convergence history with current SEM values."""
449
525
  for conv in self.convergables:
450
526
  if conv.is_mueller():
451
- # Mueller element - show worst-case theta bin
527
+ # Mueller element - track worst SEM
452
528
  mean_array, sem_array = self._calculate_mean_and_sem_array(
453
529
  conv.variable
454
530
  )
455
-
456
- if len(mean_array) == 0:
457
- print(f" {conv.variable:<10}: No data yet")
458
- continue
459
-
460
- # Get theta bins from results (assuming we have access to bins_1d)
461
- if hasattr(self, "settings") and hasattr(self.settings, "binning"):
462
- # We'll get theta values from the first batch's mueller_1d if available
463
- theta_bins = None
464
- for batch in self.batch_data:
465
- if "mueller_theta_bins" in batch:
466
- theta_bins = batch["mueller_theta_bins"]
467
- break
468
- if theta_bins is None:
469
- theta_bins = np.arange(len(mean_array))
470
- else:
471
- theta_bins = np.arange(len(mean_array))
472
-
473
- # Calculate relative SEM for each theta
474
- if conv.tolerance_type == "relative":
475
- relative_sem_array = np.where(
476
- mean_array != 0, sem_array / np.abs(mean_array), float("inf")
477
- )
478
- worst_idx = np.argmax(relative_sem_array)
479
- worst_sem = relative_sem_array[worst_idx]
480
- target_str = f"{conv.tolerance * 100:.1f}%"
481
- current_str = f"{worst_sem * 100:.2f}%"
482
- else:
483
- worst_idx = np.argmax(sem_array)
484
- worst_sem = sem_array[worst_idx]
485
- target_str = f"{conv.tolerance}"
486
- current_str = f"{worst_sem:.4g}"
487
-
488
- worst_theta = theta_bins[worst_idx]
489
- worst_mean = mean_array[worst_idx]
490
-
491
- # Count converged bins (either all or specified indices)
492
- if conv.theta_indices is not None:
493
- # Only checking specific bins
494
- indices = [i for i in conv.theta_indices if i < len(mean_array)]
531
+ if len(mean_array) > 0:
495
532
  if conv.tolerance_type == "relative":
496
- converged_bins = np.sum(
497
- relative_sem_array[indices] < conv.tolerance
533
+ relative_sem_array = np.where(
534
+ mean_array != 0,
535
+ sem_array / np.abs(mean_array),
536
+ float("inf"),
498
537
  )
538
+ worst_sem = np.max(relative_sem_array)
499
539
  else:
500
- converged_bins = np.sum(sem_array[indices] < conv.tolerance)
501
- total_bins = len(indices)
502
- bin_desc = (
503
- f"θ={[theta_bins[i] for i in indices]}"
504
- if len(indices) <= 3
505
- else f"{len(indices)} bins"
506
- )
507
- else:
508
- # Checking all bins
509
- if conv.tolerance_type == "relative":
510
- converged_bins = np.sum(relative_sem_array < conv.tolerance)
511
- else:
512
- converged_bins = np.sum(sem_array < conv.tolerance)
513
- total_bins = len(mean_array)
514
- bin_desc = f"{total_bins} bins"
515
-
516
- status = "✓" if converged_status[conv.variable] else "❌"
517
-
518
- # Print Mueller convergence info
519
- if conv.theta_indices is not None and len(conv.theta_indices) <= 3:
520
- # For small number of specific bins, show them explicitly
521
- print(
522
- f" {conv.variable:<10}: {converged_bins}/{total_bins} {bin_desc} | "
523
- f"Worst θ={worst_theta:.1f}°: {worst_mean:.4g} | SEM: {current_str} (target: {target_str}) {status}"
524
- )
525
- else:
526
- # For many bins, use standard format
527
- print(
528
- f" {conv.variable:<10}: {converged_bins}/{total_bins} bins converged | "
529
- f"Worst θ={worst_theta:.1f}°: {worst_mean:.4g} | SEM: {current_str} (target: {target_str}) {status}"
530
- )
540
+ worst_sem = np.max(sem_array)
531
541
 
532
- # Add worst SEM to convergence history
533
- self.convergence_history.append(
534
- (self.n_orientations, conv.variable, worst_sem)
535
- )
542
+ self.convergence_history.append(
543
+ (self.n_orientations, conv.variable, worst_sem)
544
+ )
536
545
  else:
537
546
  # Scalar variable
538
547
  mean, sem = self._calculate_mean_and_sem(conv.variable)
548
+ if conv.tolerance_type == "relative" and mean != 0:
549
+ sem = sem / abs(mean)
539
550
 
540
- # Calculate 95% CI
541
- ci_lower = mean - 1.96 * sem
542
- ci_upper = mean + 1.96 * sem
543
-
544
- # Format based on tolerance type
545
- if conv.tolerance_type == "relative":
546
- if mean != 0:
547
- relative_sem = sem / abs(mean)
548
- target_str = f"{conv.tolerance * 100:.1f}%"
549
- current_str = f"{relative_sem * 100:.2f}%"
550
- else:
551
- target_str = f"{conv.tolerance} (abs, mean=0)"
552
- current_str = f"{sem:.4g}"
553
- else:
554
- target_str = f"{conv.tolerance}"
555
- current_str = f"{sem:.4g}"
556
-
557
- # Status indicator
558
- status = "✓" if converged_status[conv.variable] else "❌"
559
-
560
- # Print line with mean, SEM, CI, and convergence status
561
- print(
562
- f" {conv.variable:<10}: {mean:.6f} ± {sem:.6f} [{ci_lower:.6f}, {ci_upper:.6f}] | SEM: {current_str} (target: {target_str}) {status}"
563
- )
564
-
565
- # Add to convergence history
566
551
  self.convergence_history.append(
567
552
  (self.n_orientations, conv.variable, sem)
568
553
  )
@@ -577,30 +562,99 @@ class Convergence:
577
562
  converged = False
578
563
  warning = None
579
564
 
580
- while not converged and self.n_orientations < self.max_orientations:
581
- iteration += 1
582
-
583
- # Determine batch size for this iteration
584
- remaining = self.max_orientations - self.n_orientations
585
- batch_size = min(self.batch_size, remaining)
586
-
587
- # Set batch size
588
- orientations = goad.create_uniform_orientation(batch_size)
589
-
590
- # Set the orientations for the settings
591
- self.settings.orientation = orientations
592
-
593
- mp = goad.MultiProblem(self.settings)
594
- mp.py_solve()
595
-
596
- # Update statistics
597
- self._update_statistics(mp.results, batch_size)
598
-
599
- # Print progress
600
- self._print_progress(iteration)
565
+ # Create Live context for smooth updating display
566
+ with self._display.create_live_context() as live:
567
+ # Show initial display before first batch
568
+ initial_display = self._display.build_display(
569
+ iteration=0,
570
+ n_orientations=self.n_orientations,
571
+ get_stats=self._calculate_mean_and_sem,
572
+ get_array_stats=self._calculate_mean_and_sem_array,
573
+ get_bin_labels=self._get_theta_bins,
574
+ power_ratio=None,
575
+ geom_info=None,
576
+ )
577
+ live.update(initial_display)
578
+
579
+ while not converged and self.n_orientations < self.max_orientations:
580
+ iteration += 1
581
+
582
+ # Get geometry for this batch (hook method - can be overridden)
583
+ geom_path, geom_info = self._get_next_geometry(iteration)
584
+
585
+ # Determine batch size for this iteration
586
+ remaining = self.max_orientations - self.n_orientations
587
+ batch_size = min(self.batch_size, remaining)
588
+
589
+ # Set batch size
590
+ orientations = goad.create_uniform_orientation(batch_size)
591
+
592
+ # Set the geometry path and orientations for the settings
593
+ self.settings.geom_path = geom_path
594
+ self.settings.orientation = orientations
595
+
596
+ # Run MultiProblem with error handling for bad geometries
597
+ # Suppress Rust progress bars by redirecting stderr at fd level
598
+ try:
599
+ mp = goad.MultiProblem(self.settings)
600
+ # Redirect stderr file descriptor to suppress Rust progress bars
601
+ stderr_fd = sys.stderr.fileno()
602
+ with open(os.devnull, "w") as devnull:
603
+ old_stderr_fd = os.dup(stderr_fd)
604
+ try:
605
+ os.dup2(devnull.fileno(), stderr_fd)
606
+ mp.py_solve()
607
+ finally:
608
+ os.dup2(old_stderr_fd, stderr_fd)
609
+ os.close(old_stderr_fd)
610
+ except Exception as e:
611
+ # Geometry loading failed (bad faces, degenerate geometry, etc.)
612
+ # Check if subclass wants to handle this error (e.g., skip for ensemble)
613
+ if self._handle_geometry_error(e, geom_path):
614
+ # Skip this geometry and continue
615
+ continue
616
+ else:
617
+ # For single-geometry convergence, we can't skip - must raise error
618
+ error_msg = (
619
+ f"Failed to initialize MultiProblem with geometry '{geom_path}': {e}\n"
620
+ f"Please check geometry file for:\n"
621
+ f" - Degenerate faces (area = 0)\n"
622
+ f" - Non-planar geometry\n"
623
+ f" - Faces that are too small\n"
624
+ f" - Invalid mesh topology\n"
625
+ f" - Geometry file corruption"
626
+ )
627
+ raise type(e)(error_msg) from e
628
+
629
+ # Update statistics
630
+ self._update_statistics(mp.results, batch_size)
631
+
632
+ # Extract power ratio from results
633
+ try:
634
+ powers = mp.results.powers # It's a property, not a method
635
+ power_in = powers.get("input", 1.0)
636
+ power_out = powers.get("output", 0.0)
637
+ power_ratio = power_out / power_in if power_in > 0 else 0.0
638
+ except Exception:
639
+ power_ratio = None
640
+
641
+ # Update convergence history
642
+ self._update_convergence_history()
643
+
644
+ # Update live display with optional geometry info
645
+ display = self._display.build_display(
646
+ iteration=iteration,
647
+ n_orientations=self.n_orientations,
648
+ get_stats=self._calculate_mean_and_sem,
649
+ get_array_stats=self._calculate_mean_and_sem_array,
650
+ get_bin_labels=self._get_theta_bins,
651
+ power_ratio=power_ratio,
652
+ geom_info=geom_info,
653
+ )
654
+ live.update(display)
601
655
 
602
- # Check convergence
603
- converged = self._all_converged()
656
+ # Check convergence
657
+ converged = self._all_converged()
604
658
 
605
659
  # Prepare final results
606
660
  if converged:
@@ -728,155 +782,49 @@ class EnsembleConvergence(Convergence):
728
782
  mueller_2d=mueller_2d,
729
783
  )
730
784
 
731
- def run(self) -> ConvergenceResults:
732
- """Run the ensemble convergence study.
785
+ # Track skipped geometries for error handling
786
+ self.skipped_geometries = []
733
787
 
734
- Each batch iteration randomly selects a geometry file from the
735
- ensemble directory before running the orientation averaging.
788
+ def _get_next_geometry(self, iteration: int) -> Tuple[str, Optional[str]]:
789
+ """Override to randomly select geometry from ensemble.
790
+
791
+ Args:
792
+ iteration: Current iteration number
736
793
 
737
794
  Returns:
738
- ConvergenceResults containing final ensemble-averaged values
795
+ Tuple of (geom_path, display_info)
739
796
  """
740
- iteration = 0
741
- converged = False
742
- warning = None
743
-
744
- while not converged and self.n_orientations < self.max_orientations:
745
- iteration += 1
746
-
747
- # Randomly select a geometry file for this batch
748
- geom_file = random.choice(self.geom_files)
749
- geom_path = os.path.join(self.geom_dir, geom_file)
750
-
751
- # Determine batch size for this iteration
752
- remaining = self.max_orientations - self.n_orientations
753
- batch_size = min(self.batch_size, remaining)
754
-
755
- # Create orientations for this batch
756
- orientations = goad.create_uniform_orientation(batch_size)
757
-
758
- # Update settings with selected geometry and orientations
759
- self.settings.geom_path = geom_path
760
- self.settings.orientation = orientations
761
-
762
- # Run MultiProblem with selected geometry
763
- mp = goad.MultiProblem(self.settings)
764
- mp.py_solve()
765
-
766
- # Update statistics
767
- self._update_statistics(mp.results, batch_size)
768
-
769
- # Print progress (with geometry info)
770
- min_required = self.min_batches * self.batch_size
771
- if self.n_orientations < min_required:
772
- print(
773
- f"\nIteration {iteration} ({self.n_orientations}/{min_required} orientations, min not reached) - Geometry: {geom_file}"
774
- )
775
- else:
776
- print(
777
- f"\nIteration {iteration} ({self.n_orientations} orientations, min {min_required} reached) - Geometry: {geom_file}"
778
- )
779
- self._print_progress_without_header(iteration)
780
-
781
- # Check convergence
782
- converged = self._all_converged()
783
-
784
- # Prepare final results
785
- if converged:
786
- print(f"\nConverged after {self.n_orientations} orientations.")
787
- else:
788
- warning = f"Maximum orientations ({self.max_orientations}) reached without convergence"
789
- print(f"\nWarning: {warning}")
790
-
791
- # Calculate final values and SEMs
792
- final_values = {}
793
- final_sems = {}
794
- for conv in self.convergables:
795
- if conv.is_mueller():
796
- mean_array, sem_array = self._calculate_mean_and_sem_array(
797
- conv.variable
798
- )
799
- final_values[conv.variable] = mean_array
800
- final_sems[conv.variable] = sem_array
801
- else:
802
- mean, sem = self._calculate_mean_and_sem(conv.variable)
803
- final_values[conv.variable] = mean
804
- final_sems[conv.variable] = sem
805
-
806
- # Prepare Mueller matrices with SEM
807
- mueller_1d = None
808
- mueller_1d_sem = None
809
- mueller_2d = None
810
-
811
- if self.mueller_1d and self.mueller_1d_sum is not None:
812
- mueller_1d = self.mueller_1d_sum / self.n_orientations
813
-
814
- # Compute SEM for all 16 Mueller elements
815
- # mueller_1d shape: (n_theta, 16)
816
- n_theta = mueller_1d.shape[0]
817
- mueller_1d_sem = np.zeros_like(mueller_1d)
818
-
819
- for row in range(1, 5):
820
- for col in range(1, 5):
821
- element_name = f"S{row}{col}"
822
- mueller_idx = (row - 1) * 4 + (col - 1)
823
-
824
- # Calculate mean and SEM for this element across all theta bins
825
- mean_array, sem_array = self._calculate_mean_and_sem_array(
826
- element_name
827
- )
828
-
829
- if len(sem_array) > 0:
830
- mueller_1d_sem[:, mueller_idx] = sem_array
831
-
832
- # Store mueller_1d_sem in final_values for unified API access
833
- final_values["mueller_1d_sem"] = mueller_1d_sem
797
+ # Randomly select a geometry file for this batch
798
+ geom_file = random.choice(self.geom_files)
799
+ geom_path = os.path.join(self.geom_dir, geom_file)
800
+ geom_info = f"Geom: {geom_file}"
834
801
 
835
- if self.mueller_2d and self.mueller_2d_sum is not None:
836
- mueller_2d = self.mueller_2d_sum / self.n_orientations
802
+ return geom_path, geom_info
837
803
 
838
- return ConvergenceResults(
839
- converged=converged,
840
- n_orientations=self.n_orientations,
841
- values=final_values,
842
- sem_values=final_sems,
843
- mueller_1d=mueller_1d,
844
- mueller_2d=mueller_2d,
845
- convergence_history=self.convergence_history,
846
- warning=warning,
847
- )
804
+ def _handle_geometry_error(self, error: Exception, geom_path: str) -> bool:
805
+ """Override to skip bad geometries in ensemble mode.
848
806
 
849
- def _print_progress_without_header(self, iteration: int):
850
- """Print convergence progress without iteration header (already printed with geometry)."""
851
- converged_status = self._check_convergence()
807
+ Args:
808
+ error: The exception that occurred
809
+ geom_path: Path to the geometry that failed
852
810
 
853
- for conv in self.convergables:
854
- mean, sem = self._calculate_mean_and_sem(conv.variable)
855
-
856
- # Calculate 95% CI
857
- ci_lower = mean - 1.96 * sem
858
- ci_upper = mean + 1.96 * sem
859
-
860
- # Format based on tolerance type
861
- if conv.tolerance_type == "relative":
862
- if mean != 0:
863
- relative_sem = sem / abs(mean)
864
- target_str = f"{conv.tolerance * 100:.1f}%"
865
- current_str = f"{relative_sem * 100:.2f}%"
866
- else:
867
- target_str = f"{conv.tolerance} (abs, mean=0)"
868
- current_str = f"{sem:.4g}"
869
- else:
870
- target_str = f"{conv.tolerance}"
871
- current_str = f"{sem:.4g}"
811
+ Returns:
812
+ True to skip this geometry and continue
813
+ """
814
+ # Extract just the filename
815
+ geom_file = os.path.basename(geom_path)
872
816
 
873
- # Status indicator
874
- status = "✓" if converged_status[conv.variable] else "❌"
817
+ # Print warning and track skipped geometry
818
+ print(f"\nWarning: Skipping geometry '{geom_file}': {error}")
819
+ self.skipped_geometries.append(geom_file)
875
820
 
876
- # Print line with mean, SEM, CI, and convergence status
877
- print(
878
- f" {conv.variable:<10}: {mean:.6f} ± {sem:.6f} [{ci_lower:.6f}, {ci_upper:.6f}] | SEM: {current_str} (target: {target_str}) {status}"
821
+ # Check if all geometries have been skipped
822
+ if len(self.skipped_geometries) >= len(self.geom_files):
823
+ raise ValueError(
824
+ f"All {len(self.geom_files)} geometry files failed to load. "
825
+ "Please check geometry files for degenerate faces, non-planar geometry, "
826
+ "or faces that are too small."
879
827
  )
880
828
 
881
- # Add to convergence history
882
- self.convergence_history.append((self.n_orientations, conv.variable, sem))
829
+ # Skip this geometry and continue
830
+ return True