@@ -459,6 +459,22 @@ def individualExperimentDataList(self) -> list:
459459 )
460460 return qml_data_list
461461
462+ @Property (float , notify = sampleChartRangesChanged )
463+ def residualMinX (self ):
464+ return self ._get_residual_range ()[0 ]
465+
466+ @Property (float , notify = sampleChartRangesChanged )
467+ def residualMaxX (self ):
468+ return self ._get_residual_range ()[1 ]
469+
470+ @Property (float , notify = sampleChartRangesChanged )
471+ def residualMinY (self ):
472+ return self ._get_residual_range ()[2 ]
473+
474+ @Property (float , notify = sampleChartRangesChanged )
475+ def residualMaxY (self ):
476+ return self ._get_residual_range ()[3 ]
477+
462478 @Slot (str , str , 'QVariant' )
463479 def setQtChartsSerieRef (self , page : str , serie : str , ref : QObject ):
464480 self ._chartRefs ['QtCharts' ][page ][serie ] = ref
@@ -537,70 +553,143 @@ def getExperimentDataPoints(self, experiment_index: int) -> list:
537553 console .debug (f'Error getting experiment data points for index { experiment_index } : { e } ' )
538554 return []
539555
540- @Slot (int , result = 'QVariantList' )
541- def getAnalysisDataPoints (self , experiment_index : int ) -> list :
542- """Get measured and calculated data points for a specific experiment for analysis plotting."""
543- try :
544- # Get measured experimental data
545- exp_data = self ._project_lib .experimental_data_for_model_at_index (experiment_index )
546-
547- # Get the model index for this experiment - it may be different from experiment_index
548- # When multiple experiments share the same model
549- model_index = 0
550- model_found = False
551- if hasattr (exp_data , 'model' ) and exp_data .model is not None :
552- # Find the model index in the models collection
553- for idx , model in enumerate (self ._project_lib .models ):
554- if model is exp_data .model :
555- model_index = idx
556- model_found = True
557- break
558- if not model_found :
559- console .debug (f'Warning: model for experiment { experiment_index } '
560- f'not found in models collection, falling back to model 0' )
561- else :
562- # Fallback: use experiment_index if it's within model range, else 0
563- model_index = experiment_index if experiment_index < len (self ._project_lib .models ) else 0
556+ def _get_experiment_model_index (self , experiment_index : int , exp_data = None ) -> int :
557+ """Resolve the model index used by a given experiment."""
558+ if exp_data is not None and hasattr (exp_data , 'model' ) and exp_data .model is not None :
559+ for idx , model in enumerate (self ._project_lib .models ):
560+ if model is exp_data .model :
561+ return idx
562+ if experiment_index < len (self ._project_lib .models ):
563+ return experiment_index
564+ return 0
565+
566+ def _get_aligned_analysis_values (self , experiment_index : int ) -> list [dict ]:
567+ """Return measured, calculated and sigma values aligned on experiment q points."""
568+ exp_data = self ._project_lib .experimental_data_for_model_at_index (experiment_index )
569+ q_values = np .asarray (getattr (exp_data , 'x' , np .empty (0 )), dtype = float )
570+ measured_values = np .asarray (getattr (exp_data , 'y' , np .empty (0 )), dtype = float )
571+ sigma_values = np .asarray (getattr (exp_data , 'ye' , np .zeros_like (measured_values )), dtype = float )
572+
573+ if q_values .size == 0 or measured_values .size == 0 :
574+ return []
564575
565- # Get the q values from the experimental data for calculating the model
566- q_values = exp_data .x
567- # Filter to q range
568- mask = (q_values >= self ._project_lib .q_min ) & (q_values <= self ._project_lib .q_max )
569- q_filtered = q_values [mask ]
576+ q_mask = (q_values >= self ._project_lib .q_min ) & (q_values <= self ._project_lib .q_max )
577+ q_filtered = q_values [q_mask ]
578+ measured_filtered = measured_values [q_mask ]
579+ sigma_filtered = sigma_values [q_mask ] if sigma_values .size else np .zeros_like (measured_filtered )
570580
571- # Get calculated model data at the same q points using the correct model index
581+ model_index = self ._get_experiment_model_index (experiment_index , exp_data )
582+ try :
572583 calc_data = self ._project_lib .model_data_for_model_at_index (model_index , q_filtered )
584+ except TypeError :
585+ calc_data = self ._project_lib .model_data_for_model_at_index (model_index )
586+
587+ calc_values = np .asarray (getattr (calc_data , 'y' , np .empty (0 )), dtype = float )
588+ calc_q_values = np .asarray (getattr (calc_data , 'x' , np .empty (0 )), dtype = float )
589+
590+ if calc_values .size == q_filtered .size :
591+ calculated_filtered = calc_values
592+ elif calc_values .size == 0 :
593+ calculated_filtered = measured_filtered .copy ()
594+ elif calc_q_values .size == calc_values .size and calc_values .size > 1 :
595+ calculated_filtered = np .interp (q_filtered , calc_q_values , calc_values )
596+ elif calc_values .size == 1 :
597+ calculated_filtered = np .full_like (measured_filtered , calc_values [0 ], dtype = float )
598+ else :
599+ calculated_filtered = np .resize (calc_values , q_filtered .size )
600+
601+ measured_filtered = self ._apply_rq4 (q_filtered , measured_filtered )
602+ calculated_filtered = self ._apply_rq4 (q_filtered , calculated_filtered )
603+ sigma_filtered = self ._apply_rq4 (q_filtered , sigma_filtered )
604+
605+ points = []
606+ for q_value , measured_value , calculated_value , sigma_value in zip (
607+ q_filtered ,
608+ measured_filtered ,
609+ calculated_filtered ,
610+ sigma_filtered ,
611+ ):
612+ points .append (
613+ {
614+ 'q' : float (q_value ),
615+ 'measured' : float (measured_value ),
616+ 'calculated' : float (calculated_value ),
617+ 'sigma' : float (sigma_value ),
618+ }
619+ )
620+ return points
573621
622+ @Slot (int , result = 'QVariantList' )
623+ def getAnalysisDataPoints (self , experiment_index : int ) -> list :
624+ """Get measured and calculated data points for a specific experiment for analysis plotting."""
625+ try :
574626 points = []
575- exp_points = list (exp_data .data_points ())
576- calc_y = calc_data .y
577-
578- if len (calc_y ) != len (q_filtered ):
579- console .debug (f'Warning: calculated data length ({ len (calc_y )} ) '
580- f'differs from filtered experimental data ({ len (q_filtered )} ) '
581- f'for experiment { experiment_index } ' )
582-
583- calc_idx = 0
584- for point in exp_points :
585- if point [0 ] < self ._project_lib .q_max and self ._project_lib .q_min < point [0 ]:
586- q = point [0 ]
587- r_meas = point [1 ]
588- calc_y_val = calc_y [calc_idx ] if calc_idx < len (calc_y ) else r_meas
589- r_meas = self ._apply_rq4 (q , r_meas )
590- calc_y_val = self ._apply_rq4 (q , calc_y_val )
591- points .append (
592- {
593- 'x' : float (q ),
594- 'measured' : float (np .log10 (r_meas )),
595- 'calculated' : float (np .log10 (calc_y_val )),
596- }
597- )
598- calc_idx += 1
627+ for point in self ._get_aligned_analysis_values (experiment_index ):
628+ measured = point ['measured' ]
629+ calculated = point ['calculated' ]
630+ points .append (
631+ {
632+ 'x' : point ['q' ],
633+ 'measured' : float (np .log10 (measured )) if measured > 0 else - 10.0 ,
634+ 'calculated' : float (np .log10 (calculated )) if calculated > 0 else - 10.0 ,
635+ }
636+ )
599637 return points
600638 except Exception as e :
601639 console .debug (f'Error getting analysis data points for index { experiment_index } : { e } ' )
602640 return []
603641
642+ @Slot (int , result = 'QVariantList' )
643+ def getResidualDataPoints (self , experiment_index : int ) -> list :
644+ """Get residual data points for a specific experiment."""
645+ try :
646+ points = []
647+ for point in self ._get_aligned_analysis_values (experiment_index ):
648+ sigma = point ['sigma' ]
649+ residual = point ['calculated' ] - point ['measured' ]
650+ if sigma > 0 :
651+ residual = residual / sigma
652+ points .append ({'x' : point ['q' ], 'y' : float (residual )})
653+ return points
654+ except Exception as e :
655+ console .debug (f'Error getting residual data points for index { experiment_index } : { e } ' )
656+ return []
657+
658+ def _get_residual_range (self ) -> tuple [float , float , float , float ]:
659+ """Return residual plot ranges for the current selection."""
660+ try :
661+ if self .is_multi_experiment_mode :
662+ selected_indices = getattr (self ._proxy ._analysis , '_selected_experiment_indices' , [])
663+ else :
664+ selected_indices = [self ._project_lib .current_experiment_index ]
665+
666+ all_points = []
667+ for experiment_index in selected_indices :
668+ all_points .extend (self .getResidualDataPoints (experiment_index ))
669+
670+ if not all_points :
671+ return 0.0 , 1.0 , - 1.0 , 1.0
672+
673+ x_values = np .asarray ([point ['x' ] for point in all_points ], dtype = float )
674+ y_values = np .asarray ([point ['y' ] for point in all_points ], dtype = float )
675+ if x_values .size == 0 or y_values .size == 0 :
676+ return 0.0 , 1.0 , - 1.0 , 1.0
677+
678+ min_x = float (np .min (x_values ))
679+ max_x = float (np .max (x_values ))
680+ min_y = float (np .min (y_values ))
681+ max_y = float (np .max (y_values ))
682+
683+ if min_y == max_y :
684+ margin = max (abs (min_y ) * 0.05 , 1.0 )
685+ else :
686+ margin = (max_y - min_y ) * 0.05
687+
688+ return min_x , max_x , min_y - margin , max_y + margin
689+ except Exception as e :
690+ console .debug (f'Error getting residual range: { e } ' )
691+ return 0.0 , 1.0 , - 1.0 , 1.0
692+
604693 def refreshSamplePage (self ):
605694 # Clear cached data so it gets recalculated
606695 self ._sample_data = {}
@@ -617,6 +706,7 @@ def refreshExperimentPage(self):
617706 def refreshAnalysisPage (self ):
618707 self ._model_data = {}
619708 self .drawCalculatedAndMeasuredOnAnalysisChart ()
709+ self .sampleChartRangesChanged .emit ()
620710
621711 def refreshExperimentRanges (self ):
622712 """Emit signal to update experiment chart ranges when selection changes."""
0 commit comments