@@ -562,6 +562,22 @@ def individualExperimentDataList(self) -> list:
562562 )
563563 return qml_data_list
564564
565+ @Property (float , notify = sampleChartRangesChanged )
566+ def residualMinX (self ):
567+ return self ._get_residual_range ()[0 ]
568+
569+ @Property (float , notify = sampleChartRangesChanged )
570+ def residualMaxX (self ):
571+ return self ._get_residual_range ()[1 ]
572+
573+ @Property (float , notify = sampleChartRangesChanged )
574+ def residualMinY (self ):
575+ return self ._get_residual_range ()[2 ]
576+
577+ @Property (float , notify = sampleChartRangesChanged )
578+ def residualMaxY (self ):
579+ return self ._get_residual_range ()[3 ]
580+
565581 @Slot (str , str , 'QVariant' )
566582 def setQtChartsSerieRef (self , page : str , serie : str , ref : QObject ):
567583 self ._chartRefs ['QtCharts' ][page ][serie ] = ref
@@ -640,112 +656,143 @@ def getExperimentDataPoints(self, experiment_index: int) -> list:
640656 console .debug (f'Error getting experiment data points for index { experiment_index } : { e } ' )
641657 return []
642658
643- def _get_aligned_analysis_values (self , experiment_index : int ) -> list :
644- """Return aligned measured/calculated pairs in linear (rq4-transformed) space.
645-
646- Both values have ``_apply_rq4`` applied but no log10. Only points within
647- [q_min, q_max] are included. The caller is responsible for any further
648- transformation (log10 for display, subtraction for residuals, etc.).
649-
650- Each element is a dict::
659+ def _get_experiment_model_index (self , experiment_index : int , exp_data = None ) -> int :
660+ """Resolve the model index used by a given experiment."""
661+ if exp_data is not None and hasattr (exp_data , 'model' ) and exp_data .model is not None :
662+ for idx , model in enumerate (self ._project_lib .models ):
663+ if model is exp_data .model :
664+ return idx
665+ if experiment_index < len (self ._project_lib .models ):
666+ return experiment_index
667+ return 0
651668
652- {'q': float, 'measured': float, 'calculated': float}
653- """
654- # Get measured experimental data
669+ def _get_aligned_analysis_values (self , experiment_index : int ) -> list [dict ]:
670+ """Return measured, calculated and sigma values aligned on experiment q points."""
655671 exp_data = self ._project_lib .experimental_data_for_model_at_index (experiment_index )
672+ q_values = np .asarray (getattr (exp_data , 'x' , np .empty (0 )), dtype = float )
673+ measured_values = np .asarray (getattr (exp_data , 'y' , np .empty (0 )), dtype = float )
674+ sigma_values = np .asarray (getattr (exp_data , 'ye' , np .zeros_like (measured_values )), dtype = float )
656675
657- # Resolve model index, which may differ from experiment_index when multiple
658- # experiments share the same model.
659- model_index = 0
660- model_found = False
661- if hasattr (exp_data , 'model' ) and exp_data .model is not None :
662- for idx , model in enumerate (self ._project_lib .models ):
663- if model is exp_data .model :
664- model_index = idx
665- model_found = True
666- break
667- if not model_found :
668- console .debug (f'Warning: model for experiment { experiment_index } '
669- f'not found in models collection, falling back to model 0' )
670- else :
671- model_index = experiment_index if experiment_index < len (self ._project_lib .models ) else 0
676+ if q_values .size == 0 or measured_values .size == 0 :
677+ return []
672678
673- # Filter experimental q values to [q_min, q_max]
674- q_values = exp_data . x
675- mask = ( q_values >= self . _project_lib . q_min ) & ( q_values <= self . _project_lib . q_max )
676- q_filtered = q_values [ mask ]
679+ q_mask = ( q_values >= self . _project_lib . q_min ) & ( q_values <= self . _project_lib . q_max )
680+ q_filtered = q_values [ q_mask ]
681+ measured_filtered = measured_values [ q_mask ]
682+ sigma_filtered = sigma_values [ q_mask ] if sigma_values . size else np . zeros_like ( measured_filtered )
677683
678- # Evaluate model at the filtered experimental q points
679- calc_data = self ._project_lib .model_data_for_model_at_index (model_index , q_filtered )
680- calc_y = calc_data .y
684+ model_index = self ._get_experiment_model_index (experiment_index , exp_data )
685+ try :
686+ calc_data = self ._project_lib .model_data_for_model_at_index (model_index , q_filtered )
687+ except TypeError :
688+ calc_data = self ._project_lib .model_data_for_model_at_index (model_index )
689+
690+ calc_values = np .asarray (getattr (calc_data , 'y' , np .empty (0 )), dtype = float )
691+ calc_q_values = np .asarray (getattr (calc_data , 'x' , np .empty (0 )), dtype = float )
692+
693+ if calc_values .size == q_filtered .size :
694+ calculated_filtered = calc_values
695+ elif calc_values .size == 0 :
696+ calculated_filtered = measured_filtered .copy ()
697+ elif calc_q_values .size == calc_values .size and calc_values .size > 1 :
698+ calculated_filtered = np .interp (q_filtered , calc_q_values , calc_values )
699+ elif calc_values .size == 1 :
700+ calculated_filtered = np .full_like (measured_filtered , calc_values [0 ], dtype = float )
701+ else :
702+ calculated_filtered = np .resize (calc_values , q_filtered .size )
681703
682- if len (calc_y ) != len (q_filtered ):
683- console .debug (f'Warning: calculated data length ({ len (calc_y )} ) '
684- f'differs from filtered experimental data ({ len (q_filtered )} ) '
685- f'for experiment { experiment_index } ' )
704+ measured_filtered = self ._apply_rq4 (q_filtered , measured_filtered )
705+ calculated_filtered = self ._apply_rq4 (q_filtered , calculated_filtered )
706+ sigma_filtered = self ._apply_rq4 (q_filtered , sigma_filtered )
686707
687708 points = []
688- calc_idx = 0
689- for point in exp_data .data_points ():
690- q = point [0 ]
691- if self ._project_lib .q_min <= q <= self ._project_lib .q_max :
692- r_meas = point [1 ]
693- calc_y_val = calc_y [calc_idx ] if calc_idx < len (calc_y ) else r_meas
694- sigma_linear = float (np .sqrt (max (point [2 ], 0.0 )))
695- sigma_transformed = float (self ._apply_rq4 (q , sigma_linear )) if sigma_linear > 0.0 else 0.0
696- points .append ({
697- 'q' : float (q ),
698- 'measured' : float (self ._apply_rq4 (q , r_meas )),
699- 'calculated' : float (self ._apply_rq4 (q , calc_y_val )),
700- 'sigma' : sigma_transformed ,
701- })
702- calc_idx += 1
709+ for q_value , measured_value , calculated_value , sigma_value in zip (
710+ q_filtered ,
711+ measured_filtered ,
712+ calculated_filtered ,
713+ sigma_filtered ,
714+ ):
715+ points .append (
716+ {
717+ 'q' : float (q_value ),
718+ 'measured' : float (measured_value ),
719+ 'calculated' : float (calculated_value ),
720+ 'sigma' : float (sigma_value ),
721+ }
722+ )
703723 return points
704724
705725 @Slot (int , result = 'QVariantList' )
706726 def getAnalysisDataPoints (self , experiment_index : int ) -> list :
707727 """Get measured and calculated data points for a specific experiment for analysis plotting."""
708728 try :
709729 points = []
710- for item in self ._get_aligned_analysis_values (experiment_index ):
711- q = item ['q' ]
712- r_meas = item ['measured' ]
713- r_calc = item ['calculated' ]
714- points .append ({
715- 'x' : q ,
716- 'measured' : float (np .log10 (r_meas )) if r_meas > 0 else - 10.0 ,
717- 'calculated' : float (np .log10 (r_calc )) if r_calc > 0 else - 10.0 ,
718- })
730+ for point in self ._get_aligned_analysis_values (experiment_index ):
731+ measured = point ['measured' ]
732+ calculated = point ['calculated' ]
733+ points .append (
734+ {
735+ 'x' : point ['q' ],
736+ 'measured' : float (np .log10 (measured )) if measured > 0 else - 10.0 ,
737+ 'calculated' : float (np .log10 (calculated )) if calculated > 0 else - 10.0 ,
738+ }
739+ )
719740 return points
720741 except Exception as e :
721742 console .debug (f'Error getting analysis data points for index { experiment_index } : { e } ' )
722743 return []
723744
724745 @Slot (int , result = 'QVariantList' )
725746 def getResidualDataPoints (self , experiment_index : int ) -> list :
726- """Get normalized residual data points (model − experiment) / sigma.
727-
728- Falls back to (model − experiment) / experiment when sigma is zero
729- (i.e. measurement uncertainty not provided).
730- """
747+ """Get residual data points for a specific experiment."""
731748 try :
732749 points = []
733- for item in self ._get_aligned_analysis_values (experiment_index ):
734- calc = item ['calculated' ]
735- meas = item ['measured' ]
736- sigma = item ['sigma' ]
737- if sigma > 0.0 :
738- residual = (calc - meas ) / sigma
739- elif meas > 0.0 :
740- residual = (calc - meas ) / meas
741- else :
742- residual = calc - meas
743- points .append ({'x' : float (item ['q' ]), 'y' : float (residual )})
750+ for point in self ._get_aligned_analysis_values (experiment_index ):
751+ sigma = point ['sigma' ]
752+ residual = point ['calculated' ] - point ['measured' ]
753+ if sigma > 0 :
754+ residual = residual / sigma
755+ points .append ({'x' : point ['q' ], 'y' : float (residual )})
744756 return points
745757 except Exception as e :
746758 console .debug (f'Error getting residual data points for index { experiment_index } : { e } ' )
747759 return []
748760
761+ def _get_residual_range (self ) -> tuple [float , float , float , float ]:
762+ """Return residual plot ranges for the current selection."""
763+ try :
764+ if self .is_multi_experiment_mode :
765+ selected_indices = getattr (self ._proxy ._analysis , '_selected_experiment_indices' , [])
766+ else :
767+ selected_indices = [self ._project_lib .current_experiment_index ]
768+
769+ all_points = []
770+ for experiment_index in selected_indices :
771+ all_points .extend (self .getResidualDataPoints (experiment_index ))
772+
773+ if not all_points :
774+ return 0.0 , 1.0 , - 1.0 , 1.0
775+
776+ x_values = np .asarray ([point ['x' ] for point in all_points ], dtype = float )
777+ y_values = np .asarray ([point ['y' ] for point in all_points ], dtype = float )
778+ if x_values .size == 0 or y_values .size == 0 :
779+ return 0.0 , 1.0 , - 1.0 , 1.0
780+
781+ min_x = float (np .min (x_values ))
782+ max_x = float (np .max (x_values ))
783+ min_y = float (np .min (y_values ))
784+ max_y = float (np .max (y_values ))
785+
786+ if min_y == max_y :
787+ margin = max (abs (min_y ) * 0.05 , 1.0 )
788+ else :
789+ margin = (max_y - min_y ) * 0.05
790+
791+ return min_x , max_x , min_y - margin , max_y + margin
792+ except Exception as e :
793+ console .debug (f'Error getting residual range: { e } ' )
794+ return 0.0 , 1.0 , - 1.0 , 1.0
795+
749796 def refreshSamplePage (self ):
750797 # Clear cached data so it gets recalculated
751798 self ._sample_data = {}
@@ -763,7 +810,6 @@ def refreshAnalysisPage(self):
763810 self ._model_data = {}
764811 self ._invalidate_residual_range_cache ()
765812 self .drawCalculatedAndMeasuredOnAnalysisChart ()
766- # Notify the residual chart to re-poll data and ranges
767813 self .sampleChartRangesChanged .emit ()
768814
769815 def refreshExperimentRanges (self ):
0 commit comments