Skip to content

API Reference

SANSFitter

The main class for SANS data fitting.

sans_fitter.fitter.SANSFitter

A flexible SANS model fitter that works with any SasModels model.

Features: - Loads data from various file formats (CSV, XML, HDF5) - Model-agnostic: works with any model from SasModels library - Supports multiple fitting engines (BUMPS, LMFit) - User-friendly parameter management

For model-free P(r) inversion (pair distance distribution analysis), see :mod:sans_fitter.inversion — it operates directly on datasets (fitter.data or data_ops results) and needs no model setup.

Example

fitter = SANSFitter() fitter.load_data('my_sans_data.csv') fitter.set_model('cylinder') fitter.set_param('radius', value=20, min=1, max=100) fitter.set_param('length', value=400, min=10, max=1000) result = fitter.fit(engine='bumps') fitter.plot_results()

Source code in src/sans_fitter/fitter.py
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
class SANSFitter:
    """
    A flexible SANS model fitter that works with any SasModels model.

    Features:
    - Loads data from various file formats (CSV, XML, HDF5)
    - Model-agnostic: works with any model from SasModels library
    - Supports multiple fitting engines (BUMPS, LMFit)
    - User-friendly parameter management

    For model-free P(r) inversion (pair distance distribution analysis), see
    :mod:`sans_fitter.inversion` — it operates directly on datasets
    (``fitter.data`` or ``data_ops`` results) and needs no model setup.

    Example:
        >>> fitter = SANSFitter()
        >>> fitter.load_data('my_sans_data.csv')
        >>> fitter.set_model('cylinder')
        >>> fitter.set_param('radius', value=20, min=1, max=100)
        >>> fitter.set_param('length', value=400, min=10, max=1000)
        >>> result = fitter.fit(engine='bumps')
        >>> fitter.plot_results()
    """

    def __init__(self):
        """Initialize the SANS fitter."""
        self.data = None
        self.kernel = None
        self.fit_result = None
        self._fit_contract: FitResultContract | None = None
        self._fitted_model = None
        self._full_q_range: tuple[float, float] | None = None

        # Parameter management delegated to ParameterManager
        self._param_manager = ParameterManager()

    def load_data(self, filename: str) -> None:
        """
        Load SANS data from a file.

        Supports CSV, XML, and HDF5 formats through sasdata. Columnar text/CSV
        files are interpreted in the order Q, I, dI, dQ (per the sasdata ASCII
        convention) — a file whose third column is dQ rather than dI will have
        its uncertainties and resolution swapped. Check the column summary
        printed after loading.

        Args:
            filename: Path to the data file

        Raises:
            FileNotFoundError: If the file doesn't exist
            ValueError: If the data cannot be loaded or is invalid
        """
        self.data = load_sans_data(filename)
        self._full_q_range = (self.data.qmin, self.data.qmax)

        has_dy = has_real_data(self.data.dy)
        has_dx = has_real_data(self.data.dx)

        print(f'✓ Loaded data from {filename}')
        print(f'  Q range: {self.data.qmin:.4f} to {self.data.qmax:.4f} Å⁻¹')
        print(f'  Data points: {len(self.data.x)}')
        print(f'  Error (dI) column: {"yes" if has_dy else "no"}')
        print(f'  Resolution (dQ) column: {"yes" if has_dx else "no"}')

    def set_data(self, data: Any) -> None:
        """
        Use an in-memory dataset for fitting.

        This is the injection point for datasets that were not loaded from a
        file: results of dataset arithmetic (see :mod:`sans_fitter.data.ops`),
        simulated data, or any sasdata ``Data1D`` built programmatically. The
        dataset is validated and normalized (``qmin``/``qmax``/``mask`` are
        recomputed as needed) so it is fit-ready.

        Args:
            data: A sasdata ``Data1D`` object with populated ``x`` and ``y``
                arrays. 2D data is not supported.

        Raises:
            TypeError: If the object is 2D data or lacks ``x``/``y`` arrays.
            ValueError: If ``x``/``y`` are empty, have mismatched lengths, or
                contain non-positive Q values.
        """
        if getattr(data, 'qx_data', None) is not None:
            raise TypeError('2D data is not supported. Provide a Data1D object.')
        x = getattr(data, 'x', None)
        y = getattr(data, 'y', None)
        if x is None or y is None:
            raise TypeError('Dataset must have populated x and y arrays.')
        x = np.asarray(x)
        y = np.asarray(y)
        if x.size == 0 or y.size == 0:
            raise ValueError('Dataset is empty: x and y must contain data points.')
        if x.size != y.size:
            raise ValueError(f'x and y have different lengths ({x.size} vs {y.size}).')
        if np.any(x[np.isfinite(x)] <= 0):
            raise ValueError('Q values must be positive.')
        if x.size < 5:
            warnings.warn(
                f'Dataset has only {x.size} points; fits may be unreliable.',
                stacklevel=2,
            )

        self.data = normalize_sans_data(data)
        self._full_q_range = (self.data.qmin, self.data.qmax)

        has_dy = has_real_data(self.data.dy)
        has_dx = has_real_data(self.data.dx)
        label = getattr(data, 'title', '') or getattr(data, 'filename', '') or 'in-memory dataset'

        print(f'✓ Data set: {label}')
        print(f'  Q range: {self.data.qmin:.4f} to {self.data.qmax:.4f} Å⁻¹')
        print(f'  Data points: {len(self.data.x)}')
        print(f'  Error (dI) column: {"yes" if has_dy else "no"}')
        print(f'  Resolution (dQ) column: {"yes" if has_dx else "no"}')

    def set_q_range(self, qmin: float | None = None, qmax: float | None = None) -> None:
        """
        Restrict the Q range used for fitting.

        Data points outside [qmin, qmax] are excluded from the fit (and from
        the exported fit curve/residuals) but remain visible in plots. Typical
        uses: trimming beam-stop spillover at low Q or background-dominated
        high-Q points.

        Args:
            qmin: Lower Q limit in Å⁻¹. If omitted, the current lower limit
                is reset to the full data range.
            qmax: Upper Q limit in Å⁻¹. If omitted, the current upper limit
                is reset to the full data range.

        Raises:
            ValueError: If no data is loaded, if qmin >= qmax, or if no data
                points remain in the requested range (the previous range is
                kept in that case).
        """
        if self.data is None:
            raise ValueError('No data loaded. Use load_data() first.')
        if qmin is None and qmax is None:
            raise ValueError('Provide qmin, qmax, or both.')

        full_min, full_max = self._full_q_range
        new_qmin = full_min if qmin is None else float(qmin)
        new_qmax = full_max if qmax is None else float(qmax)
        if new_qmin >= new_qmax:
            raise ValueError(f'qmin ({new_qmin:g}) must be smaller than qmax ({new_qmax:g}).')

        previous = (self.data.qmin, self.data.qmax)
        self.data.qmin = new_qmin
        self.data.qmax = new_qmax

        index = get_fit_index(self.data)
        n_points = int(index.sum())
        if n_points == 0:
            self.data.qmin, self.data.qmax = previous
            raise ValueError(
                f'No data points in Q range [{new_qmin:g}, {new_qmax:g}]. Range unchanged.'
            )

        print(f'✓ Q range for fitting: {new_qmin:.6g} to {new_qmax:.6g} Å⁻¹')
        print(f'  Points in fit: {n_points} of {len(index)}')

    def reset_q_range(self) -> None:
        """
        Reset the fitting Q range to the full range of the loaded data.

        Raises:
            ValueError: If no data is loaded.
        """
        if self.data is None:
            raise ValueError('No data loaded. Use load_data() first.')

        self.data.qmin, self.data.qmax = self._full_q_range
        n_points = int(get_fit_index(self.data).sum())
        print(f'✓ Q range reset to {self.data.qmin:.6g} to {self.data.qmax:.6g} Å⁻¹')
        print(f'  Points in fit: {n_points}')

    def get_q_range(self) -> tuple[float, float] | None:
        """
        Get the Q range currently used for fitting.

        Returns:
            Tuple (qmin, qmax) in Å⁻¹, or None if no data is loaded.
        """
        if self.data is None:
            return None
        return (self.data.qmin, self.data.qmax)

    def set_model(self, model_name: str, platform: str = 'cpu') -> None:
        """
        Set the SANS model to use for fitting.

        Accepts both single models and composite expressions understood by
        sasmodels: ``'dab+peak_lorentz'`` (sum mixture), ``'modelA*modelB'``
        (product mixture), and ``'sphere@hardsphere'`` (form factor with
        structure factor). Every atomic model name in the expression is
        validated against the sasmodels model list before loading, with a
        nearest-match suggestion for unknown names.

        This resets any active structure factor to ensure a clean state.

        Args:
            model_name: Name of the model from SasModels (e.g., 'cylinder',
                'sphere', 'dab+peak_lorentz')
            platform: Computation platform ('cpu' or 'opencl')

        Raises:
            ValueError: If the model name is not valid
        """
        _validate_model_expression(model_name)

        try:
            # Force CPU platform to avoid OpenCL issues
            self.kernel = load_model(model_name, dtype='single', platform='dll')

            # Initialize parameters via ParameterManager. Components are
            # derived from the kernel's composition tree, not the expression
            # string (robust against nested mixture plugins).
            self._param_manager.initialize_from_kernel(self.kernel, model_name)

            print(f"✓ Model '{model_name}' loaded successfully")
            print(f'  Available parameters: {len(self._param_manager.params)}')

        except Exception as e:
            raise ValueError(f"Failed to load model '{model_name}': {str(e)}") from e

    def set_models(
        self,
        *model_names: str,
        operation: str = '+',
        shared: Sequence[str] = (),
        **monikers: str,
    ) -> None:
        """
        Combine multiple models against the current dataset.

        The friendly-name entry point for composite models. Parameters are
        exposed with model-name (or moniker) prefixes instead of sasmodels'
        ``A_``/``B_`` prefixes, e.g. ``dab_cor_length`` instead of
        ``A_cor_length``.

        Args:
            *model_names: Model names, positionally. Each may itself contain
                ``@`` to apply a structure factor to one part (e.g.
                ``'sphere@hardsphere'``).
            operation: How to combine the models: ``'+'`` (sum mixture, the
                default) or ``'*'`` (product mixture).
            shared: Unprefixed parameter names that must exist in at least 2
                components. Each becomes a single unprefixed parameter driving
                every component that has it (e.g. ``shared=['sld']``).
                Polydispersity configuration stays per-component under the
                prefixed names.
            **monikers: Components given as ``moniker=model_name`` keyword
                arguments, for long model names, duplicates, or physics
                labels (e.g. ``small='sphere', large='sphere'``).

        Example:
            >>> fitter.set_models('dab', 'peak_lorentz')
            >>> fitter.set_param('dab_cor_length', value=50, vary=True)
            >>> fitter.set_models(small='sphere', large='sphere', shared=['sld'])

        Raises:
            ValueError: If fewer than 2 models are given, the operation is
                invalid, a moniker is invalid, a shared name is missing from
                enough components or names a global parameter
                (``'scale'``/``'background'``), the generated alias names
                collide or shadow a canonical name, or an
                entry expands to more than one kernel component (e.g. a
                nested ``'+'``/``'*'`` expression) — each entry must be a
                single component so monikers map 1:1; use the raw
                ``set_model('a+b')`` string path for nested expressions.
        """
        if operation not in ('+', '*'):
            raise ValueError(f"Invalid operation '{operation}'. Use '+' or '*'.")

        components: list[tuple[str, str]] = []  # (moniker, model_name)
        for name in model_names:
            # Positional moniker defaults to the model name; for product
            # entries ('sphere@hardsphere') use the form-factor part so the
            # moniker stays a valid identifier.
            moniker = name if name.isidentifier() else name.split('@')[0]
            components.append((moniker, name))
        for moniker, name in monikers.items():
            components.append((moniker, name))

        if len(components) < 2:
            raise ValueError(
                "set_models() requires at least 2 models. For a single model use set_model('name')."
            )

        # The global scale/background are shared by every component natively;
        # letting them through shared= would collapse the per-component
        # scales onto the global entry and silently drop it from the fit.
        conflicting = {'scale', 'background'} & set(shared)
        if conflicting:
            raise ValueError(
                f'Cannot share the global parameter(s) {", ".join(sorted(conflicting))}: '
                "'scale' and 'background' are already shared by every component."
            )

        # Validate monikers: valid identifiers and not reserved names.
        # Positional model names may repeat (they get auto-suffixed below);
        # keyword monikers must be unique among themselves.
        reserved = {'scale', 'background'} | set(shared)
        for moniker, _name in components:
            if not moniker.isidentifier():
                raise ValueError(
                    f"Component name '{moniker}' is not a valid identifier. "
                    'Use keyword monikers for non-identifier model names.'
                )
            if moniker in reserved:
                raise ValueError(
                    f"Component name '{moniker}' is reserved "
                    "(collides with 'scale', 'background', or a shared name)."
                )
        keyword_monikers = [moniker for moniker, _name in components[len(model_names) :]]
        if len(set(keyword_monikers)) != len(keyword_monikers):
            raise ValueError('Duplicate keyword monikers are not allowed.')

        # Duplicate positional model names auto-suffix their monikers
        # (sphere1_, sphere2_); keyword monikers are the recommended spelling
        # for that case.
        positional_counts: dict[str, int] = {}
        for name in model_names:
            positional_counts[name] = positional_counts.get(name, 0) + 1
        duplicate_names = {name for name, count in positional_counts.items() if count > 1}

        resolved: list[tuple[str, str]] = []
        dup_counters: dict[str, int] = {}
        for moniker, name in components:
            if moniker == name and name in duplicate_names:
                dup_counters[name] = dup_counters.get(name, 0) + 1
                resolved.append((f'{name}{dup_counters[name]}', name))
            else:
                resolved.append((moniker, name))
        components = resolved

        # Re-check uniqueness after auto-suffixing (a generated suffix could
        # collide with an explicit moniker).
        all_monikers = [moniker for moniker, _name in components]
        if len(set(all_monikers)) != len(all_monikers):
            raise ValueError(
                'Component names collide after auto-suffixing duplicates: '
                f'{all_monikers}. Use distinct keyword monikers.'
            )

        # Delegate loading/validation to set_model using canonical syntax.
        expression = operation.join(name for _moniker, name in components)
        self.set_model(expression)

        # Register the friendly-name alias layer. register_aliases raises on
        # shared-name or alias-collision problems (detected by building the
        # full alias map, not by ad-hoc string rules).
        self._param_manager.register_aliases(components, list(shared))

        print(f'✓ Combined {len(components)} models: {expression}')
        print(f'  Components: {", ".join(m for m, _n in components)}')
        if shared:
            print(f'  Shared parameters: {", ".join(shared)}')
        print(f'  Available parameters: {len(self._param_manager.params)}')

    def link_params(self, name: str, to: str) -> None:
        """
        Create an equality link between two parameters.

        The follower (*name*) is forced to ``vary=False`` and mirrors the
        target's (*to*) value at all times — before, during, and after the
        fit. Links are equality-only; no expressions. Works for any pair of
        parameters, including cross-component ones (``'large_sld'`` following
        ``'small_sld'``) and differently named ones.

        Args:
            name: The follower parameter name.
            to: The target parameter name.

        Raises:
            KeyError: If either name does not exist.
            ValueError: On self-links, link chains, or conflicting links.
        """
        self._param_manager.link_params(name, to)
        print(f'✓ Linked {name}{to}')

    def unlink_params(self, name: str) -> None:
        """
        Remove an equality link, restoring the follower's independence.

        Args:
            name: The follower parameter name.

        Raises:
            KeyError: If the name does not exist.
            ValueError: If the parameter is not linked.
        """
        self._param_manager.unlink_params(name)
        print(f'✓ Unlinked {name}')

    def get_links(self) -> dict[str, str]:
        """Return the active parameter equality links (follower -> target)."""
        return self._param_manager.get_links()

    def get_components(self) -> list[tuple[str, str, str]]:
        """
        Return the composite-model components.

        Returns:
            List of ``(prefix, moniker, part_model_name)`` triples, e.g.
            ``[('A', 'dab', 'dab'), ('B', 'peak_lorentz', 'peak_lorentz')]``.
            Empty for atomic models.
        """
        return self._param_manager.get_components()

    # =========================================================================
    # Property accessors for backward compatibility
    # =========================================================================

    @property
    def model_name(self) -> str | None:
        """Get the current model name."""
        return self._param_manager.model_name

    @model_name.setter
    def model_name(self, value: str | None) -> None:
        """Set the model name (used internally)."""
        self._param_manager.model_name = value

    @property
    def params(self) -> dict[str, dict[str, Any]]:
        """Get the parameter dictionary."""
        return self._param_manager.params

    @params.setter
    def params(self, value: dict[str, dict[str, Any]]) -> None:
        """Set the parameter dictionary (used internally)."""
        self._param_manager.params = value

    @property
    def _structure_factor_name(self) -> str | None:
        """Get the structure factor name."""
        return self._param_manager.get_structure_factor()

    @property
    def _radius_effective_mode(self) -> str:
        """Get the radius effective mode."""
        return self._param_manager.get_radius_effective_mode()

    def get_params(self) -> None:
        """Display current parameter values and settings in a readable format."""
        self._param_manager.display_params()

    def set_param(
        self,
        name: str,
        value: float | None = None,
        min: float | None = None,
        max: float | None = None,
        vary: bool | None = None,
    ) -> None:
        """
        Configure a model parameter for fitting.

        Args:
            name: Parameter name
            value: Initial value (optional)
            min: Minimum bound (optional)
            max: Maximum bound (optional)
            vary: Whether to vary during fit (optional)

        Raises:
            KeyError: If parameter name doesn't exist for the current model
        """
        self._param_manager.set_param(name, value=value, min=min, max=max, vary=vary)

    def set_structure_factor(
        self, structure_factor_name: str, radius_effective_mode: str = 'unconstrained'
    ) -> None:
        """
        Apply a structure factor to the current model.

        This creates a product model (form_factor * structure_factor) to account
        for inter-particle interactions in concentrated systems.

        Supported structure factors:
        - 'hardsphere': Hard sphere structure factor (Percus-Yevick closure)
        - 'hayter_msa': Hayter-Penfold rescaled MSA for charged spheres
        - 'squarewell': Square well potential
        - 'stickyhardsphere': Sticky hard sphere (Baxter model)

        Args:
            structure_factor_name: Name of the structure factor (e.g., 'hardsphere')
            radius_effective_mode: How to handle the effective radius.
                - 'unconstrained': 'radius_effective' is a separate fitting parameter.
                - 'link_radius': 'radius_effective' is constrained to the form factor's 'radius'.

        Raises:
            ValueError: If no form factor model is set, or if the structure factor is invalid
        """
        if self.kernel is None or self.model_name is None:
            raise ValueError('No form factor model loaded. Use set_model() first.')

        if self._param_manager.get_components():
            raise ValueError(
                'Cannot apply a structure factor to a composite model. '
                "The expression '(modelA+modelB)@sf' cannot be expressed in "
                'sasmodels, and naive concatenation would be parsed as '
                "'modelA + (modelB@sf)'. Apply the structure factor to one "
                "part instead, e.g. set_models('sphere@hardsphere', 'peak_lorentz')."
            )

        # Validate structure factor name
        supported_sf = ['hardsphere', 'hayter_msa', 'squarewell', 'stickyhardsphere']
        if structure_factor_name not in supported_sf:
            raise ValueError(
                f"Unsupported structure factor '{structure_factor_name}'. "
                f'Supported: {", ".join(supported_sf)}'
            )

        # Create product model name
        full_model_name = f'{self.model_name}@{structure_factor_name}'

        try:
            # Load the product model
            self.kernel = load_model(full_model_name, dtype='single', platform='dll')

            # Delegate parameter management to ParameterManager
            self._param_manager.update_for_product_model(
                self.kernel, structure_factor_name, radius_effective_mode
            )

            if radius_effective_mode == 'link_radius':
                print("  Note: 'radius_effective' linked to 'radius' value")

            print(f"✓ Structure factor '{structure_factor_name}' applied to '{self.model_name}'")
            print(f'  Product model: {full_model_name}')
            print(f'  Total parameters: {len(self.params)}')

        except Exception as e:
            raise ValueError(f"Failed to load model '{full_model_name}': {str(e)}") from e

    def remove_structure_factor(self) -> None:
        """
        Remove the current structure factor and revert to the form factor only.

        Raises:
            ValueError: If no structure factor is currently set
        """
        if self._structure_factor_name is None:
            raise ValueError('No structure factor is currently set.')

        # Reload the original form factor model
        try:
            self.kernel = load_model(self.model_name, dtype='single', platform='dll')

            # Delegate to ParameterManager - this restores params and PD state
            sf_name = self._param_manager.remove_structure_factor()

            print(f"✓ Structure factor '{sf_name}' removed")
            print(f'  Reverted to form factor: {self.model_name}')

        except Exception as e:
            raise ValueError(f'Failed to reload form factor model: {str(e)}') from e

    def get_structure_factor(self) -> str | None:
        """
        Get the name of the currently applied structure factor.

        Returns:
            Name of the structure factor, or None if no structure factor is set
        """
        return self._structure_factor_name

    # =========================================================================
    # Polydispersity Methods
    # =========================================================================

    def supports_polydispersity(self) -> bool:
        """
        Check if current model has polydisperse parameters.

        Returns:
            True if model supports polydispersity, False otherwise
        """
        return self._param_manager.has_polydisperse_parameters()

    def get_polydisperse_parameters(self) -> list[str]:
        """
        Get list of polydisperse parameter names.

        Returns:
            List of parameter names that support polydispersity
        """
        return self._param_manager.get_polydisperse_parameters()

    def set_pd_param(
        self,
        param_name: str,
        pd_width: float | None = None,
        pd_n: int | None = None,
        pd_nsigma: float | None = None,
        pd_type: str | None = None,
        vary: bool | None = None,
    ) -> None:
        """
        Configure polydispersity for a parameter.

        Args:
            param_name: Name of the base parameter (e.g., 'radius')
            pd_width: Polydispersity width (relative, 0.0 = monodisperse)
            pd_n: Number of Gaussian quadrature points (default: 35)
            pd_nsigma: Number of sigmas to include (default: 3.0)
            pd_type: Distribution type ('gaussian', 'rectangle', 'lognormal', 'schulz', 'boltzmann')
            vary: Whether to vary the pd_width during fitting

        Raises:
            KeyError: If param_name is not a polydisperse parameter
            ValueError: If pd_type is not a valid distribution type
        """
        self._param_manager.set_pd_param(
            param_name,
            pd_width=pd_width,
            pd_n=pd_n,
            pd_nsigma=pd_nsigma,
            pd_type=pd_type,
            vary=vary,
        )

    def get_pd_param(self, param_name: str) -> dict[str, Any]:
        """
        Get polydispersity configuration for a parameter.

        Args:
            param_name: Name of the base parameter (e.g., 'radius')

        Returns:
            Dictionary with pd, pd_n, pd_nsigma, pd_type, vary, and active values.
            'active' indicates whether polydispersity is active for this parameter (pd > 0).

        Raises:
            KeyError: If param_name is not a polydisperse parameter
        """
        return self._param_manager.get_pd_param(param_name)

    def enable_polydispersity(self, enabled: bool = True) -> None:
        """
        Enable or disable polydispersity globally.

        When disabled, polydispersity parameters are excluded from fitting
        but their values are preserved for when PD is re-enabled.

        Args:
            enabled: Whether to enable polydispersity (default: True)
        """
        self._param_manager.toggle_pd_visibility(enabled)

    def is_polydispersity_enabled(self) -> bool:
        """
        Check if polydispersity is enabled.

        Returns:
            True if polydispersity is globally enabled, False otherwise
        """
        return self._param_manager.is_pd_enabled()

    def get_pd_params(self) -> None:
        """Display polydispersity parameter values and settings."""
        self._param_manager.display_pd_params()

    def get_varying_pd_params(self) -> list[str]:
        """
        Get list of polydispersity parameters that are set to vary.

        Returns:
            List of parameter names (e.g., ['radius_pd']) that will vary during fitting
        """
        # ParameterManager returns base param names, we need to add _pd suffix
        # and translate to user-facing aliases on the set_models path.
        varying_base = self._param_manager.get_varying_pd_params()
        return [
            self._param_manager.to_display_name(f'{param_name}_pd') for param_name in varying_base
        ]

    def _finalize_fit(self, engine_output) -> dict[str, Any]:
        """Apply engine output to fitter state and return legacy-compatible results."""
        self._param_manager.apply_fitted_values(engine_output.fitted_values)
        self._fit_contract = engine_output.contract

        # Translate engine result names (canonical) back to user-facing names
        # so saved results and displays never expose A_/B_ on the set_models
        # path (Boundary 2 of the alias layer).
        self._fit_contract.parameters = {
            self._param_manager.to_display_name(name): dict(info)
            for name, info in self._fit_contract.parameters.items()
        }

        # Attach per-component curves for '+' mixture models (no-op otherwise).
        component_curves = self._compute_component_curves()
        if component_curves:
            self._fit_contract.artifacts.component_curves = component_curves

        self.fit_result = self._fit_contract.to_legacy_dict()
        self._fitted_model = engine_output.runtime_model

        print('\n✓ Fit completed!')
        print(f'Final χ² = {self.fit_result["chisq"]:.4f}')
        print('\nFitted parameters:')
        for name, info in self.fit_result['parameters'].items():
            print(f'  {name}: {info["formatted"]}')

        posterior = self._fit_contract.artifacts.posterior
        if posterior is not None:
            print()
            print(posterior.format_summary())

        return self.fit_result

    def _compute_component_curves(self) -> dict[str, np.ndarray] | None:
        """Compute per-component curves after a fit of a '+' mixture model.

        Each component curve is ``scale · I_part(q, scale=part_scale,
        background=0)`` — matching the mixture kernel's own computation — so
        the component curves plus the background stack onto the total curve.

        Returns None for atomic models and '*' mixtures (where part curves
        would not stack to the total and would mislead when overlaid).
        Evaluation happens on the same masked q-points as the total curve.
        """
        components = self._param_manager.get_components()
        if not components:
            return None

        # '*' mixtures: part intensities multiply, so additive component
        # curves are meaningless. Documented no-op.
        operation = getattr(self.kernel.info, 'operation', '+')
        if operation != '+':
            return None

        canonical_values = self._param_manager.get_canonical_param_values()
        global_scale = canonical_values.get('scale', 1.0)

        # Active polydispersity settings, keyed by canonical base names.
        pd_settings: dict[str, dict[str, Any]] = {}
        if self._param_manager.is_pd_enabled():
            for base_param in self._param_manager.get_polydisperse_parameters():
                pd_config = self._param_manager.polydisperse_params[base_param]
                if pd_is_active(pd_config):
                    pd_settings[base_param] = pd_config

        curves: dict[str, np.ndarray] = {}
        for prefix, moniker, part_name in components:
            # Label: moniker, with the model name appended when they differ;
            # on the raw-string path moniker == prefix ('A: dab').
            if moniker == prefix and moniker != part_name:
                label = f'{prefix}: {part_name}'
            elif moniker != part_name:
                label = f'{moniker} ({part_name})'
            else:
                label = moniker

            part_kernel = load_model(part_name, dtype='single', platform='dll')
            calculator = DirectModel(self.data, part_kernel)

            # Map fitted values by stripping the component prefix; fold in
            # active PD settings the same way the posterior evaluator does.
            part_pars: dict[str, Any] = {}
            prefix_marker = f'{prefix}_'
            for canonical, value in canonical_values.items():
                if canonical.startswith(prefix_marker):
                    stripped = canonical[len(prefix_marker) :]
                    part_pars[stripped] = value
            # The part's own scale slot gets the component scale; background
            # is excluded from component curves (shown implicitly in total).
            part_scale = canonical_values.get(f'{prefix}_scale', 1.0)
            part_pars['scale'] = part_scale
            part_pars['background'] = 0.0
            for base_param, pd_config in pd_settings.items():
                if base_param.startswith(prefix_marker):
                    stripped = base_param[len(prefix_marker) :]
                    part_pars[f'{stripped}_pd'] = pd_config['pd']
                    part_pars[f'{stripped}_pd_n'] = pd_config['pd_n']
                    part_pars[f'{stripped}_pd_nsigma'] = pd_config['pd_nsigma']
                    part_pars[f'{stripped}_pd_type'] = pd_config['pd_type']

            curves[label] = global_scale * np.asarray(calculator(**part_pars))

        return curves

    def _get_active_fit_contract(self) -> FitResultContract | None:
        """Return the active fit contract, adapting legacy runtime state if needed."""
        if self._fit_contract is not None:
            return self._fit_contract

        if self.fit_result is None:
            return None

        if self.fit_result['engine'] == 'bumps':
            return FitResultContract(
                engine=self.fit_result['engine'],
                method=self.fit_result['method'],
                chisq=self.fit_result['chisq'],
                parameters=self.fit_result['parameters'],
                artifacts=FitArtifacts(
                    fitted_curve=np.asarray(self._fitted_model.active_model.theory()),
                    fit_index=extract_fit_index(self._fitted_model.active_model),
                ),
            )

        calculator = DirectModel(self.data, self.kernel)
        par_dict = {name: info['value'] for name, info in self.fit_result['parameters'].items()}
        return FitResultContract(
            engine=self.fit_result['engine'],
            method=self.fit_result['method'],
            chisq=self.fit_result['chisq'],
            parameters=self.fit_result['parameters'],
            artifacts=FitArtifacts(
                fitted_curve=np.asarray(calculator(**par_dict)),
                fit_index=extract_fit_index(calculator),
            ),
        )

    def fit(
        self,
        engine: Literal['bumps', 'lmfit'] = 'bumps',
        method: str | None = None,
        **kwargs: Any,
    ) -> dict[str, Any]:
        """
        Perform the fit using the specified engine.

        Args:
            engine: Fitting engine ('bumps' or 'lmfit')
            method: Optimization method (engine-specific)
                   - BUMPS: 'amoeba', 'lm', 'newton', 'de' (default: 'amoeba')
                   - LMFit: 'leastsq', 'least_squares', 'differential_evolution', etc.
            **kwargs: Additional arguments passed to the fitting engine

        Returns:
            Dictionary with fit results including chi-squared and parameter values

        Raises:
            ValueError: If data or model not loaded, or invalid engine
            NotImplementedError: If a composite model or parameter links are
                used with an engine other than 'bumps'.
        """
        if self.data is None:
            raise ValueError('No data loaded. Use load_data() first.')
        if self.kernel is None:
            raise ValueError('No model loaded. Use set_model() first.')

        if engine not in ('bumps', 'lmfit'):
            raise ValueError(f"Unknown engine '{engine}'. Use 'bumps' or 'lmfit'.")

        self._check_composite_engine_support(engine)
        self._check_scale_degeneracy()
        self._check_fit_uncertainties(engine)

        if engine == 'bumps':
            return self._fit_bumps(method or 'amoeba', **kwargs)
        if not LMFIT_AVAILABLE:
            raise ValueError("scipy is not installed. Use 'bumps' engine or install scipy.")
        return self._fit_lmfit(method or 'leastsq', **kwargs)

    def _check_composite_engine_support(self, engine: str) -> None:
        """Gate composite models and parameter links to the bumps engine.

        The scipy path would probably work for composites (DirectModel accepts
        prefixed kwargs) but it is untested; failing loudly beats silently
        unvalidated results. A model using shared= always presents non-empty
        linked_params, so no separate gate is needed for it.
        """
        if engine == 'bumps':
            return
        snapshot = self._param_manager.snapshot_fit_state()
        if snapshot.linked_params:
            raise NotImplementedError(
                "Parameter links are currently supported by the 'bumps' engine only."
            )
        if snapshot.components:
            raise NotImplementedError(
                "Composite models are currently supported by the 'bumps' engine only."
            )

    def _check_scale_degeneracy(self) -> None:
        """Warn when the global scale and a component scale are both free.

        Under a mixture, the total intensity is scale · Σ(part_scale · I_part);
        varying both the global scale and any component scale is degenerate —
        only their product is fitted.
        """
        varying = self._param_manager.get_varying_params()
        if 'scale' not in varying:
            return
        # Atomic models can expose their own *_scale parameters (broad_peak,
        # gel_fit, ...) that are not mixture component scales.
        if not self._param_manager.get_components():
            return
        component_scales = [name for name in varying if name.endswith('_scale') and name != 'scale']
        if component_scales:
            warnings.warn(
                "Both the global 'scale' and component scale(s) "
                f'{", ".join(component_scales)} are varying. Their product is '
                'what the fit sees, so the split between them is degenerate. '
                'Fix one of them.',
                stacklevel=3,
            )

    def _check_fit_uncertainties(self, engine: str) -> None:
        """Validate intensity uncertainties (dI) before fitting.

        Both engines weight residuals by dI. Zero (or absent) uncertainties
        make the BUMPS χ² infinite for every parameter set, so the fit cannot
        proceed; the scipy/lmfit engine falls back to unit weights for the
        affected points (with a warning from the engine itself).
        """
        index = get_fit_index(self.data)
        dy = getattr(self.data, 'dy', None)
        if dy is None or np.asarray(dy).size == 0:
            n_zero = int(index.sum())
        else:
            dy_fit = np.asarray(dy, dtype=float)[index]
            n_zero = int(np.sum(np.nan_to_num(dy_fit) == 0))
        if n_zero == 0:
            return

        n_fit = int(index.sum())
        detail = (
            'has no intensity uncertainties (dI)'
            if n_zero == n_fit
            else f'has {n_zero} of {n_fit} fitted points with zero intensity uncertainty (dI)'
        )
        if engine == 'bumps':
            raise ValueError(
                f'Data {detail}. The bumps engine cannot weight such points '
                '(χ² becomes infinite). Provide dI values, exclude the points '
                "(mask or set_q_range), or use engine='lmfit', which treats "
                'them as unweighted.'
            )
        warnings.warn(
            f'Data {detail}. Affected residuals will be unweighted (dI treated as 1.0), '
            'so these points may dominate χ² relative to points with small errors.',
            stacklevel=2,
        )

    def _fit_bumps(self, method: str = 'amoeba', **kwargs: Any) -> dict[str, Any]:
        """Fit using BUMPS engine."""
        engine_output = fit_bumps(
            data=self.data,
            kernel=self.kernel,
            fit_state=self._param_manager.snapshot_fit_state(),
            method=method,
            **kwargs,
        )
        return self._finalize_fit(engine_output)

    def _fit_lmfit(self, method: str = 'leastsq', **kwargs: Any) -> dict[str, Any]:
        """Fit using scipy.optimize (leastsq/least_squares) engine."""
        engine_output = fit_scipy(
            data=self.data,
            kernel=self.kernel,
            fit_state=self._param_manager.snapshot_fit_state(),
            method=method,
            **kwargs,
        )
        return self._finalize_fit(engine_output)

    def fit_bayesian(
        self,
        method: str = 'dream',
        samples: int = DEFAULT_DREAM_SAMPLES,
        burn: int = DEFAULT_DREAM_BURN,
        thin: int = DEFAULT_DREAM_THIN,
        pop: int = DEFAULT_DREAM_POP,
        **kwargs: Any,
    ) -> dict[str, Any]:
        """
        Perform a Bayesian (MCMC) fit using bumps' DREAM sampler.

        Samples the posterior distribution of the varying parameters and
        stores the chain alongside the usual point-estimate results, enabling
        the posterior displays: plot_posterior_pairs(),
        plot_param_distribution(), plot_posterior_predictive(),
        plot_param_correlations(), and plot_trace().

        The reported parameter values are the best (maximum-likelihood)
        posterior sample; the reported stderr is the posterior 68% credible
        half-width.

        Args:
            method: Sampler method (default 'dream').
            samples: Number of posterior samples to draw.
            burn: Number of burn-in generations to discard (DREAM's native
                unit: each generation advances every chain by one step).
            thin: Keep every nth sample.
            pop: Population (chain) scale factor per varying parameter.
            **kwargs: Additional arguments passed to bumps.fitters.fit.

        Returns:
            Dictionary with fit results including chi-squared and parameter
            values. The posterior itself is available via get_posterior().

        Raises:
            ValueError: If data or model is not loaded, or no parameter varies.
            NotImplementedError: If a composite model or parameter links are
                used — the DREAM path does not support them yet.
        """
        if self.data is None:
            raise ValueError('No data loaded. Use load_data() first.')
        if self.kernel is None:
            raise ValueError('No model loaded. Use set_model() first.')

        snapshot = self._param_manager.snapshot_fit_state()
        if snapshot.linked_params or snapshot.components:
            raise NotImplementedError(
                'Composite models and parameter links are currently supported '
                "by the 'bumps' point-estimate engine only (fit(engine='bumps'))."
            )
        self._check_scale_degeneracy()

        engine_output = fit_bumps_dream(
            data=self.data,
            kernel=self.kernel,
            fit_state=self._param_manager.snapshot_fit_state(),
            method=method,
            samples=samples,
            burn=burn,
            thin=thin,
            pop=pop,
            **kwargs,
        )
        return self._finalize_fit(engine_output)

    def get_posterior(self) -> PosteriorSummary:
        """
        Return the posterior summary from the last Bayesian fit.

        Raises:
            ValueError: If no fit has been run or the last fit was not Bayesian.
        """
        contract = self._get_active_fit_contract()
        if contract is None:
            raise ValueError('No fit results available. Run fit_bayesian() first.')
        return contract.require_posterior()

    def plot_posterior_pairs(
        self,
        params: list[str] | None = None,
        show_contours: bool = True,
        show: bool | None = None,
    ) -> Figure:
        """
        Corner plot of the posterior: marginal densities and pairwise clouds.

        Args:
            params: Optional subset of parameter names (default: all sampled).
            show_contours: Overlay density contours on the pairwise panels.
            show: Same display convention as plot_results().

        Raises:
            ValueError: If the last fit was not Bayesian.
        """
        return plotting.plot_posterior_pairs(
            self.get_posterior(), params=params, show_contours=show_contours, show=show
        )

    def plot_param_distribution(
        self,
        param: str,
        bins: int = 50,
        show: bool | None = None,
    ) -> Figure:
        """
        Marginal posterior distribution for one parameter.

        Args:
            param: Name of a sampled (varying) parameter.
            bins: Number of histogram bins.
            show: Same display convention as plot_results().

        Raises:
            ValueError: If the last fit was not Bayesian.
            KeyError: If the parameter was not sampled.
        """
        return plotting.plot_param_distribution(self.get_posterior(), param, bins=bins, show=show)

    def plot_posterior_predictive(
        self,
        style: str = 'band',
        n_draws: int = DEFAULT_POSTERIOR_PREDICTIVE_DRAWS,
        log_scale: bool = True,
        show: bool | None = None,
    ) -> Figure:
        """
        Posterior predictive check: credible band and/or draws over the data.

        Args:
            style: 'band' (95% credible interval), 'draws' (sampled curves),
                or 'band+draws'.
            n_draws: Number of posterior samples to evaluate through the
                model. Each draw costs one sasmodels evaluation, so large
                values can be slow (especially with polydispersity).
            log_scale: Use log axes.
            show: Same display convention as plot_results().

        Raises:
            ValueError: If the last fit was not Bayesian or no data is loaded.
        """
        contract = self._get_active_fit_contract()
        if contract is None:
            raise ValueError('No fit results available. Run fit_bayesian() first.')
        posterior = contract.require_posterior()
        posterior_data = contract.artifacts.posterior_data
        model_eval = contract.artifacts.posterior_model_eval
        if posterior_data is None or model_eval is None:
            raise ValueError('Bayesian fit does not include posterior predictive artifacts.')

        return plotting.plot_posterior_predictive(
            data=posterior_data,
            posterior=posterior,
            model_eval=model_eval,
            style=style,
            n_draws=n_draws,
            fit_index=contract.artifacts.fit_index,
            log_scale=log_scale,
            show=show,
        )

    def plot_param_correlations(
        self,
        threshold: float = 0.0,
        show: bool | None = None,
    ) -> Figure:
        """
        Heatmap of the posterior parameter correlation matrix.

        Args:
            threshold: Hide cells with |correlation| below this value.
            show: Same display convention as plot_results().

        Raises:
            ValueError: If the last fit was not Bayesian.
        """
        return plotting.plot_param_correlations(
            self.get_posterior(), threshold=threshold, show=show
        )

    def plot_trace(
        self,
        params: list[str] | None = None,
        show: bool | None = None,
    ) -> Figure:
        """
        Trace plot of the MCMC chains for each sampled parameter.

        Falls back to the combined chain when per-chain data is unavailable.

        Args:
            params: Optional subset of parameter names (default: all sampled).
            show: Same display convention as plot_results().

        Raises:
            ValueError: If the last fit was not Bayesian.
        """
        return plotting.plot_trace(self.get_posterior(), params=params, show=show)

    def plot_results(
        self,
        show_residuals: bool = True,
        log_scale: bool = True,
        show: bool | None = None,
        show_components: bool = False,
    ) -> Figure:
        """
        Plot experimental data and fitted model.

        Args:
            show_residuals: If True, show residuals in a separate panel
            log_scale: If True, use log scale for both axes
            show: If True, display the figure via fig.show(); if False, only
                return it. The default (None) displays the figure except in
                Jupyter notebooks, where the returned figure is rendered by
                the notebook itself (avoids showing the plot twice).
            show_components: If True and the fitted model is a '+' mixture,
                overlay one dashed curve per component (labelled by moniker).
                A documented no-op for atomic models and '*' mixtures.

        Returns:
            Plotly Figure object
        """
        return plot_fit(
            data=self.data,
            fit_result=self._get_active_fit_contract(),
            model_name=self.model_name,
            show_residuals=show_residuals,
            log_scale=log_scale,
            show=show,
            show_components=show_components,
        )

    def save_results(self, filename: str) -> None:
        """
        Save fit results to a file.

        Args:
            filename: Output file path (CSV format)
        """
        if self.fit_result is None:
            raise ValueError('No fit results to save. Run fit() first.')

        fit_contract = self._get_active_fit_contract()
        if fit_contract is None:
            raise ValueError('No fit results to save. Run fit() first.')

        save_fit_result(
            filename=filename,
            model_name=self.model_name,
            data=self.data,
            fit_result=fit_contract,
        )

        print(f'✓ Results saved to {filename}')

load_data(filename)

Load SANS data from a file.

Supports CSV, XML, and HDF5 formats through sasdata. Columnar text/CSV files are interpreted in the order Q, I, dI, dQ (per the sasdata ASCII convention) — a file whose third column is dQ rather than dI will have its uncertainties and resolution swapped. Check the column summary printed after loading.

Parameters:

Name Type Description Default
filename str

Path to the data file

required

Raises:

Type Description
FileNotFoundError

If the file doesn't exist

ValueError

If the data cannot be loaded or is invalid

Source code in src/sans_fitter/fitter.py
def load_data(self, filename: str) -> None:
    """
    Load SANS data from a file.

    Supports CSV, XML, and HDF5 formats through sasdata. Columnar text/CSV
    files are interpreted in the order Q, I, dI, dQ (per the sasdata ASCII
    convention) — a file whose third column is dQ rather than dI will have
    its uncertainties and resolution swapped. Check the column summary
    printed after loading.

    Args:
        filename: Path to the data file

    Raises:
        FileNotFoundError: If the file doesn't exist
        ValueError: If the data cannot be loaded or is invalid
    """
    self.data = load_sans_data(filename)
    self._full_q_range = (self.data.qmin, self.data.qmax)

    has_dy = has_real_data(self.data.dy)
    has_dx = has_real_data(self.data.dx)

    print(f'✓ Loaded data from {filename}')
    print(f'  Q range: {self.data.qmin:.4f} to {self.data.qmax:.4f} Å⁻¹')
    print(f'  Data points: {len(self.data.x)}')
    print(f'  Error (dI) column: {"yes" if has_dy else "no"}')
    print(f'  Resolution (dQ) column: {"yes" if has_dx else "no"}')

set_data(data)

Use an in-memory dataset for fitting.

This is the injection point for datasets that were not loaded from a file: results of dataset arithmetic (see :mod:sans_fitter.data.ops), simulated data, or any sasdata Data1D built programmatically. The dataset is validated and normalized (qmin/qmax/mask are recomputed as needed) so it is fit-ready.

Parameters:

Name Type Description Default
data Any

A sasdata Data1D object with populated x and y arrays. 2D data is not supported.

required

Raises:

Type Description
TypeError

If the object is 2D data or lacks x/y arrays.

ValueError

If x/y are empty, have mismatched lengths, or contain non-positive Q values.

Source code in src/sans_fitter/fitter.py
def set_data(self, data: Any) -> None:
    """
    Use an in-memory dataset for fitting.

    This is the injection point for datasets that were not loaded from a
    file: results of dataset arithmetic (see :mod:`sans_fitter.data.ops`),
    simulated data, or any sasdata ``Data1D`` built programmatically. The
    dataset is validated and normalized (``qmin``/``qmax``/``mask`` are
    recomputed as needed) so it is fit-ready.

    Args:
        data: A sasdata ``Data1D`` object with populated ``x`` and ``y``
            arrays. 2D data is not supported.

    Raises:
        TypeError: If the object is 2D data or lacks ``x``/``y`` arrays.
        ValueError: If ``x``/``y`` are empty, have mismatched lengths, or
            contain non-positive Q values.
    """
    if getattr(data, 'qx_data', None) is not None:
        raise TypeError('2D data is not supported. Provide a Data1D object.')
    x = getattr(data, 'x', None)
    y = getattr(data, 'y', None)
    if x is None or y is None:
        raise TypeError('Dataset must have populated x and y arrays.')
    x = np.asarray(x)
    y = np.asarray(y)
    if x.size == 0 or y.size == 0:
        raise ValueError('Dataset is empty: x and y must contain data points.')
    if x.size != y.size:
        raise ValueError(f'x and y have different lengths ({x.size} vs {y.size}).')
    if np.any(x[np.isfinite(x)] <= 0):
        raise ValueError('Q values must be positive.')
    if x.size < 5:
        warnings.warn(
            f'Dataset has only {x.size} points; fits may be unreliable.',
            stacklevel=2,
        )

    self.data = normalize_sans_data(data)
    self._full_q_range = (self.data.qmin, self.data.qmax)

    has_dy = has_real_data(self.data.dy)
    has_dx = has_real_data(self.data.dx)
    label = getattr(data, 'title', '') or getattr(data, 'filename', '') or 'in-memory dataset'

    print(f'✓ Data set: {label}')
    print(f'  Q range: {self.data.qmin:.4f} to {self.data.qmax:.4f} Å⁻¹')
    print(f'  Data points: {len(self.data.x)}')
    print(f'  Error (dI) column: {"yes" if has_dy else "no"}')
    print(f'  Resolution (dQ) column: {"yes" if has_dx else "no"}')

set_q_range(qmin=None, qmax=None)

Restrict the Q range used for fitting.

Data points outside [qmin, qmax] are excluded from the fit (and from the exported fit curve/residuals) but remain visible in plots. Typical uses: trimming beam-stop spillover at low Q or background-dominated high-Q points.

Parameters:

Name Type Description Default
qmin float | None

Lower Q limit in Å⁻¹. If omitted, the current lower limit is reset to the full data range.

None
qmax float | None

Upper Q limit in Å⁻¹. If omitted, the current upper limit is reset to the full data range.

None

Raises:

Type Description
ValueError

If no data is loaded, if qmin >= qmax, or if no data points remain in the requested range (the previous range is kept in that case).

Source code in src/sans_fitter/fitter.py
def set_q_range(self, qmin: float | None = None, qmax: float | None = None) -> None:
    """
    Restrict the Q range used for fitting.

    Data points outside [qmin, qmax] are excluded from the fit (and from
    the exported fit curve/residuals) but remain visible in plots. Typical
    uses: trimming beam-stop spillover at low Q or background-dominated
    high-Q points.

    Args:
        qmin: Lower Q limit in Å⁻¹. If omitted, the current lower limit
            is reset to the full data range.
        qmax: Upper Q limit in Å⁻¹. If omitted, the current upper limit
            is reset to the full data range.

    Raises:
        ValueError: If no data is loaded, if qmin >= qmax, or if no data
            points remain in the requested range (the previous range is
            kept in that case).
    """
    if self.data is None:
        raise ValueError('No data loaded. Use load_data() first.')
    if qmin is None and qmax is None:
        raise ValueError('Provide qmin, qmax, or both.')

    full_min, full_max = self._full_q_range
    new_qmin = full_min if qmin is None else float(qmin)
    new_qmax = full_max if qmax is None else float(qmax)
    if new_qmin >= new_qmax:
        raise ValueError(f'qmin ({new_qmin:g}) must be smaller than qmax ({new_qmax:g}).')

    previous = (self.data.qmin, self.data.qmax)
    self.data.qmin = new_qmin
    self.data.qmax = new_qmax

    index = get_fit_index(self.data)
    n_points = int(index.sum())
    if n_points == 0:
        self.data.qmin, self.data.qmax = previous
        raise ValueError(
            f'No data points in Q range [{new_qmin:g}, {new_qmax:g}]. Range unchanged.'
        )

    print(f'✓ Q range for fitting: {new_qmin:.6g} to {new_qmax:.6g} Å⁻¹')
    print(f'  Points in fit: {n_points} of {len(index)}')

reset_q_range()

Reset the fitting Q range to the full range of the loaded data.

Raises:

Type Description
ValueError

If no data is loaded.

Source code in src/sans_fitter/fitter.py
def reset_q_range(self) -> None:
    """
    Reset the fitting Q range to the full range of the loaded data.

    Raises:
        ValueError: If no data is loaded.
    """
    if self.data is None:
        raise ValueError('No data loaded. Use load_data() first.')

    self.data.qmin, self.data.qmax = self._full_q_range
    n_points = int(get_fit_index(self.data).sum())
    print(f'✓ Q range reset to {self.data.qmin:.6g} to {self.data.qmax:.6g} Å⁻¹')
    print(f'  Points in fit: {n_points}')

get_q_range()

Get the Q range currently used for fitting.

Returns:

Type Description
tuple[float, float] | None

Tuple (qmin, qmax) in Å⁻¹, or None if no data is loaded.

Source code in src/sans_fitter/fitter.py
def get_q_range(self) -> tuple[float, float] | None:
    """
    Get the Q range currently used for fitting.

    Returns:
        Tuple (qmin, qmax) in Å⁻¹, or None if no data is loaded.
    """
    if self.data is None:
        return None
    return (self.data.qmin, self.data.qmax)

set_model(model_name, platform='cpu')

Set the SANS model to use for fitting.

Accepts both single models and composite expressions understood by sasmodels: 'dab+peak_lorentz' (sum mixture), 'modelA*modelB' (product mixture), and 'sphere@hardsphere' (form factor with structure factor). Every atomic model name in the expression is validated against the sasmodels model list before loading, with a nearest-match suggestion for unknown names.

This resets any active structure factor to ensure a clean state.

Parameters:

Name Type Description Default
model_name str

Name of the model from SasModels (e.g., 'cylinder', 'sphere', 'dab+peak_lorentz')

required
platform str

Computation platform ('cpu' or 'opencl')

'cpu'

Raises:

Type Description
ValueError

If the model name is not valid

Source code in src/sans_fitter/fitter.py
def set_model(self, model_name: str, platform: str = 'cpu') -> None:
    """
    Set the SANS model to use for fitting.

    Accepts both single models and composite expressions understood by
    sasmodels: ``'dab+peak_lorentz'`` (sum mixture), ``'modelA*modelB'``
    (product mixture), and ``'sphere@hardsphere'`` (form factor with
    structure factor). Every atomic model name in the expression is
    validated against the sasmodels model list before loading, with a
    nearest-match suggestion for unknown names.

    This resets any active structure factor to ensure a clean state.

    Args:
        model_name: Name of the model from SasModels (e.g., 'cylinder',
            'sphere', 'dab+peak_lorentz')
        platform: Computation platform ('cpu' or 'opencl')

    Raises:
        ValueError: If the model name is not valid
    """
    _validate_model_expression(model_name)

    try:
        # Force CPU platform to avoid OpenCL issues
        self.kernel = load_model(model_name, dtype='single', platform='dll')

        # Initialize parameters via ParameterManager. Components are
        # derived from the kernel's composition tree, not the expression
        # string (robust against nested mixture plugins).
        self._param_manager.initialize_from_kernel(self.kernel, model_name)

        print(f"✓ Model '{model_name}' loaded successfully")
        print(f'  Available parameters: {len(self._param_manager.params)}')

    except Exception as e:
        raise ValueError(f"Failed to load model '{model_name}': {str(e)}") from e

set_models(*model_names, operation='+', shared=(), **monikers)

Combine multiple models against the current dataset.

The friendly-name entry point for composite models. Parameters are exposed with model-name (or moniker) prefixes instead of sasmodels' A_/B_ prefixes, e.g. dab_cor_length instead of A_cor_length.

Parameters:

Name Type Description Default
*model_names str

Model names, positionally. Each may itself contain @ to apply a structure factor to one part (e.g. 'sphere@hardsphere').

()
operation str

How to combine the models: '+' (sum mixture, the default) or '*' (product mixture).

'+'
shared Sequence[str]

Unprefixed parameter names that must exist in at least 2 components. Each becomes a single unprefixed parameter driving every component that has it (e.g. shared=['sld']). Polydispersity configuration stays per-component under the prefixed names.

()
**monikers str

Components given as moniker=model_name keyword arguments, for long model names, duplicates, or physics labels (e.g. small='sphere', large='sphere').

{}
Example

fitter.set_models('dab', 'peak_lorentz') fitter.set_param('dab_cor_length', value=50, vary=True) fitter.set_models(small='sphere', large='sphere', shared=['sld'])

Raises:

Type Description
ValueError

If fewer than 2 models are given, the operation is invalid, a moniker is invalid, a shared name is missing from enough components or names a global parameter ('scale'/'background'), the generated alias names collide or shadow a canonical name, or an entry expands to more than one kernel component (e.g. a nested '+'/'*' expression) — each entry must be a single component so monikers map 1:1; use the raw set_model('a+b') string path for nested expressions.

Source code in src/sans_fitter/fitter.py
def set_models(
    self,
    *model_names: str,
    operation: str = '+',
    shared: Sequence[str] = (),
    **monikers: str,
) -> None:
    """
    Combine multiple models against the current dataset.

    The friendly-name entry point for composite models. Parameters are
    exposed with model-name (or moniker) prefixes instead of sasmodels'
    ``A_``/``B_`` prefixes, e.g. ``dab_cor_length`` instead of
    ``A_cor_length``.

    Args:
        *model_names: Model names, positionally. Each may itself contain
            ``@`` to apply a structure factor to one part (e.g.
            ``'sphere@hardsphere'``).
        operation: How to combine the models: ``'+'`` (sum mixture, the
            default) or ``'*'`` (product mixture).
        shared: Unprefixed parameter names that must exist in at least 2
            components. Each becomes a single unprefixed parameter driving
            every component that has it (e.g. ``shared=['sld']``).
            Polydispersity configuration stays per-component under the
            prefixed names.
        **monikers: Components given as ``moniker=model_name`` keyword
            arguments, for long model names, duplicates, or physics
            labels (e.g. ``small='sphere', large='sphere'``).

    Example:
        >>> fitter.set_models('dab', 'peak_lorentz')
        >>> fitter.set_param('dab_cor_length', value=50, vary=True)
        >>> fitter.set_models(small='sphere', large='sphere', shared=['sld'])

    Raises:
        ValueError: If fewer than 2 models are given, the operation is
            invalid, a moniker is invalid, a shared name is missing from
            enough components or names a global parameter
            (``'scale'``/``'background'``), the generated alias names
            collide or shadow a canonical name, or an
            entry expands to more than one kernel component (e.g. a
            nested ``'+'``/``'*'`` expression) — each entry must be a
            single component so monikers map 1:1; use the raw
            ``set_model('a+b')`` string path for nested expressions.
    """
    if operation not in ('+', '*'):
        raise ValueError(f"Invalid operation '{operation}'. Use '+' or '*'.")

    components: list[tuple[str, str]] = []  # (moniker, model_name)
    for name in model_names:
        # Positional moniker defaults to the model name; for product
        # entries ('sphere@hardsphere') use the form-factor part so the
        # moniker stays a valid identifier.
        moniker = name if name.isidentifier() else name.split('@')[0]
        components.append((moniker, name))
    for moniker, name in monikers.items():
        components.append((moniker, name))

    if len(components) < 2:
        raise ValueError(
            "set_models() requires at least 2 models. For a single model use set_model('name')."
        )

    # The global scale/background are shared by every component natively;
    # letting them through shared= would collapse the per-component
    # scales onto the global entry and silently drop it from the fit.
    conflicting = {'scale', 'background'} & set(shared)
    if conflicting:
        raise ValueError(
            f'Cannot share the global parameter(s) {", ".join(sorted(conflicting))}: '
            "'scale' and 'background' are already shared by every component."
        )

    # Validate monikers: valid identifiers and not reserved names.
    # Positional model names may repeat (they get auto-suffixed below);
    # keyword monikers must be unique among themselves.
    reserved = {'scale', 'background'} | set(shared)
    for moniker, _name in components:
        if not moniker.isidentifier():
            raise ValueError(
                f"Component name '{moniker}' is not a valid identifier. "
                'Use keyword monikers for non-identifier model names.'
            )
        if moniker in reserved:
            raise ValueError(
                f"Component name '{moniker}' is reserved "
                "(collides with 'scale', 'background', or a shared name)."
            )
    keyword_monikers = [moniker for moniker, _name in components[len(model_names) :]]
    if len(set(keyword_monikers)) != len(keyword_monikers):
        raise ValueError('Duplicate keyword monikers are not allowed.')

    # Duplicate positional model names auto-suffix their monikers
    # (sphere1_, sphere2_); keyword monikers are the recommended spelling
    # for that case.
    positional_counts: dict[str, int] = {}
    for name in model_names:
        positional_counts[name] = positional_counts.get(name, 0) + 1
    duplicate_names = {name for name, count in positional_counts.items() if count > 1}

    resolved: list[tuple[str, str]] = []
    dup_counters: dict[str, int] = {}
    for moniker, name in components:
        if moniker == name and name in duplicate_names:
            dup_counters[name] = dup_counters.get(name, 0) + 1
            resolved.append((f'{name}{dup_counters[name]}', name))
        else:
            resolved.append((moniker, name))
    components = resolved

    # Re-check uniqueness after auto-suffixing (a generated suffix could
    # collide with an explicit moniker).
    all_monikers = [moniker for moniker, _name in components]
    if len(set(all_monikers)) != len(all_monikers):
        raise ValueError(
            'Component names collide after auto-suffixing duplicates: '
            f'{all_monikers}. Use distinct keyword monikers.'
        )

    # Delegate loading/validation to set_model using canonical syntax.
    expression = operation.join(name for _moniker, name in components)
    self.set_model(expression)

    # Register the friendly-name alias layer. register_aliases raises on
    # shared-name or alias-collision problems (detected by building the
    # full alias map, not by ad-hoc string rules).
    self._param_manager.register_aliases(components, list(shared))

    print(f'✓ Combined {len(components)} models: {expression}')
    print(f'  Components: {", ".join(m for m, _n in components)}')
    if shared:
        print(f'  Shared parameters: {", ".join(shared)}')
    print(f'  Available parameters: {len(self._param_manager.params)}')

Create an equality link between two parameters.

The follower (name) is forced to vary=False and mirrors the target's (to) value at all times — before, during, and after the fit. Links are equality-only; no expressions. Works for any pair of parameters, including cross-component ones ('large_sld' following 'small_sld') and differently named ones.

Parameters:

Name Type Description Default
name str

The follower parameter name.

required
to str

The target parameter name.

required

Raises:

Type Description
KeyError

If either name does not exist.

ValueError

On self-links, link chains, or conflicting links.

Source code in src/sans_fitter/fitter.py
def link_params(self, name: str, to: str) -> None:
    """
    Create an equality link between two parameters.

    The follower (*name*) is forced to ``vary=False`` and mirrors the
    target's (*to*) value at all times — before, during, and after the
    fit. Links are equality-only; no expressions. Works for any pair of
    parameters, including cross-component ones (``'large_sld'`` following
    ``'small_sld'``) and differently named ones.

    Args:
        name: The follower parameter name.
        to: The target parameter name.

    Raises:
        KeyError: If either name does not exist.
        ValueError: On self-links, link chains, or conflicting links.
    """
    self._param_manager.link_params(name, to)
    print(f'✓ Linked {name}{to}')

Remove an equality link, restoring the follower's independence.

Parameters:

Name Type Description Default
name str

The follower parameter name.

required

Raises:

Type Description
KeyError

If the name does not exist.

ValueError

If the parameter is not linked.

Source code in src/sans_fitter/fitter.py
def unlink_params(self, name: str) -> None:
    """
    Remove an equality link, restoring the follower's independence.

    Args:
        name: The follower parameter name.

    Raises:
        KeyError: If the name does not exist.
        ValueError: If the parameter is not linked.
    """
    self._param_manager.unlink_params(name)
    print(f'✓ Unlinked {name}')

Return the active parameter equality links (follower -> target).

Source code in src/sans_fitter/fitter.py
def get_links(self) -> dict[str, str]:
    """Return the active parameter equality links (follower -> target)."""
    return self._param_manager.get_links()

get_components()

Return the composite-model components.

Returns:

Type Description
list[tuple[str, str, str]]

List of (prefix, moniker, part_model_name) triples, e.g.

list[tuple[str, str, str]]

[('A', 'dab', 'dab'), ('B', 'peak_lorentz', 'peak_lorentz')].

list[tuple[str, str, str]]

Empty for atomic models.

Source code in src/sans_fitter/fitter.py
def get_components(self) -> list[tuple[str, str, str]]:
    """
    Return the composite-model components.

    Returns:
        List of ``(prefix, moniker, part_model_name)`` triples, e.g.
        ``[('A', 'dab', 'dab'), ('B', 'peak_lorentz', 'peak_lorentz')]``.
        Empty for atomic models.
    """
    return self._param_manager.get_components()

set_structure_factor(structure_factor_name, radius_effective_mode='unconstrained')

Apply a structure factor to the current model.

This creates a product model (form_factor * structure_factor) to account for inter-particle interactions in concentrated systems.

Supported structure factors: - 'hardsphere': Hard sphere structure factor (Percus-Yevick closure) - 'hayter_msa': Hayter-Penfold rescaled MSA for charged spheres - 'squarewell': Square well potential - 'stickyhardsphere': Sticky hard sphere (Baxter model)

Parameters:

Name Type Description Default
structure_factor_name str

Name of the structure factor (e.g., 'hardsphere')

required
radius_effective_mode str

How to handle the effective radius. - 'unconstrained': 'radius_effective' is a separate fitting parameter. - 'link_radius': 'radius_effective' is constrained to the form factor's 'radius'.

'unconstrained'

Raises:

Type Description
ValueError

If no form factor model is set, or if the structure factor is invalid

Source code in src/sans_fitter/fitter.py
def set_structure_factor(
    self, structure_factor_name: str, radius_effective_mode: str = 'unconstrained'
) -> None:
    """
    Apply a structure factor to the current model.

    This creates a product model (form_factor * structure_factor) to account
    for inter-particle interactions in concentrated systems.

    Supported structure factors:
    - 'hardsphere': Hard sphere structure factor (Percus-Yevick closure)
    - 'hayter_msa': Hayter-Penfold rescaled MSA for charged spheres
    - 'squarewell': Square well potential
    - 'stickyhardsphere': Sticky hard sphere (Baxter model)

    Args:
        structure_factor_name: Name of the structure factor (e.g., 'hardsphere')
        radius_effective_mode: How to handle the effective radius.
            - 'unconstrained': 'radius_effective' is a separate fitting parameter.
            - 'link_radius': 'radius_effective' is constrained to the form factor's 'radius'.

    Raises:
        ValueError: If no form factor model is set, or if the structure factor is invalid
    """
    if self.kernel is None or self.model_name is None:
        raise ValueError('No form factor model loaded. Use set_model() first.')

    if self._param_manager.get_components():
        raise ValueError(
            'Cannot apply a structure factor to a composite model. '
            "The expression '(modelA+modelB)@sf' cannot be expressed in "
            'sasmodels, and naive concatenation would be parsed as '
            "'modelA + (modelB@sf)'. Apply the structure factor to one "
            "part instead, e.g. set_models('sphere@hardsphere', 'peak_lorentz')."
        )

    # Validate structure factor name
    supported_sf = ['hardsphere', 'hayter_msa', 'squarewell', 'stickyhardsphere']
    if structure_factor_name not in supported_sf:
        raise ValueError(
            f"Unsupported structure factor '{structure_factor_name}'. "
            f'Supported: {", ".join(supported_sf)}'
        )

    # Create product model name
    full_model_name = f'{self.model_name}@{structure_factor_name}'

    try:
        # Load the product model
        self.kernel = load_model(full_model_name, dtype='single', platform='dll')

        # Delegate parameter management to ParameterManager
        self._param_manager.update_for_product_model(
            self.kernel, structure_factor_name, radius_effective_mode
        )

        if radius_effective_mode == 'link_radius':
            print("  Note: 'radius_effective' linked to 'radius' value")

        print(f"✓ Structure factor '{structure_factor_name}' applied to '{self.model_name}'")
        print(f'  Product model: {full_model_name}')
        print(f'  Total parameters: {len(self.params)}')

    except Exception as e:
        raise ValueError(f"Failed to load model '{full_model_name}': {str(e)}") from e

get_structure_factor()

Get the name of the currently applied structure factor.

Returns:

Type Description
str | None

Name of the structure factor, or None if no structure factor is set

Source code in src/sans_fitter/fitter.py
def get_structure_factor(self) -> str | None:
    """
    Get the name of the currently applied structure factor.

    Returns:
        Name of the structure factor, or None if no structure factor is set
    """
    return self._structure_factor_name

remove_structure_factor()

Remove the current structure factor and revert to the form factor only.

Raises:

Type Description
ValueError

If no structure factor is currently set

Source code in src/sans_fitter/fitter.py
def remove_structure_factor(self) -> None:
    """
    Remove the current structure factor and revert to the form factor only.

    Raises:
        ValueError: If no structure factor is currently set
    """
    if self._structure_factor_name is None:
        raise ValueError('No structure factor is currently set.')

    # Reload the original form factor model
    try:
        self.kernel = load_model(self.model_name, dtype='single', platform='dll')

        # Delegate to ParameterManager - this restores params and PD state
        sf_name = self._param_manager.remove_structure_factor()

        print(f"✓ Structure factor '{sf_name}' removed")
        print(f'  Reverted to form factor: {self.model_name}')

    except Exception as e:
        raise ValueError(f'Failed to reload form factor model: {str(e)}') from e

get_params()

Display current parameter values and settings in a readable format.

Source code in src/sans_fitter/fitter.py
def get_params(self) -> None:
    """Display current parameter values and settings in a readable format."""
    self._param_manager.display_params()

set_param(name, value=None, min=None, max=None, vary=None)

Configure a model parameter for fitting.

Parameters:

Name Type Description Default
name str

Parameter name

required
value float | None

Initial value (optional)

None
min float | None

Minimum bound (optional)

None
max float | None

Maximum bound (optional)

None
vary bool | None

Whether to vary during fit (optional)

None

Raises:

Type Description
KeyError

If parameter name doesn't exist for the current model

Source code in src/sans_fitter/fitter.py
def set_param(
    self,
    name: str,
    value: float | None = None,
    min: float | None = None,
    max: float | None = None,
    vary: bool | None = None,
) -> None:
    """
    Configure a model parameter for fitting.

    Args:
        name: Parameter name
        value: Initial value (optional)
        min: Minimum bound (optional)
        max: Maximum bound (optional)
        vary: Whether to vary during fit (optional)

    Raises:
        KeyError: If parameter name doesn't exist for the current model
    """
    self._param_manager.set_param(name, value=value, min=min, max=max, vary=vary)

fit(engine='bumps', method=None, **kwargs)

Perform the fit using the specified engine.

Parameters:

Name Type Description Default
engine Literal['bumps', 'lmfit']

Fitting engine ('bumps' or 'lmfit')

'bumps'
method str | None

Optimization method (engine-specific) - BUMPS: 'amoeba', 'lm', 'newton', 'de' (default: 'amoeba') - LMFit: 'leastsq', 'least_squares', 'differential_evolution', etc.

None
**kwargs Any

Additional arguments passed to the fitting engine

{}

Returns:

Type Description
dict[str, Any]

Dictionary with fit results including chi-squared and parameter values

Raises:

Type Description
ValueError

If data or model not loaded, or invalid engine

NotImplementedError

If a composite model or parameter links are used with an engine other than 'bumps'.

Source code in src/sans_fitter/fitter.py
def fit(
    self,
    engine: Literal['bumps', 'lmfit'] = 'bumps',
    method: str | None = None,
    **kwargs: Any,
) -> dict[str, Any]:
    """
    Perform the fit using the specified engine.

    Args:
        engine: Fitting engine ('bumps' or 'lmfit')
        method: Optimization method (engine-specific)
               - BUMPS: 'amoeba', 'lm', 'newton', 'de' (default: 'amoeba')
               - LMFit: 'leastsq', 'least_squares', 'differential_evolution', etc.
        **kwargs: Additional arguments passed to the fitting engine

    Returns:
        Dictionary with fit results including chi-squared and parameter values

    Raises:
        ValueError: If data or model not loaded, or invalid engine
        NotImplementedError: If a composite model or parameter links are
            used with an engine other than 'bumps'.
    """
    if self.data is None:
        raise ValueError('No data loaded. Use load_data() first.')
    if self.kernel is None:
        raise ValueError('No model loaded. Use set_model() first.')

    if engine not in ('bumps', 'lmfit'):
        raise ValueError(f"Unknown engine '{engine}'. Use 'bumps' or 'lmfit'.")

    self._check_composite_engine_support(engine)
    self._check_scale_degeneracy()
    self._check_fit_uncertainties(engine)

    if engine == 'bumps':
        return self._fit_bumps(method or 'amoeba', **kwargs)
    if not LMFIT_AVAILABLE:
        raise ValueError("scipy is not installed. Use 'bumps' engine or install scipy.")
    return self._fit_lmfit(method or 'leastsq', **kwargs)

fit_bayesian(method='dream', samples=DEFAULT_DREAM_SAMPLES, burn=DEFAULT_DREAM_BURN, thin=DEFAULT_DREAM_THIN, pop=DEFAULT_DREAM_POP, **kwargs)

Perform a Bayesian (MCMC) fit using bumps' DREAM sampler.

Samples the posterior distribution of the varying parameters and stores the chain alongside the usual point-estimate results, enabling the posterior displays: plot_posterior_pairs(), plot_param_distribution(), plot_posterior_predictive(), plot_param_correlations(), and plot_trace().

The reported parameter values are the best (maximum-likelihood) posterior sample; the reported stderr is the posterior 68% credible half-width.

Parameters:

Name Type Description Default
method str

Sampler method (default 'dream').

'dream'
samples int

Number of posterior samples to draw.

DEFAULT_DREAM_SAMPLES
burn int

Number of burn-in generations to discard (DREAM's native unit: each generation advances every chain by one step).

DEFAULT_DREAM_BURN
thin int

Keep every nth sample.

DEFAULT_DREAM_THIN
pop int

Population (chain) scale factor per varying parameter.

DEFAULT_DREAM_POP
**kwargs Any

Additional arguments passed to bumps.fitters.fit.

{}

Returns:

Type Description
dict[str, Any]

Dictionary with fit results including chi-squared and parameter

dict[str, Any]

values. The posterior itself is available via get_posterior().

Raises:

Type Description
ValueError

If data or model is not loaded, or no parameter varies.

NotImplementedError

If a composite model or parameter links are used — the DREAM path does not support them yet.

Source code in src/sans_fitter/fitter.py
def fit_bayesian(
    self,
    method: str = 'dream',
    samples: int = DEFAULT_DREAM_SAMPLES,
    burn: int = DEFAULT_DREAM_BURN,
    thin: int = DEFAULT_DREAM_THIN,
    pop: int = DEFAULT_DREAM_POP,
    **kwargs: Any,
) -> dict[str, Any]:
    """
    Perform a Bayesian (MCMC) fit using bumps' DREAM sampler.

    Samples the posterior distribution of the varying parameters and
    stores the chain alongside the usual point-estimate results, enabling
    the posterior displays: plot_posterior_pairs(),
    plot_param_distribution(), plot_posterior_predictive(),
    plot_param_correlations(), and plot_trace().

    The reported parameter values are the best (maximum-likelihood)
    posterior sample; the reported stderr is the posterior 68% credible
    half-width.

    Args:
        method: Sampler method (default 'dream').
        samples: Number of posterior samples to draw.
        burn: Number of burn-in generations to discard (DREAM's native
            unit: each generation advances every chain by one step).
        thin: Keep every nth sample.
        pop: Population (chain) scale factor per varying parameter.
        **kwargs: Additional arguments passed to bumps.fitters.fit.

    Returns:
        Dictionary with fit results including chi-squared and parameter
        values. The posterior itself is available via get_posterior().

    Raises:
        ValueError: If data or model is not loaded, or no parameter varies.
        NotImplementedError: If a composite model or parameter links are
            used — the DREAM path does not support them yet.
    """
    if self.data is None:
        raise ValueError('No data loaded. Use load_data() first.')
    if self.kernel is None:
        raise ValueError('No model loaded. Use set_model() first.')

    snapshot = self._param_manager.snapshot_fit_state()
    if snapshot.linked_params or snapshot.components:
        raise NotImplementedError(
            'Composite models and parameter links are currently supported '
            "by the 'bumps' point-estimate engine only (fit(engine='bumps'))."
        )
    self._check_scale_degeneracy()

    engine_output = fit_bumps_dream(
        data=self.data,
        kernel=self.kernel,
        fit_state=self._param_manager.snapshot_fit_state(),
        method=method,
        samples=samples,
        burn=burn,
        thin=thin,
        pop=pop,
        **kwargs,
    )
    return self._finalize_fit(engine_output)

get_posterior()

Return the posterior summary from the last Bayesian fit.

Raises:

Type Description
ValueError

If no fit has been run or the last fit was not Bayesian.

Source code in src/sans_fitter/fitter.py
def get_posterior(self) -> PosteriorSummary:
    """
    Return the posterior summary from the last Bayesian fit.

    Raises:
        ValueError: If no fit has been run or the last fit was not Bayesian.
    """
    contract = self._get_active_fit_contract()
    if contract is None:
        raise ValueError('No fit results available. Run fit_bayesian() first.')
    return contract.require_posterior()

plot_results(show_residuals=True, log_scale=True, show=None, show_components=False)

Plot experimental data and fitted model.

Parameters:

Name Type Description Default
show_residuals bool

If True, show residuals in a separate panel

True
log_scale bool

If True, use log scale for both axes

True
show bool | None

If True, display the figure via fig.show(); if False, only return it. The default (None) displays the figure except in Jupyter notebooks, where the returned figure is rendered by the notebook itself (avoids showing the plot twice).

None
show_components bool

If True and the fitted model is a '+' mixture, overlay one dashed curve per component (labelled by moniker). A documented no-op for atomic models and '*' mixtures.

False

Returns:

Type Description
Figure

Plotly Figure object

Source code in src/sans_fitter/fitter.py
def plot_results(
    self,
    show_residuals: bool = True,
    log_scale: bool = True,
    show: bool | None = None,
    show_components: bool = False,
) -> Figure:
    """
    Plot experimental data and fitted model.

    Args:
        show_residuals: If True, show residuals in a separate panel
        log_scale: If True, use log scale for both axes
        show: If True, display the figure via fig.show(); if False, only
            return it. The default (None) displays the figure except in
            Jupyter notebooks, where the returned figure is rendered by
            the notebook itself (avoids showing the plot twice).
        show_components: If True and the fitted model is a '+' mixture,
            overlay one dashed curve per component (labelled by moniker).
            A documented no-op for atomic models and '*' mixtures.

    Returns:
        Plotly Figure object
    """
    return plot_fit(
        data=self.data,
        fit_result=self._get_active_fit_contract(),
        model_name=self.model_name,
        show_residuals=show_residuals,
        log_scale=log_scale,
        show=show,
        show_components=show_components,
    )

plot_posterior_pairs(params=None, show_contours=True, show=None)

Corner plot of the posterior: marginal densities and pairwise clouds.

Parameters:

Name Type Description Default
params list[str] | None

Optional subset of parameter names (default: all sampled).

None
show_contours bool

Overlay density contours on the pairwise panels.

True
show bool | None

Same display convention as plot_results().

None

Raises:

Type Description
ValueError

If the last fit was not Bayesian.

Source code in src/sans_fitter/fitter.py
def plot_posterior_pairs(
    self,
    params: list[str] | None = None,
    show_contours: bool = True,
    show: bool | None = None,
) -> Figure:
    """
    Corner plot of the posterior: marginal densities and pairwise clouds.

    Args:
        params: Optional subset of parameter names (default: all sampled).
        show_contours: Overlay density contours on the pairwise panels.
        show: Same display convention as plot_results().

    Raises:
        ValueError: If the last fit was not Bayesian.
    """
    return plotting.plot_posterior_pairs(
        self.get_posterior(), params=params, show_contours=show_contours, show=show
    )

plot_param_distribution(param, bins=50, show=None)

Marginal posterior distribution for one parameter.

Parameters:

Name Type Description Default
param str

Name of a sampled (varying) parameter.

required
bins int

Number of histogram bins.

50
show bool | None

Same display convention as plot_results().

None

Raises:

Type Description
ValueError

If the last fit was not Bayesian.

KeyError

If the parameter was not sampled.

Source code in src/sans_fitter/fitter.py
def plot_param_distribution(
    self,
    param: str,
    bins: int = 50,
    show: bool | None = None,
) -> Figure:
    """
    Marginal posterior distribution for one parameter.

    Args:
        param: Name of a sampled (varying) parameter.
        bins: Number of histogram bins.
        show: Same display convention as plot_results().

    Raises:
        ValueError: If the last fit was not Bayesian.
        KeyError: If the parameter was not sampled.
    """
    return plotting.plot_param_distribution(self.get_posterior(), param, bins=bins, show=show)

plot_posterior_predictive(style='band', n_draws=DEFAULT_POSTERIOR_PREDICTIVE_DRAWS, log_scale=True, show=None)

Posterior predictive check: credible band and/or draws over the data.

Parameters:

Name Type Description Default
style str

'band' (95% credible interval), 'draws' (sampled curves), or 'band+draws'.

'band'
n_draws int

Number of posterior samples to evaluate through the model. Each draw costs one sasmodels evaluation, so large values can be slow (especially with polydispersity).

DEFAULT_POSTERIOR_PREDICTIVE_DRAWS
log_scale bool

Use log axes.

True
show bool | None

Same display convention as plot_results().

None

Raises:

Type Description
ValueError

If the last fit was not Bayesian or no data is loaded.

Source code in src/sans_fitter/fitter.py
def plot_posterior_predictive(
    self,
    style: str = 'band',
    n_draws: int = DEFAULT_POSTERIOR_PREDICTIVE_DRAWS,
    log_scale: bool = True,
    show: bool | None = None,
) -> Figure:
    """
    Posterior predictive check: credible band and/or draws over the data.

    Args:
        style: 'band' (95% credible interval), 'draws' (sampled curves),
            or 'band+draws'.
        n_draws: Number of posterior samples to evaluate through the
            model. Each draw costs one sasmodels evaluation, so large
            values can be slow (especially with polydispersity).
        log_scale: Use log axes.
        show: Same display convention as plot_results().

    Raises:
        ValueError: If the last fit was not Bayesian or no data is loaded.
    """
    contract = self._get_active_fit_contract()
    if contract is None:
        raise ValueError('No fit results available. Run fit_bayesian() first.')
    posterior = contract.require_posterior()
    posterior_data = contract.artifacts.posterior_data
    model_eval = contract.artifacts.posterior_model_eval
    if posterior_data is None or model_eval is None:
        raise ValueError('Bayesian fit does not include posterior predictive artifacts.')

    return plotting.plot_posterior_predictive(
        data=posterior_data,
        posterior=posterior,
        model_eval=model_eval,
        style=style,
        n_draws=n_draws,
        fit_index=contract.artifacts.fit_index,
        log_scale=log_scale,
        show=show,
    )

plot_param_correlations(threshold=0.0, show=None)

Heatmap of the posterior parameter correlation matrix.

Parameters:

Name Type Description Default
threshold float

Hide cells with |correlation| below this value.

0.0
show bool | None

Same display convention as plot_results().

None

Raises:

Type Description
ValueError

If the last fit was not Bayesian.

Source code in src/sans_fitter/fitter.py
def plot_param_correlations(
    self,
    threshold: float = 0.0,
    show: bool | None = None,
) -> Figure:
    """
    Heatmap of the posterior parameter correlation matrix.

    Args:
        threshold: Hide cells with |correlation| below this value.
        show: Same display convention as plot_results().

    Raises:
        ValueError: If the last fit was not Bayesian.
    """
    return plotting.plot_param_correlations(
        self.get_posterior(), threshold=threshold, show=show
    )

plot_trace(params=None, show=None)

Trace plot of the MCMC chains for each sampled parameter.

Falls back to the combined chain when per-chain data is unavailable.

Parameters:

Name Type Description Default
params list[str] | None

Optional subset of parameter names (default: all sampled).

None
show bool | None

Same display convention as plot_results().

None

Raises:

Type Description
ValueError

If the last fit was not Bayesian.

Source code in src/sans_fitter/fitter.py
def plot_trace(
    self,
    params: list[str] | None = None,
    show: bool | None = None,
) -> Figure:
    """
    Trace plot of the MCMC chains for each sampled parameter.

    Falls back to the combined chain when per-chain data is unavailable.

    Args:
        params: Optional subset of parameter names (default: all sampled).
        show: Same display convention as plot_results().

    Raises:
        ValueError: If the last fit was not Bayesian.
    """
    return plotting.plot_trace(self.get_posterior(), params=params, show=show)

save_results(filename)

Save fit results to a file.

Parameters:

Name Type Description Default
filename str

Output file path (CSV format)

required
Source code in src/sans_fitter/fitter.py
def save_results(self, filename: str) -> None:
    """
    Save fit results to a file.

    Args:
        filename: Output file path (CSV format)
    """
    if self.fit_result is None:
        raise ValueError('No fit results to save. Run fit() first.')

    fit_contract = self._get_active_fit_contract()
    if fit_contract is None:
        raise ValueError('No fit results to save. Run fit() first.')

    save_fit_result(
        filename=filename,
        model_name=self.model_name,
        data=self.data,
        fit_result=fit_contract,
    )

    print(f'✓ Results saved to {filename}')

supports_polydispersity()

Check if current model has polydisperse parameters.

Returns:

Type Description
bool

True if model supports polydispersity, False otherwise

Source code in src/sans_fitter/fitter.py
def supports_polydispersity(self) -> bool:
    """
    Check if current model has polydisperse parameters.

    Returns:
        True if model supports polydispersity, False otherwise
    """
    return self._param_manager.has_polydisperse_parameters()

get_polydisperse_parameters()

Get list of polydisperse parameter names.

Returns:

Type Description
list[str]

List of parameter names that support polydispersity

Source code in src/sans_fitter/fitter.py
def get_polydisperse_parameters(self) -> list[str]:
    """
    Get list of polydisperse parameter names.

    Returns:
        List of parameter names that support polydispersity
    """
    return self._param_manager.get_polydisperse_parameters()

set_pd_param(param_name, pd_width=None, pd_n=None, pd_nsigma=None, pd_type=None, vary=None)

Configure polydispersity for a parameter.

Parameters:

Name Type Description Default
param_name str

Name of the base parameter (e.g., 'radius')

required
pd_width float | None

Polydispersity width (relative, 0.0 = monodisperse)

None
pd_n int | None

Number of Gaussian quadrature points (default: 35)

None
pd_nsigma float | None

Number of sigmas to include (default: 3.0)

None
pd_type str | None

Distribution type ('gaussian', 'rectangle', 'lognormal', 'schulz', 'boltzmann')

None
vary bool | None

Whether to vary the pd_width during fitting

None

Raises:

Type Description
KeyError

If param_name is not a polydisperse parameter

ValueError

If pd_type is not a valid distribution type

Source code in src/sans_fitter/fitter.py
def set_pd_param(
    self,
    param_name: str,
    pd_width: float | None = None,
    pd_n: int | None = None,
    pd_nsigma: float | None = None,
    pd_type: str | None = None,
    vary: bool | None = None,
) -> None:
    """
    Configure polydispersity for a parameter.

    Args:
        param_name: Name of the base parameter (e.g., 'radius')
        pd_width: Polydispersity width (relative, 0.0 = monodisperse)
        pd_n: Number of Gaussian quadrature points (default: 35)
        pd_nsigma: Number of sigmas to include (default: 3.0)
        pd_type: Distribution type ('gaussian', 'rectangle', 'lognormal', 'schulz', 'boltzmann')
        vary: Whether to vary the pd_width during fitting

    Raises:
        KeyError: If param_name is not a polydisperse parameter
        ValueError: If pd_type is not a valid distribution type
    """
    self._param_manager.set_pd_param(
        param_name,
        pd_width=pd_width,
        pd_n=pd_n,
        pd_nsigma=pd_nsigma,
        pd_type=pd_type,
        vary=vary,
    )

get_pd_param(param_name)

Get polydispersity configuration for a parameter.

Parameters:

Name Type Description Default
param_name str

Name of the base parameter (e.g., 'radius')

required

Returns:

Type Description
dict[str, Any]

Dictionary with pd, pd_n, pd_nsigma, pd_type, vary, and active values.

dict[str, Any]

'active' indicates whether polydispersity is active for this parameter (pd > 0).

Raises:

Type Description
KeyError

If param_name is not a polydisperse parameter

Source code in src/sans_fitter/fitter.py
def get_pd_param(self, param_name: str) -> dict[str, Any]:
    """
    Get polydispersity configuration for a parameter.

    Args:
        param_name: Name of the base parameter (e.g., 'radius')

    Returns:
        Dictionary with pd, pd_n, pd_nsigma, pd_type, vary, and active values.
        'active' indicates whether polydispersity is active for this parameter (pd > 0).

    Raises:
        KeyError: If param_name is not a polydisperse parameter
    """
    return self._param_manager.get_pd_param(param_name)

enable_polydispersity(enabled=True)

Enable or disable polydispersity globally.

When disabled, polydispersity parameters are excluded from fitting but their values are preserved for when PD is re-enabled.

Parameters:

Name Type Description Default
enabled bool

Whether to enable polydispersity (default: True)

True
Source code in src/sans_fitter/fitter.py
def enable_polydispersity(self, enabled: bool = True) -> None:
    """
    Enable or disable polydispersity globally.

    When disabled, polydispersity parameters are excluded from fitting
    but their values are preserved for when PD is re-enabled.

    Args:
        enabled: Whether to enable polydispersity (default: True)
    """
    self._param_manager.toggle_pd_visibility(enabled)

is_polydispersity_enabled()

Check if polydispersity is enabled.

Returns:

Type Description
bool

True if polydispersity is globally enabled, False otherwise

Source code in src/sans_fitter/fitter.py
def is_polydispersity_enabled(self) -> bool:
    """
    Check if polydispersity is enabled.

    Returns:
        True if polydispersity is globally enabled, False otherwise
    """
    return self._param_manager.is_pd_enabled()

get_pd_params()

Display polydispersity parameter values and settings.

Source code in src/sans_fitter/fitter.py
def get_pd_params(self) -> None:
    """Display polydispersity parameter values and settings."""
    self._param_manager.display_pd_params()

get_varying_pd_params()

Get list of polydispersity parameters that are set to vary.

Returns:

Type Description
list[str]

List of parameter names (e.g., ['radius_pd']) that will vary during fitting

Source code in src/sans_fitter/fitter.py
def get_varying_pd_params(self) -> list[str]:
    """
    Get list of polydispersity parameters that are set to vary.

    Returns:
        List of parameter names (e.g., ['radius_pd']) that will vary during fitting
    """
    # ParameterManager returns base param names, we need to add _pd suffix
    # and translate to user-facing aliases on the set_models path.
    varying_base = self._param_manager.get_varying_pd_params()
    return [
        self._param_manager.to_display_name(f'{param_name}_pd') for param_name in varying_base
    ]

Composite model naming

set_models() exposes parameters under friendly alias names; set_model() with a composite expression keeps sasmodels' canonical names. Both spellings are accepted by set_param(), link_params(), set_pd_param() and get_pd_param(). For set_models('dab', 'peak_lorentz'):

Friendly alias (set_models) Canonical name (set_model)
scale, background scale, background (shared natively)
dab_scale A_scale
dab_cor_length A_cor_length
peak_lorentz_scale B_scale
peak_lorentz_peak_pos B_peak_pos
peak_lorentz_peak_hwhm B_peak_hwhm

With keyword monikers (set_models(small='sphere', large='sphere')) the prefix is the moniker (small_radiusA_radius). Parameters listed in shared= collapse to a single unprefixed name (sldA_sld + B_sld); their prefixed aliases remain addressable for polydispersity configuration.

PosteriorSummary

Posterior sample chain and per-parameter statistics returned by fit_bayesian().

sans_fitter.results.PosteriorSummary dataclass

Posterior sample chain and per-parameter statistics from a Bayesian fit.

labels follows the sampler's chain order (problem.labels() for bumps DREAM) and indexes the columns of samples.

Source code in src/sans_fitter/results.py
@dataclass(slots=True)
class PosteriorSummary:
    """Posterior sample chain and per-parameter statistics from a Bayesian fit.

    ``labels`` follows the sampler's chain order (``problem.labels()`` for
    bumps DREAM) and indexes the columns of ``samples``.
    """

    labels: list[str]
    samples: np.ndarray  # [n_samples, n_params]
    logp: np.ndarray | None = None  # [n_samples]
    chains: np.ndarray | None = None  # [n_generations, n_chains, n_params]
    best: dict[str, float] = field(default_factory=dict)
    mean: dict[str, float] = field(default_factory=dict)
    median: dict[str, float] = field(default_factory=dict)
    std: dict[str, float] = field(default_factory=dict)
    ci_68: dict[str, tuple[float, float]] = field(default_factory=dict)
    ci_95: dict[str, tuple[float, float]] = field(default_factory=dict)
    diagnostics: dict[str, dict[str, float]] | None = None

    @property
    def n_samples(self) -> int:
        return int(self.samples.shape[0])

    @property
    def n_params(self) -> int:
        return int(self.samples.shape[1])

    def index_of(self, param: str) -> int:
        """Return the chain column for a parameter name."""
        try:
            return self.labels.index(param)
        except ValueError:
            available = ', '.join(self.labels)
            raise KeyError(
                f"Parameter '{param}' is not part of the posterior sample. Available: {available}"
            ) from None

    def format_summary(self) -> str:
        """Return a table of per-parameter posterior statistics."""
        header = (
            f'{"Parameter":<20} {"Best":>12} {"Mean":>12} {"Median":>12} '
            f'{"Std":>12} {"68% CI":>26} {"95% CI":>26}'
        )
        lines = ['Posterior summary:', header, '-' * len(header)]
        for name in self.labels:
            lo68, hi68 = self.ci_68[name]
            lo95, hi95 = self.ci_95[name]
            lines.append(
                f'{name:<20} {self.best[name]:>12.6g} {self.mean[name]:>12.6g} '
                f'{self.median[name]:>12.6g} {self.std[name]:>12.6g} '
                f'{f"[{lo68:.6g}, {hi68:.6g}]":>26} {f"[{lo95:.6g}, {hi95:.6g}]":>26}'
            )
        if self.diagnostics is not None:
            lines.append('')
            lines.append(f'{"Parameter":<20} {"R-hat":>10} {"ESS":>10}')
            lines.append('-' * 42)
            for name in self.labels:
                stats = self.diagnostics.get(name, {})
                r_hat = stats.get('r_hat')
                ess = stats.get('ess')
                r_hat_text = f'{r_hat:.4f}' if r_hat is not None else 'n/a'
                ess_text = f'{ess:.0f}' if ess is not None else 'n/a'
                lines.append(f'{name:<20} {r_hat_text:>10} {ess_text:>10}')
        return '\n'.join(lines)

    def save_posterior_csv(self, filename: str) -> None:
        """Dump the raw posterior chain to CSV for external analysis."""
        columns = list(self.labels)
        data = [np.asarray(self.samples)]
        if self.logp is not None:
            columns.append('logp')
            data.append(np.asarray(self.logp).reshape(-1, 1))
        table = np.hstack(data)
        with open(filename, 'w') as f:
            f.write(','.join(columns) + '\n')
            for row in table:
                f.write(','.join(f'{value:.8e}' for value in row) + '\n')

index_of(param)

Return the chain column for a parameter name.

Source code in src/sans_fitter/results.py
def index_of(self, param: str) -> int:
    """Return the chain column for a parameter name."""
    try:
        return self.labels.index(param)
    except ValueError:
        available = ', '.join(self.labels)
        raise KeyError(
            f"Parameter '{param}' is not part of the posterior sample. Available: {available}"
        ) from None

format_summary()

Return a table of per-parameter posterior statistics.

Source code in src/sans_fitter/results.py
def format_summary(self) -> str:
    """Return a table of per-parameter posterior statistics."""
    header = (
        f'{"Parameter":<20} {"Best":>12} {"Mean":>12} {"Median":>12} '
        f'{"Std":>12} {"68% CI":>26} {"95% CI":>26}'
    )
    lines = ['Posterior summary:', header, '-' * len(header)]
    for name in self.labels:
        lo68, hi68 = self.ci_68[name]
        lo95, hi95 = self.ci_95[name]
        lines.append(
            f'{name:<20} {self.best[name]:>12.6g} {self.mean[name]:>12.6g} '
            f'{self.median[name]:>12.6g} {self.std[name]:>12.6g} '
            f'{f"[{lo68:.6g}, {hi68:.6g}]":>26} {f"[{lo95:.6g}, {hi95:.6g}]":>26}'
        )
    if self.diagnostics is not None:
        lines.append('')
        lines.append(f'{"Parameter":<20} {"R-hat":>10} {"ESS":>10}')
        lines.append('-' * 42)
        for name in self.labels:
            stats = self.diagnostics.get(name, {})
            r_hat = stats.get('r_hat')
            ess = stats.get('ess')
            r_hat_text = f'{r_hat:.4f}' if r_hat is not None else 'n/a'
            ess_text = f'{ess:.0f}' if ess is not None else 'n/a'
            lines.append(f'{name:<20} {r_hat_text:>10} {ess_text:>10}')
    return '\n'.join(lines)

save_posterior_csv(filename)

Dump the raw posterior chain to CSV for external analysis.

Source code in src/sans_fitter/results.py
def save_posterior_csv(self, filename: str) -> None:
    """Dump the raw posterior chain to CSV for external analysis."""
    columns = list(self.labels)
    data = [np.asarray(self.samples)]
    if self.logp is not None:
        columns.append('logp')
        data.append(np.asarray(self.logp).reshape(-1, 1))
    table = np.hstack(data)
    with open(filename, 'w') as f:
        f.write(','.join(columns) + '\n')
        for row in table:
            f.write(','.join(f'{value:.8e}' for value in row) + '\n')

data_ops

Dataset arithmetic: add, subtract, multiply and divide datasets (or a dataset and a scalar) with propagated uncertainties, returning fit-ready Data1D objects.

sans_fitter.data.ops

Dataset arithmetic for SANS data (issue #45).

Notebook-friendly functions to add, subtract, multiply and divide datasets — similar to SasView's Data Operation utility. Typical uses:

  • subtract(sample, background) — empty-cell / solvent subtraction
  • multiply(data, 2.0) or divide(data, transmission) — rescaling
  • subtract(data, 0.05) — flat background subtraction

The arithmetic itself (including error propagation, e.g. dy = sqrt(dy_a² + dy_b²) for addition/subtraction) is delegated to sasdata's Data1D operators; this module wraps them so the results are fit-ready (qmin/qmax/mask set), carry provenance metadata, and fail with actionable messages.

Example

from sans_fitter import SANSFitter, data_ops sample = data_ops.load('sample.csv') background = data_ops.load('empty_cell.csv') result = data_ops.subtract(sample, background) result = data_ops.multiply(result, 0.85) fitter = SANSFitter() fitter.set_data(result) fitter.set_model('sphere') fitter.fit()

Limitations
  • Both datasets must share the same Q grid (sasdata requires x-values to match within 1% relative tolerance). Interpolation onto a common grid is not yet supported.
  • Resolution (dx/dxl/dxw) propagation through arithmetic is not validated — sasdata combines dx in an RMS-like way and skips slit-smearing widths. A warning is emitted when resolution data is present on any operand; treat resolution on results with care, especially for slit-smeared data.

load(filename)

Load a SANS dataset from a file and return a fit-ready Data1D.

This is the canonical standalone loader: SANSFitter.load_data() uses the same implementation, so datasets loaded here behave identically to those loaded through the fitter. Supports CSV, XML and HDF5 formats via sasdata (columnar text files are read in the order Q, I, dI, dQ).

Parameters:

Name Type Description Default
filename str

Path to the data file.

required

Returns:

Type Description
Data1D

A Data1D object with qmin/qmax/mask set.

Raises:

Type Description
ValueError

If the file cannot be loaded or contains no data.

Source code in src/sans_fitter/data/ops.py
def load(filename: str) -> Data1D:
    """Load a SANS dataset from a file and return a fit-ready ``Data1D``.

    This is the canonical standalone loader: ``SANSFitter.load_data()`` uses
    the same implementation, so datasets loaded here behave identically to
    those loaded through the fitter. Supports CSV, XML and HDF5 formats via
    sasdata (columnar text files are read in the order Q, I, dI, dQ).

    Args:
        filename: Path to the data file.

    Returns:
        A ``Data1D`` object with ``qmin``/``qmax``/``mask`` set.

    Raises:
        ValueError: If the file cannot be loaded or contains no data.
    """
    return load_sans_data(filename)

add(a, b)

Return a + b as a new fit-ready dataset.

b may be a Data1D on the same Q grid as a (intensities are added point-wise, uncertainties combine as dy = sqrt(dy_a² + dy_b²)) or a scalar (y' = y + b; dy and the Q grid are unchanged).

Source code in src/sans_fitter/data/ops.py
def add(a: Data1D, b: Operand) -> Data1D:
    """Return ``a + b`` as a new fit-ready dataset.

    ``b`` may be a ``Data1D`` on the same Q grid as ``a`` (intensities are
    added point-wise, uncertainties combine as ``dy = sqrt(dy_a² + dy_b²)``)
    or a scalar (``y' = y + b``; ``dy`` and the Q grid are unchanged).
    """
    return _operation('+', a, b)

subtract(a, b)

Return a − b as a new fit-ready dataset. Order matters.

Typical use: subtract(sample, background) for empty-cell / solvent subtraction. b may be a Data1D on the same Q grid as a (uncertainties combine as dy = sqrt(dy_a² + dy_b²)) or a scalar for flat background subtraction (y' = y − b; dy and the Q grid are unchanged).

Source code in src/sans_fitter/data/ops.py
def subtract(a: Data1D, b: Operand) -> Data1D:
    """Return ``a − b`` as a new fit-ready dataset. Order matters.

    Typical use: ``subtract(sample, background)`` for empty-cell / solvent
    subtraction. ``b`` may be a ``Data1D`` on the same Q grid as ``a``
    (uncertainties combine as ``dy = sqrt(dy_a² + dy_b²)``) or a scalar for
    flat background subtraction (``y' = y − b``; ``dy`` and the Q grid are
    unchanged).
    """
    return _operation('-', a, b)

multiply(a, b)

Return a × b as a new fit-ready dataset.

b may be a Data1D on the same Q grid as a (relative uncertainties combine in quadrature) or a scalar (y' = b·y, dy' = b·dy; the Q grid is unchanged). Typical scalar use: rescaling to absolute units.

Source code in src/sans_fitter/data/ops.py
def multiply(a: Data1D, b: Operand) -> Data1D:
    """Return ``a × b`` as a new fit-ready dataset.

    ``b`` may be a ``Data1D`` on the same Q grid as ``a`` (relative
    uncertainties combine in quadrature) or a scalar (``y' = b·y``,
    ``dy' = b·dy``; the Q grid is unchanged). Typical scalar use: rescaling
    to absolute units.
    """
    return _operation('*', a, b)

divide(a, b)

Return a / b as a new fit-ready dataset. Order matters.

b may be a Data1D on the same Q grid as a (relative uncertainties combine in quadrature) or a scalar (y' = y/b, dy' = dy/b; the Q grid is unchanged). Typical scalar use: dividing by a transmission factor.

Source code in src/sans_fitter/data/ops.py
def divide(a: Data1D, b: Operand) -> Data1D:
    """Return ``a / b`` as a new fit-ready dataset. Order matters.

    ``b`` may be a ``Data1D`` on the same Q grid as ``a`` (relative
    uncertainties combine in quadrature) or a scalar (``y' = y/b``,
    ``dy' = dy/b``; the Q grid is unchanged). Typical scalar use: dividing by
    a transmission factor.
    """
    return _operation('/', a, b)

pr_inversion

Model-free P(r) inversion (indirect Fourier transform): recover the pair distance distribution function from I(q), with automatic parameter estimation and D_max exploration.

sans_fitter.inversion

P(r) inversion — indirect Fourier transform of I(q) (issue #57).

Model-free analysis recovering the real-space pair distance distribution function P(r) from measured I(q), using Moore's sine-basis expansion (J. Appl. Cryst. 13 (1980) 168). This is a Moore-style IFT inspired by SasView's Inversion perspective, not a numeric port: the regularization operator, uncertainty semantics and heuristics are re-derived, with SasView's exact operator available as regularizer='sasview' for comparison.

Units and conventions: q in 1/Angstrom, r and d_max in Angstrom; intensity in whatever units the file uses. P(r) is defined by I(0) = 4*pi * integral(P dr) in those same intensity units. P(0) = P(d_max) = 0 by construction of the basis; the fit is unconstrained, so P(r) can go negative (unlike GNOM/ATSAS) — the positive_fraction diagnostics quantify this.

Example

from sans_fitter import data_ops, pr_inversion data = data_ops.load('protein.csv') scan = pr_inversion.explore_dmax(data, d_max=120.0, fit_background=False) scan.plot() result = pr_inversion.auto_invert(data, d_max=120.0, fit_background=False) print(result.format_summary()) result.plot_pr() result.plot_fit(data)

Limitations
  • Slit smearing (USANS) and pinhole dQ resolution are not supported; a warning is emitted when slit-smearing columns carry real data.
  • Buffer-subtracted data (the usual protein case) should use fit_background=False — the fitted flat background of the default can absorb I(0) and bias Rg on already-subtracted data.

DEFAULT_N_TERMS = 10 module-attribute

DEFAULT_R_POINTS = 101 module-attribute

REGULARIZERS = ('corrected', 'sasview') module-attribute

SASVIEW_N_REG = 20 module-attribute

PrResult dataclass

Result of a P(r) inversion.

coefficients[k] is c_(k+1) (basis index n = 1..n_terms); the background is never a coefficient entry. covariance is the full matrix — background row/column first when the background was fitted — and is a conditional linearized uncertainty: valid at the chosen alpha and d_max, assuming known independent Gaussian errors, biased by the regularization. q_fit/iq_fit/sigma_fit are the accepted data q-points, the fit evaluated there, and the sigma actually used (needed for residuals when uncertainties were fabricated); smooth curves come from :meth:evaluate_iq on a dense grid.

regularization_penalty is alpha * ||L c||^2 in the active regularizer's own scaling: approximately alpha * integral(P''(r)^2 dr) for 'corrected', SasView's native (D/N_r)^2 row scaling for 'sasview' — the values are not comparable across modes.

Source code in src/sans_fitter/inversion/result.py
@dataclass(slots=True)
class PrResult:
    """Result of a P(r) inversion.

    ``coefficients[k]`` is ``c_(k+1)`` (basis index n = 1..n_terms); the
    background is never a coefficient entry. ``covariance`` is the full
    matrix — background row/column *first* when the background was fitted —
    and is a conditional linearized uncertainty: valid at the chosen alpha
    and d_max, assuming known independent Gaussian errors, biased by the
    regularization. ``q_fit``/``iq_fit``/``sigma_fit`` are the accepted data
    q-points, the fit evaluated there, and the sigma actually used (needed
    for residuals when uncertainties were fabricated); smooth curves come
    from :meth:`evaluate_iq` on a dense grid.

    ``regularization_penalty`` is ``alpha * ||L c||^2`` in the active
    regularizer's own scaling: approximately ``alpha * integral(P''(r)^2 dr)``
    for ``'corrected'``, SasView's native ``(D/N_r)^2`` row scaling for
    ``'sasview'`` — the values are not comparable across modes.
    """

    d_max: float
    n_terms: int
    alpha: float
    regularizer: str
    coefficients: np.ndarray
    covariance: np.ndarray
    background: float
    background_fitted: bool
    background_err: float
    data_chisq: float
    effective_dof: float
    regularization_penalty: float
    n_points_used: int
    accepted: np.ndarray
    condition_number: float
    rank: int
    uncertainties_fabricated: bool
    n_dropped_points: int
    rg: float
    i0: float
    oscillations: float
    positive_fraction: float
    sigma_positive_fraction: float
    r: np.ndarray
    pr: np.ndarray
    pr_err: np.ndarray
    q_fit: np.ndarray
    iq_fit: np.ndarray
    sigma_fit: np.ndarray

    @property
    def coefficient_covariance(self) -> np.ndarray:
        """Coefficient block of the covariance (background row/column removed)."""
        if self.background_fitted:
            return self.covariance[1:, 1:]
        return self.covariance

    def evaluate_pr(self, r: np.ndarray) -> np.ndarray:
        """Evaluate P(r) on an arbitrary r grid."""
        r = np.asarray(r, dtype=float)
        total = np.zeros_like(r)
        for j, c in enumerate(self.coefficients):
            total += c * _ortho(self.d_max, j + 1, r)
        return total

    def evaluate_pr_err(self, r: np.ndarray) -> np.ndarray:
        """Evaluate the P(r) uncertainty band via the full quadratic form."""
        r = np.asarray(r, dtype=float)
        _, band = _pr_curve_and_band(self.coefficients, self.coefficient_covariance, self.d_max, r)
        return band

    def evaluate_iq(self, q: np.ndarray) -> np.ndarray:
        """Evaluate the fitted I(q) (including the background) on an arbitrary q grid."""
        q = np.asarray(q, dtype=float)
        total = np.full_like(q, self.background)
        for j, c in enumerate(self.coefficients):
            total += c * _ortho_transformed(self.d_max, j + 1, q)
        return total

    def format_summary(self) -> str:
        """Return an ASCII table of inputs, quality diagnostics and derived outputs.

        The goodness-of-fit line is labelled "approx. chi2 per residual dof":
        ``data_chisq / (n_points_used - effective_dof)``, where
        ``effective_dof`` counts the *fitted* dimensions of data space
        (``tr(H)``), not the number of parameters. It is an approximate
        diagnostic for a regularized fit, and not interpretable at all when
        uncertainties were fabricated.
        """
        residual_dof = self.n_points_used - self.effective_dof
        approx_chi2 = self.data_chisq / residual_dof if residual_dof > 0 else float('nan')
        background_note = 'fitted' if self.background_fitted else 'fixed'
        lines = [
            'P(r) inversion summary',
            '----------------------',
            f'{"D_max (Ang)":<28} {self.d_max:.6g}',
            f'{"Number of terms":<28} {self.n_terms}',
            f'{"Alpha":<28} {self.alpha:.6g}',
            f'{"Regularizer":<28} {self.regularizer}',
            f'{f"Background ({background_note})":<28} {self.background:.6g}'
            + (f' +/- {self.background_err:.3g}' if self.background_fitted else ''),
            f'{"Rg (Ang)":<28} {self.rg:.6g}',
            f'{"I(0)":<28} {self.i0:.6g}',
            f'{"Oscillations":<28} {self.oscillations:.4g}',
            f'{"Positive fraction":<28} {self.positive_fraction:.4g}',
            f'{"1-sigma positive fraction":<28} {self.sigma_positive_fraction:.4g}',
            f'{"Data chi-squared":<28} {self.data_chisq:.6g}',
            f'{"Effective dof (tr H)":<28} {self.effective_dof:.4g}',
            f'{"Approx. chi2 per residual dof":<28} {approx_chi2:.4g}',
            f'{"Points used":<28} {self.n_points_used} (of {self.accepted.size})',
            f'{"Condition number":<28} {self.condition_number:.4g}',
        ]
        if self.uncertainties_fabricated:
            lines.append(
                'WARNING: intensity uncertainties were fabricated; '
                'chi-squared-based diagnostics are not interpretable.'
            )
        if self.n_dropped_points:
            lines.append(f'NOTE: {self.n_dropped_points} point(s) dropped during preparation.')
        return '\n'.join(lines)

    def save_csv(self, filename: str) -> None:
        """Save inputs, diagnostics and the P(r) curve (r, P, dP columns) to CSV."""
        with open(filename, 'w') as f:
            f.write('# P(r) Inversion Results\n')
            f.write(f'# D_max (Ang): {self.d_max:.6g}\n')
            f.write(f'# Number of terms: {self.n_terms}\n')
            f.write(f'# Alpha: {self.alpha:.6g}\n')
            f.write(f'# Regularizer: {self.regularizer}\n')
            f.write(f'# Background: {self.background:.6g}\n')
            f.write(f'# Background fitted: {self.background_fitted}\n')
            if self.background_fitted:
                f.write(f'# Background uncertainty: {self.background_err:.6g}\n')
            f.write(f'# Rg (Ang): {self.rg:.6g}\n')
            f.write(f'# I(0): {self.i0:.6g}\n')
            f.write(f'# Oscillations: {self.oscillations:.6g}\n')
            f.write(f'# Positive fraction: {self.positive_fraction:.6g}\n')
            f.write(f'# 1-sigma positive fraction: {self.sigma_positive_fraction:.6g}\n')
            f.write(f'# Data chi-squared: {self.data_chisq:.6g}\n')
            f.write(f'# Effective dof: {self.effective_dof:.6g}\n')
            f.write(f'# Points used: {self.n_points_used} of {self.accepted.size}\n')
            f.write(f'# Uncertainties fabricated: {self.uncertainties_fabricated}\n')
            f.write(f'# Points dropped in preparation: {self.n_dropped_points}\n')
            f.write('#\n')
            f.write('r,P(r),dP(r)\n')
            for r_value, p_value, dp_value in zip(self.r, self.pr, self.pr_err, strict=True):
                f.write(f'{r_value:.6e},{p_value:.6e},{dp_value:.6e}\n')

    def plot_pr(self, show: bool | None = None):
        """Plot P(r) with its uncertainty band. Same display convention as plot_results()."""
        from ..plotting import plot_pr_distribution

        return plot_pr_distribution(self, show=show)

    def plot_fit(self, data: Any, show: bool | None = None, log_scale: bool = True):
        """Plot data vs the fitted I(q) with residuals.

        The dataset is passed explicitly — the result stores the model and the
        accepted mask, not the observed intensities. Same display convention
        as plot_results(). Pass ``log_scale=False`` when intensities include
        zero or negative values (a log axis silently omits such points).
        """
        from ..plotting import plot_pr_fit

        return plot_pr_fit(data, self, show=show, log_scale=log_scale)

coefficient_covariance property

Coefficient block of the covariance (background row/column removed).

evaluate_iq(q)

Evaluate the fitted I(q) (including the background) on an arbitrary q grid.

Source code in src/sans_fitter/inversion/result.py
def evaluate_iq(self, q: np.ndarray) -> np.ndarray:
    """Evaluate the fitted I(q) (including the background) on an arbitrary q grid."""
    q = np.asarray(q, dtype=float)
    total = np.full_like(q, self.background)
    for j, c in enumerate(self.coefficients):
        total += c * _ortho_transformed(self.d_max, j + 1, q)
    return total

evaluate_pr(r)

Evaluate P(r) on an arbitrary r grid.

Source code in src/sans_fitter/inversion/result.py
def evaluate_pr(self, r: np.ndarray) -> np.ndarray:
    """Evaluate P(r) on an arbitrary r grid."""
    r = np.asarray(r, dtype=float)
    total = np.zeros_like(r)
    for j, c in enumerate(self.coefficients):
        total += c * _ortho(self.d_max, j + 1, r)
    return total

evaluate_pr_err(r)

Evaluate the P(r) uncertainty band via the full quadratic form.

Source code in src/sans_fitter/inversion/result.py
def evaluate_pr_err(self, r: np.ndarray) -> np.ndarray:
    """Evaluate the P(r) uncertainty band via the full quadratic form."""
    r = np.asarray(r, dtype=float)
    _, band = _pr_curve_and_band(self.coefficients, self.coefficient_covariance, self.d_max, r)
    return band

format_summary()

Return an ASCII table of inputs, quality diagnostics and derived outputs.

The goodness-of-fit line is labelled "approx. chi2 per residual dof": data_chisq / (n_points_used - effective_dof), where effective_dof counts the fitted dimensions of data space (tr(H)), not the number of parameters. It is an approximate diagnostic for a regularized fit, and not interpretable at all when uncertainties were fabricated.

Source code in src/sans_fitter/inversion/result.py
def format_summary(self) -> str:
    """Return an ASCII table of inputs, quality diagnostics and derived outputs.

    The goodness-of-fit line is labelled "approx. chi2 per residual dof":
    ``data_chisq / (n_points_used - effective_dof)``, where
    ``effective_dof`` counts the *fitted* dimensions of data space
    (``tr(H)``), not the number of parameters. It is an approximate
    diagnostic for a regularized fit, and not interpretable at all when
    uncertainties were fabricated.
    """
    residual_dof = self.n_points_used - self.effective_dof
    approx_chi2 = self.data_chisq / residual_dof if residual_dof > 0 else float('nan')
    background_note = 'fitted' if self.background_fitted else 'fixed'
    lines = [
        'P(r) inversion summary',
        '----------------------',
        f'{"D_max (Ang)":<28} {self.d_max:.6g}',
        f'{"Number of terms":<28} {self.n_terms}',
        f'{"Alpha":<28} {self.alpha:.6g}',
        f'{"Regularizer":<28} {self.regularizer}',
        f'{f"Background ({background_note})":<28} {self.background:.6g}'
        + (f' +/- {self.background_err:.3g}' if self.background_fitted else ''),
        f'{"Rg (Ang)":<28} {self.rg:.6g}',
        f'{"I(0)":<28} {self.i0:.6g}',
        f'{"Oscillations":<28} {self.oscillations:.4g}',
        f'{"Positive fraction":<28} {self.positive_fraction:.4g}',
        f'{"1-sigma positive fraction":<28} {self.sigma_positive_fraction:.4g}',
        f'{"Data chi-squared":<28} {self.data_chisq:.6g}',
        f'{"Effective dof (tr H)":<28} {self.effective_dof:.4g}',
        f'{"Approx. chi2 per residual dof":<28} {approx_chi2:.4g}',
        f'{"Points used":<28} {self.n_points_used} (of {self.accepted.size})',
        f'{"Condition number":<28} {self.condition_number:.4g}',
    ]
    if self.uncertainties_fabricated:
        lines.append(
            'WARNING: intensity uncertainties were fabricated; '
            'chi-squared-based diagnostics are not interpretable.'
        )
    if self.n_dropped_points:
        lines.append(f'NOTE: {self.n_dropped_points} point(s) dropped during preparation.')
    return '\n'.join(lines)

plot_fit(data, show=None, log_scale=True)

Plot data vs the fitted I(q) with residuals.

The dataset is passed explicitly — the result stores the model and the accepted mask, not the observed intensities. Same display convention as plot_results(). Pass log_scale=False when intensities include zero or negative values (a log axis silently omits such points).

Source code in src/sans_fitter/inversion/result.py
def plot_fit(self, data: Any, show: bool | None = None, log_scale: bool = True):
    """Plot data vs the fitted I(q) with residuals.

    The dataset is passed explicitly — the result stores the model and the
    accepted mask, not the observed intensities. Same display convention
    as plot_results(). Pass ``log_scale=False`` when intensities include
    zero or negative values (a log axis silently omits such points).
    """
    from ..plotting import plot_pr_fit

    return plot_pr_fit(data, self, show=show, log_scale=log_scale)

plot_pr(show=None)

Plot P(r) with its uncertainty band. Same display convention as plot_results().

Source code in src/sans_fitter/inversion/result.py
def plot_pr(self, show: bool | None = None):
    """Plot P(r) with its uncertainty band. Same display convention as plot_results()."""
    from ..plotting import plot_pr_distribution

    return plot_pr_distribution(self, show=show)

save_csv(filename)

Save inputs, diagnostics and the P(r) curve (r, P, dP columns) to CSV.

Source code in src/sans_fitter/inversion/result.py
def save_csv(self, filename: str) -> None:
    """Save inputs, diagnostics and the P(r) curve (r, P, dP columns) to CSV."""
    with open(filename, 'w') as f:
        f.write('# P(r) Inversion Results\n')
        f.write(f'# D_max (Ang): {self.d_max:.6g}\n')
        f.write(f'# Number of terms: {self.n_terms}\n')
        f.write(f'# Alpha: {self.alpha:.6g}\n')
        f.write(f'# Regularizer: {self.regularizer}\n')
        f.write(f'# Background: {self.background:.6g}\n')
        f.write(f'# Background fitted: {self.background_fitted}\n')
        if self.background_fitted:
            f.write(f'# Background uncertainty: {self.background_err:.6g}\n')
        f.write(f'# Rg (Ang): {self.rg:.6g}\n')
        f.write(f'# I(0): {self.i0:.6g}\n')
        f.write(f'# Oscillations: {self.oscillations:.6g}\n')
        f.write(f'# Positive fraction: {self.positive_fraction:.6g}\n')
        f.write(f'# 1-sigma positive fraction: {self.sigma_positive_fraction:.6g}\n')
        f.write(f'# Data chi-squared: {self.data_chisq:.6g}\n')
        f.write(f'# Effective dof: {self.effective_dof:.6g}\n')
        f.write(f'# Points used: {self.n_points_used} of {self.accepted.size}\n')
        f.write(f'# Uncertainties fabricated: {self.uncertainties_fabricated}\n')
        f.write(f'# Points dropped in preparation: {self.n_dropped_points}\n')
        f.write('#\n')
        f.write('r,P(r),dP(r)\n')
        for r_value, p_value, dp_value in zip(self.r, self.pr, self.pr_err, strict=True):
            f.write(f'{r_value:.6e},{p_value:.6e},{dp_value:.6e}\n')

DmaxScan dataclass

Result of :func:explore_dmax.

Arrays are success-only: every quantity array has the same length as d_max_values; D_max points whose inversion failed are omitted and recorded in failures as (d_max, message) pairs.

Source code in src/sans_fitter/inversion/estimate.py
@dataclass(slots=True)
class DmaxScan:
    """Result of :func:`explore_dmax`.

    Arrays are success-only: every quantity array has the same length as
    ``d_max_values``; D_max points whose inversion failed are omitted and
    recorded in ``failures`` as ``(d_max, message)`` pairs.
    """

    d_max_values: np.ndarray
    data_chisq: np.ndarray
    rg: np.ndarray
    i0: np.ndarray
    oscillations: np.ndarray
    positive_fraction: np.ndarray
    sigma_positive_fraction: np.ndarray
    background: np.ndarray
    alpha: np.ndarray
    n_terms: int
    failures: list[tuple[float, str]]

    def format_summary(self) -> str:
        """Return an ASCII table of the scanned quantities per D_max."""
        header = (
            f'{"D_max":>10} {"data_chisq":>12} {"Rg":>10} {"I(0)":>12} '
            f'{"Osc":>8} {"P+":>7} {"P+1s":>7} {"Bkg":>10}'
        )
        lines = [f'D_max scan ({self.n_terms} terms):', header, '-' * len(header)]
        for i, d in enumerate(self.d_max_values):
            lines.append(
                f'{d:>10.4g} {self.data_chisq[i]:>12.6g} {self.rg[i]:>10.4g} '
                f'{self.i0[i]:>12.6g} {self.oscillations[i]:>8.3g} '
                f'{self.positive_fraction[i]:>7.3f} {self.sigma_positive_fraction[i]:>7.3f} '
                f'{self.background[i]:>10.4g}'
            )
        if self.failures:
            lines.append('')
            lines.append('Failed points:')
            for d, message in self.failures:
                lines.append(f'  D_max = {d:.4g}: {message}')
        return '\n'.join(lines)

    def plot(self, quantity: str = 'rg', show: bool | None = None):
        """Plot a scanned quantity (or 'all') vs D_max. Same display convention
        as plot_results()."""
        from ..plotting import plot_dmax_scan

        return plot_dmax_scan(self, quantity=quantity, show=show)

format_summary()

Return an ASCII table of the scanned quantities per D_max.

Source code in src/sans_fitter/inversion/estimate.py
def format_summary(self) -> str:
    """Return an ASCII table of the scanned quantities per D_max."""
    header = (
        f'{"D_max":>10} {"data_chisq":>12} {"Rg":>10} {"I(0)":>12} '
        f'{"Osc":>8} {"P+":>7} {"P+1s":>7} {"Bkg":>10}'
    )
    lines = [f'D_max scan ({self.n_terms} terms):', header, '-' * len(header)]
    for i, d in enumerate(self.d_max_values):
        lines.append(
            f'{d:>10.4g} {self.data_chisq[i]:>12.6g} {self.rg[i]:>10.4g} '
            f'{self.i0[i]:>12.6g} {self.oscillations[i]:>8.3g} '
            f'{self.positive_fraction[i]:>7.3f} {self.sigma_positive_fraction[i]:>7.3f} '
            f'{self.background[i]:>10.4g}'
        )
    if self.failures:
        lines.append('')
        lines.append('Failed points:')
        for d, message in self.failures:
            lines.append(f'  D_max = {d:.4g}: {message}')
    return '\n'.join(lines)

plot(quantity='rg', show=None)

Plot a scanned quantity (or 'all') vs D_max. Same display convention as plot_results().

Source code in src/sans_fitter/inversion/estimate.py
def plot(self, quantity: str = 'rg', show: bool | None = None):
    """Plot a scanned quantity (or 'all') vs D_max. Same display convention
    as plot_results()."""
    from ..plotting import plot_dmax_scan

    return plot_dmax_scan(self, quantity=quantity, show=show)

AlphaEstimate dataclass

Result of :func:estimate_alpha.

Source code in src/sans_fitter/inversion/estimate.py
@dataclass(frozen=True)
class AlphaEstimate:
    """Result of :func:`estimate_alpha`."""

    alpha: float
    message: str

NTermsEstimate dataclass

Result of :func:estimate_n_terms. alpha was evaluated at the chosen n_terms during the scan and is the authoritative companion value — use it directly rather than re-estimating.

Source code in src/sans_fitter/inversion/estimate.py
@dataclass(frozen=True)
class NTermsEstimate:
    """Result of :func:`estimate_n_terms`. ``alpha`` was evaluated at the
    chosen ``n_terms`` during the scan and is the authoritative companion
    value — use it directly rather than re-estimating."""

    n_terms: int
    alpha: float
    message: str

InsufficientDataError

Bases: ValueError

The dataset does not carry enough usable points for the request.

Source code in src/sans_fitter/inversion/result.py
class InsufficientDataError(ValueError):
    """The dataset does not carry enough usable points for the request."""

PrEstimationError

Bases: RuntimeError

A parameter estimation scan found no acceptable candidate.

Source code in src/sans_fitter/inversion/result.py
class PrEstimationError(RuntimeError):
    """A parameter estimation scan found no acceptable candidate."""

invert(data, d_max, n_terms=DEFAULT_N_TERMS, alpha=0.0, fit_background=True, background=0.0, r_points=DEFAULT_R_POINTS, regularizer='corrected')

Invert I(q) into the pair distance distribution P(r) on [0, d_max].

P(r) is expanded in Moore's sine basis and the coefficients come from a regularized linear least-squares fit solved by SVD. The fit honours the dataset's qmin/qmax/mask (as set by SANSFitter.set_q_range) when present; raw datasets are used in full. The input dataset is never modified.

Parameters:

Name Type Description Default
data Any

A 1D dataset (Data1D) with x (q), y (I) and optionally dy. When dy is absent, uncertainties are fabricated (with a warning and a flag on the result).

required
d_max float

Maximum particle dimension in Angstrom; P(d_max) = 0 by construction.

required
n_terms int

Number of sine-basis terms.

DEFAULT_N_TERMS
alpha float

Regularization constant; 0 means an unregularized fit (a warning is emitted — use :func:estimate_alpha or :func:auto_invert for a data-driven value).

0.0
fit_background bool

Fit a flat background as an extra (unregularized) column. Use False for buffer-subtracted data — the usual protein workflow — together with background for any known residual level.

True
background float

Constant background subtracted from the data when fit_background is False. Ignored otherwise.

0.0
r_points int

Number of points of the r grid for P(r) evaluation and the derived integrals.

DEFAULT_R_POINTS
regularizer str

'corrected' (true second-derivative penalty with a resolved quadrature) or 'sasview' (SasView's exact operator, for compatibility/comparison).

'corrected'

Returns:

Name Type Description
A PrResult

class:PrResult with the solution, uncertainties, quality

PrResult

diagnostics and derived scalars (Rg, I(0), oscillations, positive

PrResult

fractions).

Raises:

Type Description
TypeError

If the dataset is not 1D (2D or SESANS data).

ValueError

For invalid arguments or unusable datasets.

InsufficientDataError

When too few usable points remain.

Source code in src/sans_fitter/inversion/solver.py
def invert(
    data: Any,
    d_max: float,
    n_terms: int = DEFAULT_N_TERMS,
    alpha: float = 0.0,
    fit_background: bool = True,
    background: float = 0.0,
    r_points: int = DEFAULT_R_POINTS,
    regularizer: str = 'corrected',
) -> PrResult:
    """Invert I(q) into the pair distance distribution P(r) on [0, d_max].

    P(r) is expanded in Moore's sine basis and the coefficients come from a
    regularized linear least-squares fit solved by SVD. The fit honours the
    dataset's ``qmin``/``qmax``/``mask`` (as set by ``SANSFitter.set_q_range``)
    when present; raw datasets are used in full. The input dataset is never
    modified.

    Args:
        data: A 1D dataset (``Data1D``) with ``x`` (q), ``y`` (I) and
            optionally ``dy``. When ``dy`` is absent, uncertainties are
            fabricated (with a warning and a flag on the result).
        d_max: Maximum particle dimension in Angstrom; P(d_max) = 0 by
            construction.
        n_terms: Number of sine-basis terms.
        alpha: Regularization constant; 0 means an unregularized fit (a
            warning is emitted — use :func:`estimate_alpha` or
            :func:`auto_invert` for a data-driven value).
        fit_background: Fit a flat background as an extra (unregularized)
            column. Use ``False`` for buffer-subtracted data — the usual
            protein workflow — together with ``background`` for any known
            residual level.
        background: Constant background subtracted from the data when
            ``fit_background`` is False. Ignored otherwise.
        r_points: Number of points of the r grid for P(r) evaluation and the
            derived integrals.
        regularizer: ``'corrected'`` (true second-derivative penalty with a
            resolved quadrature) or ``'sasview'`` (SasView's exact operator,
            for compatibility/comparison).

    Returns:
        A :class:`PrResult` with the solution, uncertainties, quality
        diagnostics and derived scalars (Rg, I(0), oscillations, positive
        fractions).

    Raises:
        TypeError: If the dataset is not 1D (2D or SESANS data).
        ValueError: For invalid arguments or unusable datasets.
        InsufficientDataError: When too few usable points remain.
    """
    _validate_invert_args(d_max, n_terms, alpha, r_points, regularizer, background)
    if alpha == 0.0:
        warnings.warn(
            'alpha = 0 gives an unregularized fit, which is usually noise-dominated; '
            'consider estimate_alpha() or auto_invert() for a data-driven value.',
            stacklevel=2,
        )
    prep = _prepare_data(data)
    return _invert_prepared(
        prep, d_max, n_terms, alpha, fit_background, background, r_points, regularizer
    )

auto_invert(data, d_max, fit_background=True, background=0.0, r_points=DEFAULT_R_POINTS, regularizer='corrected')

One-shot inversion with automatic selection of n_terms and alpha.

Runs the :func:estimate_n_terms scan and inverts with the estimate's (n_terms, alpha) pair. The background (used when fit_background is False) is applied during the selection scan as well, so the parameters are chosen on the same problem the final inversion solves. The chosen values are recorded on the result (result.n_terms, result.alpha).

Source code in src/sans_fitter/inversion/estimate.py
def auto_invert(
    data: Any,
    d_max: float,
    fit_background: bool = True,
    background: float = 0.0,
    r_points: int = DEFAULT_R_POINTS,
    regularizer: str = 'corrected',
) -> PrResult:
    """One-shot inversion with automatic selection of n_terms and alpha.

    Runs the :func:`estimate_n_terms` scan and inverts with the estimate's
    ``(n_terms, alpha)`` pair. The ``background`` (used when ``fit_background``
    is False) is applied during the selection scan as well, so the parameters
    are chosen on the same problem the final inversion solves. The chosen
    values are recorded on the result (``result.n_terms``, ``result.alpha``).
    """
    _validate_invert_args(d_max, 1, 0.0, r_points, regularizer, background)
    prep = _prepare_data(data)
    estimate = _estimate_n_terms_prepared(prep, d_max, fit_background, regularizer, background)
    logger.debug('auto_invert: %s', estimate.message)
    return _invert_prepared(
        prep,
        d_max,
        estimate.n_terms,
        estimate.alpha,
        fit_background,
        background,
        r_points,
        regularizer,
    )

estimate_alpha(data, d_max, n_terms, fit_background=True, regularizer='corrected', background=0.0)

Estimate the regularization constant alpha for a given number of terms.

Starts from the Frobenius-balance suggestion (the alpha that balances the data and unit-alpha penalty blocks) and descends geometrically, returning the largest alpha just before spurious structure (a second peak) appears in P(r), or — for smooth single-peak shapes where that never happens — the largest alpha satisfying the discrepancy principle (data chi-squared down to the number of points).

background is the fixed level subtracted when fit_background is False (ignored otherwise) — pass the same value you will pass to :func:invert, so the estimate is made on the problem actually solved. The heuristic evaluates candidates on the default 101-point r grid regardless of the r_points used for the final inversion.

Raises:

Type Description
TypeError

If the dataset is not 1D (2D or SESANS data).

PrEstimationError

When no alpha in the scan yields a solvable inversion.

Source code in src/sans_fitter/inversion/estimate.py
def estimate_alpha(
    data: Any,
    d_max: float,
    n_terms: int,
    fit_background: bool = True,
    regularizer: str = 'corrected',
    background: float = 0.0,
) -> AlphaEstimate:
    """Estimate the regularization constant alpha for a given number of terms.

    Starts from the Frobenius-balance suggestion (the alpha that balances the
    data and unit-alpha penalty blocks) and descends geometrically, returning
    the largest alpha just before spurious structure (a second peak) appears
    in P(r), or — for smooth single-peak shapes where that never happens —
    the largest alpha satisfying the discrepancy principle (data chi-squared
    down to the number of points).

    ``background`` is the fixed level subtracted when ``fit_background`` is
    False (ignored otherwise) — pass the same value you will pass to
    :func:`invert`, so the estimate is made on the problem actually solved.
    The heuristic evaluates candidates on the default 101-point r grid
    regardless of the ``r_points`` used for the final inversion.

    Raises:
        TypeError: If the dataset is not 1D (2D or SESANS data).
        PrEstimationError: When no alpha in the scan yields a solvable
            inversion.
    """
    _validate_invert_args(d_max, n_terms, 0.0, DEFAULT_R_POINTS, regularizer, background)
    prep = _prepare_data(data)
    return _estimate_alpha_prepared(prep, d_max, n_terms, fit_background, regularizer, background)

estimate_n_terms(data, d_max, fit_background=True, regularizer='corrected', background=0.0)

Estimate the number of basis terms (and the matching alpha).

Scans admissible N, preferring the smallest N that fits the data with a significantly positive P(r) (1-sigma positive fraction >= 0.9, with 0.8 and 0.7 fallback buckets). The scan stops early once P(r) becomes wildly oscillatory.

background is the fixed level subtracted when fit_background is False (ignored otherwise) — pass the same value you will pass to :func:invert, so the selection is made on the problem actually solved. The heuristic evaluates candidates on the default 101-point r grid regardless of the r_points used for the final inversion.

Raises:

Type Description
TypeError

If the dataset is not 1D (2D or SESANS data).

InsufficientDataError

When no N is admissible for the dataset.

PrEstimationError

When the scan has no acceptable candidate.

Source code in src/sans_fitter/inversion/estimate.py
def estimate_n_terms(
    data: Any,
    d_max: float,
    fit_background: bool = True,
    regularizer: str = 'corrected',
    background: float = 0.0,
) -> NTermsEstimate:
    """Estimate the number of basis terms (and the matching alpha).

    Scans admissible N, preferring the smallest N that fits the data with a
    significantly positive P(r) (1-sigma positive fraction >= 0.9, with 0.8
    and 0.7 fallback buckets). The scan stops early once P(r) becomes wildly
    oscillatory.

    ``background`` is the fixed level subtracted when ``fit_background`` is
    False (ignored otherwise) — pass the same value you will pass to
    :func:`invert`, so the selection is made on the problem actually solved.
    The heuristic evaluates candidates on the default 101-point r grid
    regardless of the ``r_points`` used for the final inversion.

    Raises:
        TypeError: If the dataset is not 1D (2D or SESANS data).
        InsufficientDataError: When no N is admissible for the dataset.
        PrEstimationError: When the scan has no acceptable candidate.
    """
    _validate_invert_args(d_max, 1, 0.0, DEFAULT_R_POINTS, regularizer, background)
    prep = _prepare_data(data)
    return _estimate_n_terms_prepared(prep, d_max, fit_background, regularizer, background)

explore_dmax(data, d_max, n_terms=None, alpha=None, dmin=None, dmax=None, n_points=DMAX_SCAN_POINTS, refit_alpha=False, fit_background=True, regularizer='corrected', background=0.0)

Re-invert over a range of D_max values to locate a stable choice.

A good D_max shows a plateau in Rg and I(0) and a minimum in the data chi-squared. Defaults: scan 0.9*d_max .. 1.1*d_max in n_points steps, with n_terms/alpha estimated once at the central d_max and held fixed across the scan (comparable across D_max thanks to the corrected operator's resolved quadrature). refit_alpha=True recomputes the alpha suggestion at each D_max instead.

background is the fixed level subtracted when fit_background is False (ignored otherwise) — pass the same value you use with :func:invert/:func:auto_invert, so the scan explores the same problem the final inversion solves.

Note

The scan suppresses the per-point Shannon-support warnings via the process-global warnings filter, so it is not thread-safe: inversions running concurrently in other threads may have those warnings swallowed while a scan is in flight.

Raises:

Type Description
TypeError

If the dataset is not 1D (2D or SESANS data).

ValueError

For an invalid scan range.

InsufficientDataError / PrEstimationError

From the central estimation when n_terms/alpha are not supplied, or when every scan point fails.

Source code in src/sans_fitter/inversion/estimate.py
def explore_dmax(
    data: Any,
    d_max: float,
    n_terms: int | None = None,
    alpha: float | None = None,
    dmin: float | None = None,
    dmax: float | None = None,
    n_points: int = DMAX_SCAN_POINTS,
    refit_alpha: bool = False,
    fit_background: bool = True,
    regularizer: str = 'corrected',
    background: float = 0.0,
) -> DmaxScan:
    """Re-invert over a range of D_max values to locate a stable choice.

    A good D_max shows a plateau in Rg and I(0) and a minimum in the data
    chi-squared. Defaults: scan ``0.9*d_max .. 1.1*d_max`` in ``n_points``
    steps, with ``n_terms``/``alpha`` estimated once at the central ``d_max``
    and held fixed across the scan (comparable across D_max thanks to the
    corrected operator's resolved quadrature). ``refit_alpha=True`` recomputes
    the alpha suggestion at each D_max instead.

    ``background`` is the fixed level subtracted when ``fit_background`` is
    False (ignored otherwise) — pass the same value you use with
    :func:`invert`/:func:`auto_invert`, so the scan explores the same problem
    the final inversion solves.

    Note:
        The scan suppresses the per-point Shannon-support warnings via the
        process-global ``warnings`` filter, so it is not thread-safe:
        inversions running concurrently in other threads may have those
        warnings swallowed while a scan is in flight.

    Raises:
        TypeError: If the dataset is not 1D (2D or SESANS data).
        ValueError: For an invalid scan range.
        InsufficientDataError / PrEstimationError: From the central estimation
            when ``n_terms``/``alpha`` are not supplied, or when every scan
            point fails.
    """
    _validate_invert_args(
        d_max,
        1 if n_terms is None else n_terms,
        0.0 if alpha is None else alpha,
        DEFAULT_R_POINTS,
        regularizer,
        background,
    )
    _require_integer('n_points', n_points)
    low = DMAX_SCAN_LOW_FACTOR * d_max if dmin is None else dmin
    high = DMAX_SCAN_HIGH_FACTOR * d_max if dmax is None else dmax
    if not (np.isfinite(low) and np.isfinite(high) and 0 < low < high):
        raise ValueError(f'Invalid D_max scan range: [{low}, {high}].')
    if n_points < 2:
        raise ValueError(f'n_points must be at least 2, got {n_points}.')

    prep = _prepare_data(data)

    # One scan-level advisory instead of a per-point repeat (the per-point
    # copies are suppressed inside the loop below).
    support = np.pi / float(prep.q.min())
    if high > support:
        warnings.warn(
            f'Part of the D_max scan range ([{low:g}, {high:g}]) exceeds '
            f'pi/q_min = {support:g}; distances beyond it are only weakly '
            'constrained by the lowest measured q (low-q support heuristic).',
            stacklevel=2,
        )

    if n_terms is None:
        estimate = _estimate_n_terms_prepared(prep, d_max, fit_background, regularizer, background)
        n_terms = estimate.n_terms
        if alpha is None:
            alpha = estimate.alpha
    elif alpha is None:
        alpha = _estimate_alpha_prepared(
            prep, d_max, n_terms, fit_background, regularizer, background
        ).alpha

    scan_values = np.linspace(low, high, n_points)
    collected: dict[str, list[float]] = {
        key: []
        for key in (
            'd_max',
            'data_chisq',
            'rg',
            'i0',
            'oscillations',
            'positive_fraction',
            'sigma_positive_fraction',
            'background',
            'alpha',
        )
    }
    failures: list[tuple[float, str]] = []
    for d in scan_values:
        try:
            # The scan deliberately varies D_max at fixed N, so both per-point
            # support warnings (channel count and pi/q_min) are noise by
            # construction here — the scan-level advisory above covers them.
            with warnings.catch_warnings():
                warnings.filterwarnings('ignore', message='.*Shannon channels.*')
                warnings.filterwarnings('ignore', message='.*pi/q_min.*')
                alpha_d = (
                    _estimate_alpha_prepared(
                        prep, float(d), n_terms, fit_background, regularizer, background
                    ).alpha
                    if refit_alpha
                    else alpha
                )
                result = _invert_prepared(
                    prep,
                    float(d),
                    n_terms,
                    alpha_d,
                    fit_background,
                    background,
                    DEFAULT_R_POINTS,
                    regularizer,
                )
        except (ValueError, np.linalg.LinAlgError, PrEstimationError) as e:
            failures.append((float(d), str(e)))
            continue
        collected['d_max'].append(float(d))
        collected['data_chisq'].append(result.data_chisq)
        collected['rg'].append(result.rg)
        collected['i0'].append(result.i0)
        collected['oscillations'].append(result.oscillations)
        collected['positive_fraction'].append(result.positive_fraction)
        collected['sigma_positive_fraction'].append(result.sigma_positive_fraction)
        collected['background'].append(result.background)
        collected['alpha'].append(alpha_d)

    if not collected['d_max']:
        details = '; '.join(f'D_max={d:g}: {message}' for d, message in failures)
        raise PrEstimationError(f'Every D_max scan point failed: {details}')

    return DmaxScan(
        d_max_values=np.asarray(collected['d_max']),
        data_chisq=np.asarray(collected['data_chisq']),
        rg=np.asarray(collected['rg']),
        i0=np.asarray(collected['i0']),
        oscillations=np.asarray(collected['oscillations']),
        positive_fraction=np.asarray(collected['positive_fraction']),
        sigma_positive_fraction=np.asarray(collected['sigma_positive_fraction']),
        background=np.asarray(collected['background']),
        alpha=np.asarray(collected['alpha']),
        n_terms=int(n_terms),
        failures=failures,
    )

examples

Curated example datasets and a simulator for generating data. See the Example Data guide for the full collection.

sans_fitter.examples

Example datasets and simulated data (issue #53).

Two complementary ways to get data without hunting for a file:

Bundled example datasets — the same collection SasView ships, curated here with the model that fits each one and sensible starting parameters::

>>> from sans_fitter import examples
>>> examples.describe()                        # what is available
>>> data = examples.load('silica_spheres')     # fit-ready Data1D
>>> fitter = examples.load_fitter('silica_spheres')  # data + model + parameters
>>> result = fitter.fit()

The files themselves are not vendored into this package. They live inside the installed sasdata distribution (sasdata/example_data/1d_data), which is already a hard dependency, so the set stays in sync with sasdata and costs nothing to ship. :func:load resolves them through :mod:importlib.resources and raises an actionable error if the layout ever changes.

Simulated data — computed on demand from any sasmodels model, with known ground truth::

>>> data = examples.simulate('sphere', radius=50, noise=0.03, seed=0)
>>> data.truth
{'radius': 50.0, 'sld': 1.0, ...}

Simulated data is the better teaching tool when you want a self-checking exercise ("fit this, you should recover radius = 50"), works for any of the ~100 sasmodels models, and needs no files on disk. Real bundled data is the better tool for everything a simulation will not show you: instrument resolution, sloping backgrounds, noisy high-Q tails and negative intensities after subtraction.

Both routes return a fit-ready Data1Dqmin/qmax/mask set — that can be handed straight to :meth:SANSFitter.set_data or to :mod:sans_fitter.data.ops.

Example dataclass

A curated bundled dataset and how to fit it.

Attributes:

Name Type Description
name str

Short key used by :func:load and :func:load_fitter.

filename str

File name within the sasdata 1D example directory.

model str

sasmodels model name that describes this sample.

description str

What the sample is and what it is useful for teaching.

params dict[str, dict[str, Any]]

Suggested starting configuration, as {param_name: {'value': ..., 'min': ..., 'max': ..., 'vary': ...}}. Passed verbatim to :meth:SANSFitter.set_param.

structure_factor str | None

Structure factor to apply, if the sample is concentrated enough to need one.

polydispersity dict[str, dict[str, Any]]

{param_name: {'pd_width': ..., 'vary': ...}}, passed to :meth:SANSFitter.set_pd_param.

truth dict[str, float] | None

Generating parameters, for simulated files only. None for measured data, where no ground truth exists.

notes str

Caveats worth knowing before fitting — engine restrictions, known difficulties. Empty when there are none.

tags tuple[str, ...]

Free-form labels for filtering with :func:list_examples.

source str

Provenance note.

Source code in src/sans_fitter/examples.py
@dataclass(frozen=True)
class Example:
    """A curated bundled dataset and how to fit it.

    Attributes:
        name: Short key used by :func:`load` and :func:`load_fitter`.
        filename: File name within the sasdata 1D example directory.
        model: sasmodels model name that describes this sample.
        description: What the sample is and what it is useful for teaching.
        params: Suggested starting configuration, as
            ``{param_name: {'value': ..., 'min': ..., 'max': ..., 'vary': ...}}``.
            Passed verbatim to :meth:`SANSFitter.set_param`.
        structure_factor: Structure factor to apply, if the sample is
            concentrated enough to need one.
        polydispersity: ``{param_name: {'pd_width': ..., 'vary': ...}}``,
            passed to :meth:`SANSFitter.set_pd_param`.
        truth: Generating parameters, for simulated files only. ``None`` for
            measured data, where no ground truth exists.
        notes: Caveats worth knowing before fitting — engine restrictions,
            known difficulties. Empty when there are none.
        tags: Free-form labels for filtering with :func:`list_examples`.
        source: Provenance note.
    """

    name: str
    filename: str
    model: str
    description: str
    params: dict[str, dict[str, Any]] = field(default_factory=dict)
    structure_factor: str | None = None
    polydispersity: dict[str, dict[str, Any]] = field(default_factory=dict)
    truth: dict[str, float] | None = None
    notes: str = ''
    tags: tuple[str, ...] = ()
    source: str = 'sasdata example_data'

list_examples(tag=None)

Return the names of the bundled examples, optionally filtered by tag.

Parameters:

Name Type Description Default
tag str | None

Only return examples carrying this tag (e.g. 'measured', 'simulated', 'structure-factor', 'polydispersity', 'resolution').

None

Returns:

Type Description
list[str]

Sorted example names.

Source code in src/sans_fitter/examples.py
def list_examples(tag: str | None = None) -> list[str]:
    """Return the names of the bundled examples, optionally filtered by *tag*.

    Args:
        tag: Only return examples carrying this tag (e.g. ``'measured'``,
            ``'simulated'``, ``'structure-factor'``, ``'polydispersity'``,
            ``'resolution'``).

    Returns:
        Sorted example names.
    """
    names = sorted(_REGISTRY)
    if tag is None:
        return names
    return [name for name in names if tag in _REGISTRY[name].tags]

describe(name=None)

Print a human-readable summary of the bundled examples.

Parameters:

Name Type Description Default
name str | None

Print the full detail for a single example. When omitted, print a one-line-per-example overview of the whole collection.

None
Source code in src/sans_fitter/examples.py
def describe(name: str | None = None) -> None:
    """Print a human-readable summary of the bundled examples.

    Args:
        name: Print the full detail for a single example. When omitted, print a
            one-line-per-example overview of the whole collection.
    """
    if name is not None:
        _describe_one(get_example(name))
        return

    print(f'{len(_REGISTRY)} bundled example datasets (from the installed sasdata package)')
    print()
    width = max(len(key) for key in _REGISTRY)
    for key in sorted(_REGISTRY):
        example = _REGISTRY[key]
        print(f'  {key:<{width}}  {example.model:<18}  {_summarize(example.description)}')
    print()
    print("Load one with examples.load('name') or examples.load_fitter('name');")
    print("see full detail with examples.describe('name').")

get_example(name)

Return the :class:Example record for name.

Raises:

Type Description
KeyError

If name is not a known example.

Source code in src/sans_fitter/examples.py
def get_example(name: str) -> Example:
    """Return the :class:`Example` record for *name*.

    Raises:
        KeyError: If *name* is not a known example.
    """
    try:
        return _REGISTRY[name]
    except KeyError:
        available = ', '.join(sorted(_REGISTRY))
        raise KeyError(f"Unknown example '{name}'. Available: {available}") from None

example_path(name)

Return the filesystem path of a bundled example file.

Useful when you want to pass the file to :func:sans_fitter.data.ops.load or to any other reader yourself.

Raises:

Type Description
KeyError

If name is not a known example.

FileNotFoundError

If the file is missing from the sasdata install.

Source code in src/sans_fitter/examples.py
def example_path(name: str) -> str:
    """Return the filesystem path of a bundled example file.

    Useful when you want to pass the file to :func:`sans_fitter.data.ops.load`
    or to any other reader yourself.

    Raises:
        KeyError: If *name* is not a known example.
        FileNotFoundError: If the file is missing from the sasdata install.
    """
    example = get_example(name)
    path = _example_dir() / example.filename
    if not path.is_file():
        raise FileNotFoundError(
            f"Example '{name}' expects the file '{example.filename}' in the "
            f'sasdata example directory, but it is not there. The installed '
            f'sasdata version may have renamed or removed it.'
        )
    return str(path)

load(name)

Load a bundled example dataset and return a fit-ready Data1D.

Goes through the same loader as :meth:SANSFitter.load_data, so the result behaves identically to any dataset you load yourself.

Parameters:

Name Type Description Default
name str

Example name — see :func:list_examples.

required

Returns:

Type Description
Data1D

A Data1D with qmin/qmax/mask set.

Raises:

Type Description
KeyError

If name is not a known example.

FileNotFoundError

If the file is missing from the sasdata install.

ValueError

If the file cannot be parsed.

Source code in src/sans_fitter/examples.py
def load(name: str) -> Data1D:
    """Load a bundled example dataset and return a fit-ready ``Data1D``.

    Goes through the same loader as :meth:`SANSFitter.load_data`, so the result
    behaves identically to any dataset you load yourself.

    Args:
        name: Example name — see :func:`list_examples`.

    Returns:
        A ``Data1D`` with ``qmin``/``qmax``/``mask`` set.

    Raises:
        KeyError: If *name* is not a known example.
        FileNotFoundError: If the file is missing from the sasdata install.
        ValueError: If the file cannot be parsed.
    """
    return load_sans_data(example_path(name))

load_fitter(name, quiet=True)

Return a :class:SANSFitter preloaded with an example and ready to fit.

Sets the data, the model, any structure factor and polydispersity, and the suggested starting parameters — so a tutorial reaches fitter.fit() in one line::

>>> fitter = examples.load_fitter('silica_spheres')
>>> result = fitter.fit()

The starting parameters are coarse values chosen to put the model in the right basin, not published results. Check get_example(name).truth to see whether ground truth is known.

Parameters:

Name Type Description Default
name str

Example name — see :func:list_examples.

required
quiet bool

Suppress the progress messages that set_data/set_model normally print, so the preset is a single quiet step. Pass False to see them.

True

Returns:

Type Description
SANSFitter

A configured SANSFitter.

Source code in src/sans_fitter/examples.py
def load_fitter(name: str, quiet: bool = True) -> SANSFitter:
    """Return a :class:`SANSFitter` preloaded with an example and ready to fit.

    Sets the data, the model, any structure factor and polydispersity, and the
    suggested starting parameters — so a tutorial reaches ``fitter.fit()`` in
    one line::

        >>> fitter = examples.load_fitter('silica_spheres')
        >>> result = fitter.fit()

    The starting parameters are coarse values chosen to put the model in the
    right basin, not published results. Check ``get_example(name).truth`` to see
    whether ground truth is known.

    Args:
        name: Example name — see :func:`list_examples`.
        quiet: Suppress the progress messages that ``set_data``/``set_model``
            normally print, so the preset is a single quiet step. Pass ``False``
            to see them.

    Returns:
        A configured ``SANSFitter``.
    """
    example = get_example(name)
    data = load(name)

    with _maybe_silenced(quiet):
        fitter = SANSFitter()
        fitter.set_data(data)
        fitter.set_model(example.model)

        if example.structure_factor is not None:
            fitter.set_structure_factor(example.structure_factor)

        for param_name, settings in example.params.items():
            fitter.set_param(param_name, **settings)

        if example.polydispersity:
            fitter.enable_polydispersity(True)
            for param_name, settings in example.polydispersity.items():
                fitter.set_pd_param(param_name, **settings)

    return fitter

simulate(model='sphere', qmin=0.005, qmax=0.5, npoints=100, noise=0.02, seed=0, dq=None, q=None, **params)

Simulate a SANS dataset from any sasmodels model, with known truth.

The generating parameters are attached to the result as data.truth, so a tutorial can state the answer up front and the reader can check whether the fit recovers it.

Parameters:

Name Type Description Default
model str

sasmodels model name, e.g. 'sphere', 'cylinder', 'core_shell_sphere'. Product models such as 'sphere@hardsphere' work too.

'sphere'
qmin float

Lowest Q, in 1/A. Ignored when q is given.

0.005
qmax float

Highest Q, in 1/A. Ignored when q is given.

0.5
npoints int

Number of log-spaced Q points. Ignored when q is given.

100
noise float

Relative noise level. 0.02 gives a point at the median intensity a 2% error bar; uncertainties follow counting statistics (dI = noise * sqrt(|I| * median|I|)), so the relative error grows in the dim form-factor minima and the high-Q tail as it does in real data. Pass 0 for noise-free data — note that the bumps engine refuses data without uncertainties.

0.02
seed int | None

Seed for the noise, so results are reproducible. Pass None for fresh noise on every call.

0
dq float | None

Relative resolution width. When given, dx = dq * q is attached and the simulated intensity is smeared accordingly, matching what an instrument would measure.

None
q ndarray | None

Explicit Q array, overriding qmin/qmax/npoints. Use this to simulate onto the grid of a real dataset.

None
**params Any

Model parameters, e.g. radius=50, sld=4.0. Anything unspecified keeps its sasmodels default. A polydispersity width such as radius_pd=0.15 is enough on its own — the companion _pd_n/_pd_type/_pd_nsigma settings are filled from :data:~sans_fitter.polydispersity.PD_DEFAULTS, because sasmodels silently ignores a width with no _pd_n.

{}

Returns:

Type Description
Data1D

A fit-ready Data1D with an extra truth attribute holding the

Data1D

full parameter set used to generate it.

Raises:

Type Description
ValueError

If the model name is unknown, a parameter is not valid for the model, or the Q range is not positive and increasing.

Example

data = simulate('sphere', radius=50, noise=0.03, seed=1) data.truth['radius'] 50.0

Source code in src/sans_fitter/examples.py
def simulate(
    model: str = 'sphere',
    qmin: float = 0.005,
    qmax: float = 0.5,
    npoints: int = 100,
    noise: float = 0.02,
    seed: int | None = 0,
    dq: float | None = None,
    q: np.ndarray | None = None,
    **params: Any,
) -> Data1D:
    """Simulate a SANS dataset from any sasmodels model, with known truth.

    The generating parameters are attached to the result as ``data.truth``, so
    a tutorial can state the answer up front and the reader can check whether
    the fit recovers it.

    Args:
        model: sasmodels model name, e.g. ``'sphere'``, ``'cylinder'``,
            ``'core_shell_sphere'``. Product models such as
            ``'sphere@hardsphere'`` work too.
        qmin: Lowest Q, in 1/A. Ignored when *q* is given.
        qmax: Highest Q, in 1/A. Ignored when *q* is given.
        npoints: Number of log-spaced Q points. Ignored when *q* is given.
        noise: Relative noise level. ``0.02`` gives a point at the median
            intensity a 2% error bar; uncertainties follow counting statistics
            (``dI = noise * sqrt(|I| * median|I|)``), so the relative error
            grows in the dim form-factor minima and the high-Q tail as it does
            in real data. Pass ``0`` for noise-free data — note that the bumps
            engine refuses data without uncertainties.
        seed: Seed for the noise, so results are reproducible. Pass ``None``
            for fresh noise on every call.
        dq: Relative resolution width. When given, ``dx = dq * q`` is attached
            and the *simulated intensity is smeared accordingly*, matching what
            an instrument would measure.
        q: Explicit Q array, overriding *qmin*/*qmax*/*npoints*. Use this to
            simulate onto the grid of a real dataset.
        **params: Model parameters, e.g. ``radius=50``, ``sld=4.0``. Anything
            unspecified keeps its sasmodels default. A polydispersity width
            such as ``radius_pd=0.15`` is enough on its own — the companion
            ``_pd_n``/``_pd_type``/``_pd_nsigma`` settings are filled from
            :data:`~sans_fitter.polydispersity.PD_DEFAULTS`, because sasmodels
            silently ignores a width with no ``_pd_n``.

    Returns:
        A fit-ready ``Data1D`` with an extra ``truth`` attribute holding the
        full parameter set used to generate it.

    Raises:
        ValueError: If the model name is unknown, a parameter is not valid for
            the model, or the Q range is not positive and increasing.

    Example:
        >>> data = simulate('sphere', radius=50, noise=0.03, seed=1)
        >>> data.truth['radius']
        50.0
    """
    q_values = _build_q(q, qmin, qmax, npoints)

    if not np.isfinite(noise) or noise < 0:
        raise ValueError(f'noise must be non-negative and finite, got {noise}.')
    if dq is not None and (not np.isfinite(dq) or dq < 0):
        raise ValueError(f'dq must be non-negative and finite, got {dq}.')

    try:
        kernel = load_model(model, dtype='single', platform='dll')
    except Exception as exc:
        raise ValueError(f"Failed to load model '{model}': {exc}") from exc

    defaults = _model_defaults(kernel)
    unknown = [
        key for key in params if key not in defaults and not _is_polydispersity_key(key, defaults)
    ]
    if unknown:
        raise ValueError(
            f'Parameter(s) {", ".join(sorted(unknown))} are not valid for model '
            f"'{model}'. Valid parameters: {', '.join(sorted(defaults))}."
        )

    params = _complete_polydispersity(params)

    resolution = None if dq is None else np.asarray(q_values) * float(dq)
    template = normalize_sans_data(
        Data1D(
            x=q_values,
            y=np.zeros_like(q_values),
            dy=np.zeros_like(q_values),
            dx=resolution,
        )
    )

    calculator = DirectModel(template, kernel)
    intensity = np.asarray(calculator(**params), dtype=float)

    intensity, uncertainty = _apply_noise(intensity, noise, seed)

    data = normalize_sans_data(Data1D(x=q_values, y=intensity, dy=uncertainty, dx=resolution))
    # Record what generated this dataset. `truth` merges the explicit arguments
    # over the model defaults, so it is the complete parameter set, not just
    # what the caller happened to pass.
    data.truth = {**defaults, **params}
    data.filename = f'simulated_{model}'
    return data

simulate_pair(model='sphere', background_level=0.5, noise=0.02, seed=0, **kwargs)

Simulate a matched sample and background pair for dataset arithmetic.

Both datasets land on an identical Q grid, which is what :mod:sans_fitter.data.ops requires — the sample is model + flat background, and the background dataset is that flat level alone::

>>> sample, background = simulate_pair('sphere', radius=50)
>>> subtracted = data_ops.subtract(sample, background)
>>> fitter = SANSFitter()
>>> fitter.set_data(subtracted)

Parameters:

Name Type Description Default
model str

sasmodels model name for the sample.

'sphere'
background_level float

Flat intensity added to the sample and carried by the background dataset. An explicit background= model parameter in **kwargs is treated as part of the sample's signal, not the flat level: it is added on top of background_level in the sample only, so it survives subtract(sample, background).

0.5
noise float

Relative Gaussian noise, applied independently to each dataset.

0.02
seed int | None

Seed for reproducibility. The background uses seed + 1 so the two datasets do not share identical noise.

0
**kwargs Any

Forwarded to :func:simulate — Q range, dq, and model parameters.

{}

Returns:

Type Description
tuple[Data1D, Data1D]

(sample, background), both fit-ready and on the same Q grid.

Source code in src/sans_fitter/examples.py
def simulate_pair(
    model: str = 'sphere',
    background_level: float = 0.5,
    noise: float = 0.02,
    seed: int | None = 0,
    **kwargs: Any,
) -> tuple[Data1D, Data1D]:
    """Simulate a matched sample and background pair for dataset arithmetic.

    Both datasets land on an identical Q grid, which is what
    :mod:`sans_fitter.data.ops` requires — the sample is *model + flat
    background*, and the background dataset is that flat level alone::

        >>> sample, background = simulate_pair('sphere', radius=50)
        >>> subtracted = data_ops.subtract(sample, background)
        >>> fitter = SANSFitter()
        >>> fitter.set_data(subtracted)

    Args:
        model: sasmodels model name for the sample.
        background_level: Flat intensity added to the sample and carried by the
            background dataset. An explicit ``background=`` model parameter in
            ``**kwargs`` is treated as part of the sample's signal, not the
            flat level: it is added on top of ``background_level`` in the
            sample only, so it survives ``subtract(sample, background)``.
        noise: Relative Gaussian noise, applied independently to each dataset.
        seed: Seed for reproducibility. The background uses ``seed + 1`` so the
            two datasets do not share identical noise.
        **kwargs: Forwarded to :func:`simulate` — Q range, ``dq``, and model
            parameters.

    Returns:
        ``(sample, background)``, both fit-ready and on the same Q grid.
    """
    params = dict(kwargs)
    params['background'] = params.get('background', 0.0) + background_level

    sample = simulate(model, noise=noise, seed=seed, **params)

    # 'empty' reproduces the flat level alone: same Q grid, scale zeroed so only
    # `background` survives.
    empty_params = {key: value for key, value in params.items() if _is_grid_kwarg(key)}
    background = simulate(
        model,
        noise=noise,
        seed=None if seed is None else seed + 1,
        scale=0.0,
        background=background_level,
        **empty_params,
    )

    # data_ops names results after their operands, so distinct labels keep the
    # provenance trail readable ('sample - background', not 'x - x').
    sample.filename = f'simulated_{model}_sample'
    sample.title = sample.filename
    background.filename = f'simulated_{model}_background'
    background.title = background.filename
    return sample, background

ParameterManager

Internal class for managing model parameters and polydispersity settings.

sans_fitter.modeling.parameters.ParameterManager

Manages model parameters for SANS fitting.

Handles parameter initialization, validation, bounds management, special logic for structure factor parameter linking, and polydispersity support.

Attributes:

Name Type Description
params dict[str, dict[str, Any]]

Dictionary of parameter configurations

model_name str | None

Name of the current model

structure_factor_name str | None

Name of applied structure factor (if any)

radius_effective_mode str | None

Mode for handling radius_effective ('unconstrained' or 'link_radius')

polydisperse_params dict[str, dict[str, Any]]

Dictionary of polydispersity parameters

pd_enabled dict[str, dict[str, Any]]

Whether polydispersity is globally enabled

Source code in src/sans_fitter/modeling/parameters.py
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
class ParameterManager:
    """
    Manages model parameters for SANS fitting.

    Handles parameter initialization, validation, bounds management,
    special logic for structure factor parameter linking, and polydispersity support.

    Attributes:
        params: Dictionary of parameter configurations
        model_name: Name of the current model
        structure_factor_name: Name of applied structure factor (if any)
        radius_effective_mode: Mode for handling radius_effective ('unconstrained' or 'link_radius')
        polydisperse_params: Dictionary of polydispersity parameters
        pd_enabled: Whether polydispersity is globally enabled
    """

    def __init__(self):
        """Initialize the parameter manager."""
        self.params: dict[str, dict[str, Any]] = {}
        self.model_name: str | None = None
        self._sf_manager = StructureFactorManager()
        self._pd_manager = PolydispersityManager()

        # Composite-model state (see 46_COMPOSITE_MODELS.md)
        # _components: ordered (prefix, moniker, part_model_name) triples.
        self._components: list[tuple[str, str, str]] = []
        # _links: equality links, follower -> target, stored under whatever
        # names self.params uses (aliases on the set_models path, canonical
        # names on the raw set_model path).
        self._links: dict[str, str] = {}
        # Alias layer (set_models path only): alias -> canonical name. Shared
        # parameters additionally map one alias to several canonical names.
        self._alias_to_canonical: dict[str, str] = {}
        self._canonical_to_alias: dict[str, str] = {}
        self._shared_to_canonicals: dict[str, list[str]] = {}

    @property
    def _structure_factor_name(self) -> str | None:
        return self._sf_manager.name

    @_structure_factor_name.setter
    def _structure_factor_name(self, value: str | None) -> None:
        self._sf_manager.name = value

    @property
    def _radius_effective_mode(self) -> str:
        return self._sf_manager.radius_effective_mode

    @_radius_effective_mode.setter
    def _radius_effective_mode(self, value: str) -> None:
        self._sf_manager.radius_effective_mode = value

    @property
    def _form_factor_params(self) -> dict[str, dict[str, Any]]:
        return self._sf_manager.backed_up_params

    @_form_factor_params.setter
    def _form_factor_params(self, value: dict[str, dict[str, Any]]) -> None:
        self._sf_manager.backed_up_params = value

    @property
    def _polydisperse_param_names(self) -> list[str]:
        return self._pd_manager.param_names

    @_polydisperse_param_names.setter
    def _polydisperse_param_names(self, value: list[str]) -> None:
        self._pd_manager.param_names = value

    @property
    def polydisperse_params(self) -> dict[str, dict[str, Any]]:
        return self._pd_manager.params

    @polydisperse_params.setter
    def polydisperse_params(self, value: dict[str, dict[str, Any]]) -> None:
        self._pd_manager.params = value

    @property
    def _pd_enabled(self) -> bool:
        return self._pd_manager.enabled

    @_pd_enabled.setter
    def _pd_enabled(self, value: bool) -> None:
        self._pd_manager.enabled = value

    @property
    def _backed_up_pd_state(self) -> dict[str, Any] | None:
        return self._pd_manager.backup_state

    @_backed_up_pd_state.setter
    def _backed_up_pd_state(self, value: dict[str, Any] | None) -> None:
        self._pd_manager.backup_state = value

    def initialize_from_kernel(
        self,
        kernel: Any,
        model_name: str,
        components: list[tuple[str, str, str]] | None = None,
    ) -> None:
        """
        Initialize parameters from a SasModels kernel.

        Args:
            kernel: SasModels kernel object
            model_name: Name of the model
            components: Optional ordered ``(prefix, moniker, part_model_name)``
                triples for composite models. Defaults to deriving components
                from the kernel's composition tree (empty for atomic models).

        Raises:
            ValueError: If kernel is invalid
        """
        if kernel is None:
            raise ValueError('Kernel cannot be None')

        # Clear all state first to ensure clean initialization
        self.clear()

        self.model_name = model_name
        if components is None:
            components = derive_mixture_components(kernel)
        self._components = [tuple(entry) for entry in components]

        # Extract parameters from kernel
        for param in kernel.info.parameters.kernel_parameters:
            lo, hi = default_parameter_bounds(param.default, param.limits)
            self.params[param.name] = {
                'value': param.default,
                'min': lo,
                'max': hi,
                'vary': False,  # By default, parameters are fixed
                'description': param.description,
            }

            # Track polydisperse parameters
            if getattr(param, 'polydisperse', False):
                self._polydisperse_param_names.append(param.name)

        # Add implicit scale and background parameters (present in all models)
        if 'scale' not in self.params:
            self.params['scale'] = {
                'value': 1.0,
                'min': 0.0,
                'max': np.inf,
                'vary': False,
                'description': 'Scale factor for the model intensity',
            }

        if 'background' not in self.params:
            self.params['background'] = {
                'value': 0.0,
                'min': 0.0,
                'max': np.inf,
                'vary': False,
                'description': 'Constant background level',
            }

        self._initialize_polydispersity_params()

    def get_param_dict(self) -> dict[str, dict[str, Any]]:
        """
        Get the full parameter dictionary.

        Returns:
            Dictionary of parameter configurations
        """
        return self.params

    def get_param_values(self) -> dict[str, float]:
        """
        Get dictionary of parameter names to current values.

        Returns:
            Dictionary mapping parameter names to their current values
        """
        return {name: info['value'] for name, info in self.params.items()}

    def get_canonical_param_values(self) -> dict[str, float]:
        """Get current parameter values keyed by canonical sasmodels names.

        Shared parameters expand to every canonical name they drive. Used by
        post-fit evaluation (component curves) that speaks sasmodels names.
        """
        values: dict[str, float] = {}
        for name, info in self.params.items():
            canonicals = self._shared_to_canonicals.get(name)
            if canonicals:
                for canonical in canonicals:
                    values[canonical] = info['value']
            else:
                values[self._resolve_canonical(name)] = info['value']
        return values

    def snapshot_fit_state(self) -> ParameterStateSnapshot:
        """Capture a stable snapshot of parameter state for fitting engines.

        The snapshot carries **canonical sasmodels names only**: alias-keyed
        entries are emitted under their canonical names, shared parameters
        expand to their first canonical name as the link target plus equality
        links from the remaining canonical names, and ``_links`` (stored under
        user-facing names) is translated to canonical names here — the one and
        only translation site for links.
        """
        canonical_params: dict[str, dict[str, Any]] = {}
        linked_params: dict[str, str] = {}

        for name, info in self.params.items():
            canonicals = self._shared_to_canonicals.get(name)
            if canonicals:
                # Shared parameter: one user-facing entry drives several
                # canonical parameters. Emit the first as the target and link
                # the rest to it.
                target = canonicals[0]
                canonical_params[target] = dict(info)
                for follower in canonicals[1:]:
                    canonical_params[follower] = dict(info)
                    canonical_params[follower]['vary'] = False
                    linked_params[follower] = target
            else:
                canonical = self._alias_to_canonical.get(name, name)
                canonical_params[canonical] = dict(info)

        # Translate equality links (stored in user-facing names) to canonical.
        for follower, target in self._links.items():
            follower_canonicals = self._shared_to_canonicals.get(follower, [follower])
            target_canonicals = self._shared_to_canonicals.get(target, [target])
            for follower_canonical in follower_canonicals:
                follower_canonical = self._alias_to_canonical.get(
                    follower_canonical, follower_canonical
                )
                target_canonical = self._alias_to_canonical.get(
                    target_canonicals[0], target_canonicals[0]
                )
                linked_params[follower_canonical] = target_canonical
                if follower_canonical in canonical_params:
                    canonical_params[follower_canonical]['vary'] = False

        varying = [
            name
            for name, info in canonical_params.items()
            if info['vary'] and name not in linked_params
        ]

        return ParameterStateSnapshot(
            params=canonical_params,
            polydisperse_param_names=self._pd_manager.get_parameters(),
            polydisperse_params={
                name: dict(info) for name, info in self._pd_manager.params.items()
            },
            pd_enabled=self._pd_manager.is_enabled(),
            radius_effective_mode=self._radius_effective_mode,
            structure_factor_name=self._structure_factor_name,
            varying_params=varying,
            varying_pd_params=self.get_varying_pd_params(),
            linked_params=linked_params,
            components=tuple(self._components),
        )

    def apply_fitted_values(self, fitted_values: dict[str, float]) -> None:
        """Apply fitted values back into regular and PD parameter state.

        Engine results carry canonical names; they are translated back through
        the reverse alias map before write-back so a fitted ``A_sld`` lands on
        the user-facing ``sld`` entry. The reverse map is consulted *before*
        the raw ``params`` membership check: on the alias path ``params`` is
        keyed by aliases, and an alias may equal an unrelated canonical name
        (moniker shadowing a sasmodels prefix) — the canonical spelling of an
        engine result must never be mistaken for such an alias.
        """
        for name, value in fitted_values.items():
            if name in self._canonical_to_alias:
                self.set_param(self._canonical_to_alias[name], value=value)
            elif name in self.params:
                self.set_param(name, value=value)
            elif name.endswith('_pd'):
                base_param = name[:-3]
                # PD state is keyed by canonical names end-to-end; no reverse
                # translation is needed here.
                if base_param in self._pd_manager.get_parameters():
                    self.set_pd_param(base_param, pd_width=value)

        # Propagate each link target's fitted value onto its followers.
        # Followers are excluded from the varying set, so engine results never
        # contain a follower name — this post-loop propagation is the
        # follower's *only* update path. Do not remove it as apparently dead.
        for follower, target in self._links.items():
            if target in self.params and follower in self.params:
                self.params[follower]['value'] = self.params[target]['value']

    def resolve_name(self, name: str) -> str:
        """Resolve a user-facing parameter name to the key used in ``params``.

        Resolution rule: try the alias map first, fall back to canonical names
        (so ``A_sld`` always works), then raise ``KeyError`` listing the
        user-facing (alias) names.
        """
        if name in self.params:
            return name
        if name in self._alias_to_canonical:
            canonical = self._alias_to_canonical[name]
            if canonical in self.params:
                return canonical
            # Alias of a suppressed (shared) prefixed entry: not in params.
            return name
        if name in self._canonical_to_alias:
            return self._canonical_to_alias[name]
        available = ', '.join(self.params.keys())
        raise KeyError(f"Parameter '{name}' not found. Available: {available}")

    def link_params(self, name: str, to: str) -> None:
        """Create an equality link: *name* (follower) mirrors *to* (target).

        The follower is forced to ``vary=False`` and always carries the
        target's value — before, during, and after the fit.

        Raises:
            KeyError: If either name does not exist.
            ValueError: On self-links, link chains, or conflicting links.
        """
        follower = self.resolve_name(name)
        target = self.resolve_name(to)
        for resolved, original in ((follower, name), (target, to)):
            if resolved not in self.params:
                available = ', '.join(self.params.keys())
                raise KeyError(f"Parameter '{original}' not found. Available: {available}")
        if follower == target:
            raise ValueError(f"Cannot link parameter '{name}' to itself.")
        if follower in self._links:
            raise ValueError(f"Parameter '{name}' is already linked to '{self._links[follower]}'.")
        if target in self._links:
            raise ValueError(
                f"Cannot link '{name}' to '{to}': '{to}' is itself a follower. "
                'Link chains are not supported — link both followers directly '
                'to the common target.'
            )
        if follower in self._links.values():
            raise ValueError(
                f"Cannot make '{name}' a follower: it is the target of another "
                'link. Link chains are not supported — link both followers '
                'directly to the common target.'
            )
        self._links[follower] = target
        self.params[follower]['vary'] = False
        self.params[follower]['value'] = self.params[target]['value']

    def unlink_params(self, name: str) -> None:
        """Remove an equality link, restoring the follower's independence.

        Raises:
            KeyError: If the name does not exist.
            ValueError: If the parameter is not a follower.
        """
        # Accept a raw link key first so a link can always be removed, even if
        # its follower no longer resolves to a live parameter.
        follower = name if name in self._links else self.resolve_name(name)
        if follower not in self._links:
            raise ValueError(f"Parameter '{name}' is not linked to another parameter.")
        del self._links[follower]

    def get_links(self) -> dict[str, str]:
        """Return the equality links (follower -> target) in user-facing names."""
        return dict(self._links)

    def get_components(self) -> list[tuple[str, str, str]]:
        """Return composite components as (prefix, moniker, part_model_name) triples.

        Empty for atomic models.
        """
        return [tuple(entry) for entry in self._components]

    # =========================================================================
    # Alias layer (set_models path) — see 46_COMPOSITE_MODELS.md §4.2b
    # =========================================================================

    def register_aliases(
        self, components: list[tuple[str, str]], shared: 'list[str] | tuple[str, ...]'
    ) -> None:
        """Build the friendly-name alias layer over a loaded composite model.

        Args:
            components: Ordered ``(moniker, model_name)`` pairs as given to
                ``set_models``. Monikers replace the prefix-derived monikers in
                the component triples by position.
            shared: Parameter names (unprefixed) that must exist in >= 2
                components and are collapsed into a single unprefixed parameter.

        Raises:
            ValueError: If a model entry expanded to more than one kernel
                component (monikers could not map 1:1), if a shared name is
                present in fewer than 2 components, or if the generated alias
                set has collisions.

        Note:
            This method is atomic: all validation runs against local state, and
            ``self.*`` is only mutated once every check has passed. On failure
            the manager is left exactly as ``set_model`` configured it.
        """
        if len(components) != len(self._components):
            raise ValueError(
                f'{len(components)} model entries expanded to {len(self._components)} kernel '
                'components (a nested mixture expression or mixture plugin). Pass each '
                'component separately so monikers map 1:1.'
            )

        # Overlay the user's monikers onto the kernel-derived triples by
        # position — into a local list; self._components is committed last.
        new_components = [
            (prefix, moniker, part_name)
            for (prefix, _old_moniker, part_name), (moniker, _model_name) in zip(
                self._components, components, strict=False
            )
        ]

        # Longest-prefix-first so nested combined prefixes (e.g. 'AB') win.
        comps = sorted(new_components, key=lambda c: len(c[0]), reverse=True)

        alias_to_canonical: dict[str, str] = {}
        canonical_to_alias: dict[str, str] = {}
        for canonical in self.params.keys():
            matched = None
            for prefix, moniker, _part_name in comps:
                if prefix and canonical.startswith(prefix + '_'):
                    matched = (prefix, moniker)
                    break
            if matched is None:
                continue  # global parameter (scale/background) — no alias
            prefix, moniker = matched
            stripped = canonical[len(prefix) + 1 :]
            alias = f'{moniker}_{stripped}'
            if alias in alias_to_canonical and alias_to_canonical[alias] != canonical:
                raise ValueError(
                    f"Component monikers produce a colliding parameter name '{alias}' "
                    f"(from both '{alias_to_canonical[alias]}' and '{canonical}'). "
                    'Choose distinct monikers.'
                )
            alias_to_canonical[alias] = canonical

        # Reject aliases that shadow an unrelated canonical name (e.g.
        # set_models(B='sphere', A='cylinder') maps prefix A -> moniker "B",
        # so the alias 'B_radius' equals the cylinder's canonical name).
        # Such shadowing makes name resolution ambiguous and would cross-wire
        # fitted values between components.
        for alias, canonical in alias_to_canonical.items():
            if alias in self.params and alias != canonical:
                raise ValueError(
                    f"Component moniker produces the alias '{alias}', which shadows "
                    f"the canonical parameter '{alias}' of another component "
                    f"(the alias maps to '{canonical}'). Choose monikers that do "
                    "not reuse sasmodels' A/B/C prefix letters in a different order."
                )

        # Shared parameters: one-to-many, must exist in >= 2 components.
        shared_to_canonicals: dict[str, list[str]] = {}
        for shared_name in shared:
            canonicals = []
            for prefix, _moniker, _part_name in new_components:
                candidate = f'{prefix}_{shared_name}' if prefix else shared_name
                if candidate in self.params:
                    canonicals.append(candidate)
            if len(canonicals) < 2:
                per_component = '; '.join(
                    f'{moniker}: '
                    + ', '.join(
                        name[len(prefix) + 1 :]
                        for name in self.params
                        if prefix and name.startswith(prefix + '_')
                    )
                    for prefix, moniker, _part in new_components
                )
                raise ValueError(
                    f"Shared parameter '{shared_name}' must exist in at least 2 "
                    f'components (found in {len(canonicals)}). '
                    f'Per-component parameters — {per_component}'
                )
            shared_to_canonicals[shared_name] = canonicals
            # The shared canonical names reverse-map to the shared alias.
            for canonical in canonicals:
                canonical_to_alias[canonical] = shared_name

        # Non-shared canonical names reverse-map to their prefixed alias.
        for alias, canonical in alias_to_canonical.items():
            if canonical not in canonical_to_alias:
                canonical_to_alias[canonical] = alias

        # Re-key the user-facing params dict to alias names.
        new_params: dict[str, dict[str, Any]] = {}
        shared_canonical_set = {
            canonical for canonicals in shared_to_canonicals.values() for canonical in canonicals
        }
        for canonical, info in self.params.items():
            if canonical not in alias_to_canonical.values():
                new_params[canonical] = info  # global scale/background
                continue
            if canonical in shared_canonical_set:
                # The prefixed alias of a shared parameter is suppressed: only
                # the shared name appears in the user-facing params dict.
                shared_name = canonical_to_alias[canonical]
                if shared_to_canonicals[shared_name][0] == canonical:
                    new_params[shared_name] = info
            else:
                new_params[canonical_to_alias[canonical]] = info

        self._components = new_components
        self.params = new_params
        self._alias_to_canonical = alias_to_canonical
        self._canonical_to_alias = canonical_to_alias
        self._shared_to_canonicals = shared_to_canonicals

    def _resolve_canonical(self, name: str) -> str:
        """Map an alias (or canonical) name to its canonical sasmodels name."""
        return self._alias_to_canonical.get(name, name)

    def to_display_name(self, canonical_name: str) -> str:
        """Translate a canonical sasmodels name to its user-facing name.

        Used for engine results, saved CSVs, and plot labels on the
        ``set_models`` path. On the raw ``set_model`` path the alias map is
        empty and names pass through unchanged. Polydispersity width names
        (``A_radius_pd``) translate through their base parameter's alias.
        """
        if canonical_name in self._canonical_to_alias:
            return self._canonical_to_alias[canonical_name]
        if canonical_name.endswith('_pd'):
            base = canonical_name.removesuffix('_pd')
            if base in self._canonical_to_alias:
                return f'{self._canonical_to_alias[base]}_pd'
        return canonical_name

    def set_param(
        self,
        name: str,
        value: float | None = None,
        min: float | None = None,
        max: float | None = None,
        vary: bool | None = None,
    ) -> None:
        """
        Configure a model parameter.

        Args:
            name: Parameter name (alias or canonical)
            value: Initial value (optional)
            min: Minimum bound (optional)
            max: Maximum bound (optional)
            vary: Whether to vary during fit (optional)

        Raises:
            KeyError: If parameter name doesn't exist
            ValueError: If the parameter is a link follower and value/vary is
                written, or if vary=True is requested for a follower.
        """
        resolved = self.resolve_name(name)
        if resolved not in self.params:
            available = ', '.join(self.params.keys())
            raise KeyError(f"Parameter '{name}' not found. Available: {available}")

        if resolved in self._links:
            target = self._links[resolved]
            if value is not None or vary is True:
                raise ValueError(
                    f"Parameter '{name}' is linked to '{target}' and cannot be "
                    'set directly. Configure the target, or unlink_params() first.'
                )

        if value is not None:
            self.params[resolved]['value'] = value
            # Sync radius_effective when radius is updated in link_radius mode
            if (
                resolved == 'radius'
                and self._radius_effective_mode == 'link_radius'
                and 'radius_effective' in self.params
            ):
                self.params['radius_effective']['value'] = value
            # Propagate to equality-link followers of this parameter.
            for follower, link_target in self._links.items():
                if link_target == resolved:
                    self.params[follower]['value'] = value
        if min is not None:
            self.params[resolved]['min'] = min
        if max is not None:
            self.params[resolved]['max'] = max
        if vary is not None:
            self.params[resolved]['vary'] = vary

    def validate_param(self, name: str) -> bool:
        """
        Check if a parameter name exists.

        Args:
            name: Parameter name to validate

        Returns:
            True if parameter exists, False otherwise
        """
        return name in self.params

    def display_params(self) -> None:
        """Display current parameter values and settings in a readable format.

        For composite models the parameters are grouped: global parameters
        first, then shared parameters (from ``shared=``), then one block per
        component moniker.
        """
        if not self.params:
            print('No parameters available.')
            return

        print(f'\n{"=" * 80}')
        print(f'Model: {self.model_name}')
        if self._structure_factor_name:
            print(f'Structure Factor: {self._structure_factor_name}')
            print(f'Radius Effective Mode: {self._radius_effective_mode}')
        print(f'{"=" * 80}')

        if not self._components:
            self._print_param_table(self.params)
            print(f'{"=" * 80}\n')
            return

        global_names = {'scale', 'background'}
        shared_names = set(self._shared_to_canonicals.keys())

        def entry_line(name: str, info: dict[str, Any]) -> str:
            vary_str = '✓' if info['vary'] else '✗'
            if name == 'radius_effective' and self._radius_effective_mode == 'link_radius':
                vary_str = '→radius'
            if name in self._links:
                vary_str = f'→{self._links[name]}'
            return (
                f'{name:<28} {info["value"]:<12.4g} {info["min"]:<12.4g} '
                f'{info["max"]:<12.4g} {vary_str:<8}'
            )

        header = f'{"Parameter":<28} {"Value":<12} {"Min":<12} {"Max":<12} {"Vary":<8}'
        print(header)
        print(f'{"-" * 80}')

        print('Global:')
        for name in ('scale', 'background'):
            if name in self.params:
                print('  ' + entry_line(name, self.params[name]))
        if shared_names:
            print('Shared:')
            for name in sorted(shared_names):
                if name in self.params:
                    print('  ' + entry_line(name, self.params[name]))
        # Assign each parameter to the component with the longest matching
        # moniker prefix, so 'sphere_big_radius' files under a 'sphere_big'
        # moniker rather than also matching a plain 'sphere' moniker.
        monikers_by_length = sorted(
            (moniker for _prefix, moniker, _part in self._components), key=len, reverse=True
        )

        def owning_moniker(name: str) -> str | None:
            for candidate in monikers_by_length:
                if name.startswith(f'{candidate}_'):
                    return candidate
            return None

        for _prefix, moniker, part_name in self._components:
            label = moniker if moniker == part_name else f'{moniker} ({part_name})'
            print(f'{label}:')
            for name, info in self.params.items():
                if name in global_names or name in shared_names:
                    continue
                # Moniker alone covers both paths: params are keyed by alias
                # after register_aliases, and moniker == prefix on the raw
                # set_model path. Also matching the prefix would misfile
                # params when one component's moniker equals another's prefix.
                if owning_moniker(name) == moniker:
                    print('  ' + entry_line(name, info))
        print(f'{"=" * 80}\n')

    def _print_param_table(self, params: dict[str, dict[str, Any]]) -> None:
        """Print a flat parameter table (atomic models)."""
        print(f'{"Parameter":<20} {"Value":<12} {"Min":<12} {"Max":<12} {"Vary":<8}')
        print(f'{"-" * 80}')
        for name, info in params.items():
            vary_str = '✓' if info['vary'] else '✗'
            # Show linked indicator for radius_effective in link_radius mode
            if name == 'radius_effective' and self._radius_effective_mode == 'link_radius':
                vary_str = '→radius'
            if name in self._links:
                vary_str = f'→{self._links[name]}'
            print(
                f'{name:<20} {info["value"]:<12.4g} {info["min"]:<12.4g} '
                f'{info["max"]:<12.4g} {vary_str:<8}'
            )

    def backup_params(self) -> None:
        """Backup current parameters (used before applying structure factor)."""
        self._sf_manager.backup_params(self.params)

    def restore_params(self) -> None:
        """Restore backed up parameters (used when removing structure factor)."""
        if self._sf_manager.has_backup():
            self.params = self._sf_manager.restore_params()

    def has_backed_up_params(self) -> bool:
        """
        Check if there are backed up parameters.

        Returns:
            True if parameters have been backed up, False otherwise
        """
        return self._sf_manager.has_backup()

    def get_backed_up_params(self) -> dict[str, dict[str, Any]]:
        """
        Get the backed up form factor parameters.

        Returns:
            Dictionary of backed up parameters
        """
        return self._sf_manager.backed_up_params

    def update_for_product_model(
        self, kernel: Any, structure_factor_name: str, radius_effective_mode: str = 'unconstrained'
    ) -> None:
        """
        Update parameters for a product model (form factor @ structure factor).

        Args:
            kernel: New product model kernel
            structure_factor_name: Name of the structure factor
            radius_effective_mode: How to handle radius_effective
                - 'unconstrained': radius_effective is a separate parameter
                - 'link_radius': radius_effective is linked to radius

        Raises:
            ValueError: If radius_effective_mode is invalid
        """
        # Backup polydispersity state if not already done
        if not self._backed_up_pd_state:
            self.backup_pd_state()
        self.params = self._sf_manager.apply(
            kernel=kernel,
            sf_name=structure_factor_name,
            re_mode=radius_effective_mode,
            current_params=self.params,
        )
        self._prune_stale_links()

    def remove_structure_factor(self) -> str:
        """
        Remove structure factor and restore form factor parameters.

        Returns:
            Name of the removed structure factor

        Raises:
            ValueError: If no structure factor is currently set
        """
        sf_name, restored_params = self._sf_manager.remove()
        self.params = restored_params
        self.restore_pd_state()
        self._prune_stale_links()
        return sf_name

    def _prune_stale_links(self) -> None:
        """Drop equality links whose follower or target left ``params``.

        Called after every params rebuild (applying/removing a structure
        factor). Without this, a stale link survives as a phantom entry that
        cannot be unlinked and keeps blocking link-free engines.
        """
        stale = [
            follower
            for follower, target in self._links.items()
            if follower not in self.params or target not in self.params
        ]
        for follower in stale:
            target = self._links.pop(follower)
            warnings.warn(
                f"Removed parameter link '{follower}' -> '{target}': one of the "
                'parameters no longer exists after the model change.',
                stacklevel=3,
            )

    def get_structure_factor(self) -> str | None:
        """
        Get the name of the currently applied structure factor.

        Returns:
            Name of the structure factor, or None if no structure factor is set
        """
        return self._sf_manager.name

    def get_radius_effective_mode(self) -> str:
        """
        Get the current radius_effective mode.

        Returns:
            Current radius_effective mode ('unconstrained' or 'link_radius')
        """
        return self._sf_manager.radius_effective_mode

    def update_param_value(self, name: str, value: float) -> None:
        """
        Update a parameter's value.

        Args:
            name: Parameter name
            value: New value

        Raises:
            KeyError: If parameter doesn't exist
        """
        if name not in self.params:
            raise KeyError(f"Parameter '{name}' not found")
        self.params[name]['value'] = value

    def get_varying_params(self) -> list[str]:
        """
        Get list of parameter names that are set to vary.

        Returns:
            List of parameter names with vary=True
        """
        return [name for name, info in self.params.items() if info['vary']]

    # =========================================================================
    # Polydispersity Methods
    # =========================================================================

    def _initialize_polydispersity_params(self) -> None:
        """Initialize polydispersity parameters for all polydisperse parameters."""
        self._pd_manager.initialize(self._polydisperse_param_names)

    def get_polydisperse_parameters(self) -> list[str]:
        """
        Return list of parameter names that support polydispersity.

        Returns:
            List of parameter names that can have polydispersity applied
        """
        return self._pd_manager.get_parameters()

    def has_polydisperse_parameters(self) -> bool:
        """
        Check if the current model has any polydisperse parameters.

        Returns:
            True if model has polydisperse parameters, False otherwise
        """
        return self._pd_manager.has_parameters()

    def set_pd_param(
        self,
        base_param: str,
        pd_width: float | None = None,
        pd_n: int | None = None,
        pd_nsigma: float | None = None,
        pd_type: str | None = None,
        vary: bool | None = None,
    ) -> None:
        """
        Configure polydispersity for a specific parameter.

        Args:
            base_param: Name of the base parameter (e.g., 'radius')
            pd_width: Polydispersity width (relative, 0.0 = monodisperse)
            pd_n: Number of Gaussian quadrature points (default: 35)
            pd_nsigma: Number of sigmas to include (default: 3.0)
            pd_type: Distribution type ('gaussian', 'rectangle', 'lognormal', 'schulz', 'boltzmann')
            vary: Whether to vary the pd_width during fitting

        Raises:
            KeyError: If base_param is not a polydisperse parameter
            ValueError: If pd_type is not a valid distribution type
        """
        # Polydispersity state is keyed by canonical names end-to-end; resolve
        # aliases (e.g. 'small_radius' -> 'A_radius') before delegating.
        base_param = self._resolve_canonical(base_param)
        self._pd_manager.set_param(
            base_param,
            pd_width=pd_width,
            pd_n=pd_n,
            pd_nsigma=pd_nsigma,
            pd_type=pd_type,
            vary=vary,
        )

    def get_pd_param(self, base_param: str) -> dict[str, Any]:
        """
        Get polydispersity configuration for a specific parameter.

        Args:
            base_param: Name of the base parameter (e.g., 'radius')

        Returns:
            Dictionary with pd, pd_n, pd_nsigma, pd_type, vary, and active values.
            'active' indicates whether polydispersity is active for this parameter (pd > 0).

        Raises:
            KeyError: If base_param is not a polydisperse parameter
        """
        base_param = self._resolve_canonical(base_param)
        return self._pd_manager.get_param(base_param)

    def toggle_pd_visibility(self, enabled: bool) -> None:
        """
        Enable/disable polydispersity globally.

        When disabled, polydispersity parameters are excluded from fitting
        but their values are preserved for when PD is re-enabled.

        Args:
            enabled: Whether polydispersity should be enabled
        """
        self._pd_manager.set_enabled(enabled)

    def is_pd_enabled(self) -> bool:
        """
        Check if polydispersity is globally enabled.

        Returns:
            True if polydispersity is enabled, False otherwise
        """
        return self._pd_manager.is_enabled()

    def get_pd_params_for_fitting(self) -> dict[str, Any]:
        """
        Return polydispersity parameters to include in fitting.

        Only returns PD parameters when pd_enabled is True.
        Returns parameters in the format expected by SasModels:
        - {param}_pd: polydispersity width
        - {param}_pd_n: number of quadrature points
        - {param}_pd_nsigma: number of sigmas
        - {param}_pd_type: distribution type

        Returns:
            Dictionary of PD parameters ready for fitting
        """
        return self._pd_manager.get_fitting_params()

    def get_varying_pd_params(self) -> list[str]:
        """
        Get list of polydispersity parameter names set to vary.

        Only returns parameters when pd_enabled is True.

        Returns:
            List of base parameter names whose PD width should vary
        """
        return self._pd_manager.get_varying_params()

    def display_pd_params(self) -> None:
        """Display polydispersity parameter values and settings.

        On the ``set_models`` path the canonical prefixed names are translated
        to their user-facing aliases for display.
        """
        self._pd_manager.display(name_map=self._canonical_to_alias or None)

    def backup_pd_state(self) -> None:
        """Backup current polydispersity state (used before applying structure factor)."""
        self._pd_manager.backup()

    def restore_pd_state(self) -> None:
        """Restore backed up polydispersity state (used when removing structure factor)."""
        self._pd_manager.restore()

    def has_backed_up_pd_state(self) -> bool:
        """
        Check if there is backed up polydispersity state.

        Returns:
            True if polydispersity state has been backed up, False otherwise
        """
        return self._pd_manager.has_backup()

    def clear(self) -> None:
        """Clear all parameters and reset state."""
        self.params = {}
        self.model_name = None
        self._sf_manager.clear()

        # Reset composite-model state
        self._components = []
        self._links = {}
        self._alias_to_canonical = {}
        self._canonical_to_alias = {}
        self._shared_to_canonicals = {}

        # Reset polydispersity state
        self._pd_manager.clear()

get_polydisperse_parameters()

Return list of parameter names that support polydispersity.

Returns:

Type Description
list[str]

List of parameter names that can have polydispersity applied

Source code in src/sans_fitter/modeling/parameters.py
def get_polydisperse_parameters(self) -> list[str]:
    """
    Return list of parameter names that support polydispersity.

    Returns:
        List of parameter names that can have polydispersity applied
    """
    return self._pd_manager.get_parameters()

has_polydisperse_parameters()

Check if the current model has any polydisperse parameters.

Returns:

Type Description
bool

True if model has polydisperse parameters, False otherwise

Source code in src/sans_fitter/modeling/parameters.py
def has_polydisperse_parameters(self) -> bool:
    """
    Check if the current model has any polydisperse parameters.

    Returns:
        True if model has polydisperse parameters, False otherwise
    """
    return self._pd_manager.has_parameters()

set_pd_param(base_param, pd_width=None, pd_n=None, pd_nsigma=None, pd_type=None, vary=None)

Configure polydispersity for a specific parameter.

Parameters:

Name Type Description Default
base_param str

Name of the base parameter (e.g., 'radius')

required
pd_width float | None

Polydispersity width (relative, 0.0 = monodisperse)

None
pd_n int | None

Number of Gaussian quadrature points (default: 35)

None
pd_nsigma float | None

Number of sigmas to include (default: 3.0)

None
pd_type str | None

Distribution type ('gaussian', 'rectangle', 'lognormal', 'schulz', 'boltzmann')

None
vary bool | None

Whether to vary the pd_width during fitting

None

Raises:

Type Description
KeyError

If base_param is not a polydisperse parameter

ValueError

If pd_type is not a valid distribution type

Source code in src/sans_fitter/modeling/parameters.py
def set_pd_param(
    self,
    base_param: str,
    pd_width: float | None = None,
    pd_n: int | None = None,
    pd_nsigma: float | None = None,
    pd_type: str | None = None,
    vary: bool | None = None,
) -> None:
    """
    Configure polydispersity for a specific parameter.

    Args:
        base_param: Name of the base parameter (e.g., 'radius')
        pd_width: Polydispersity width (relative, 0.0 = monodisperse)
        pd_n: Number of Gaussian quadrature points (default: 35)
        pd_nsigma: Number of sigmas to include (default: 3.0)
        pd_type: Distribution type ('gaussian', 'rectangle', 'lognormal', 'schulz', 'boltzmann')
        vary: Whether to vary the pd_width during fitting

    Raises:
        KeyError: If base_param is not a polydisperse parameter
        ValueError: If pd_type is not a valid distribution type
    """
    # Polydispersity state is keyed by canonical names end-to-end; resolve
    # aliases (e.g. 'small_radius' -> 'A_radius') before delegating.
    base_param = self._resolve_canonical(base_param)
    self._pd_manager.set_param(
        base_param,
        pd_width=pd_width,
        pd_n=pd_n,
        pd_nsigma=pd_nsigma,
        pd_type=pd_type,
        vary=vary,
    )

get_pd_param(base_param)

Get polydispersity configuration for a specific parameter.

Parameters:

Name Type Description Default
base_param str

Name of the base parameter (e.g., 'radius')

required

Returns:

Type Description
dict[str, Any]

Dictionary with pd, pd_n, pd_nsigma, pd_type, vary, and active values.

dict[str, Any]

'active' indicates whether polydispersity is active for this parameter (pd > 0).

Raises:

Type Description
KeyError

If base_param is not a polydisperse parameter

Source code in src/sans_fitter/modeling/parameters.py
def get_pd_param(self, base_param: str) -> dict[str, Any]:
    """
    Get polydispersity configuration for a specific parameter.

    Args:
        base_param: Name of the base parameter (e.g., 'radius')

    Returns:
        Dictionary with pd, pd_n, pd_nsigma, pd_type, vary, and active values.
        'active' indicates whether polydispersity is active for this parameter (pd > 0).

    Raises:
        KeyError: If base_param is not a polydisperse parameter
    """
    base_param = self._resolve_canonical(base_param)
    return self._pd_manager.get_param(base_param)

toggle_pd_visibility(enabled)

Enable/disable polydispersity globally.

When disabled, polydispersity parameters are excluded from fitting but their values are preserved for when PD is re-enabled.

Parameters:

Name Type Description Default
enabled bool

Whether polydispersity should be enabled

required
Source code in src/sans_fitter/modeling/parameters.py
def toggle_pd_visibility(self, enabled: bool) -> None:
    """
    Enable/disable polydispersity globally.

    When disabled, polydispersity parameters are excluded from fitting
    but their values are preserved for when PD is re-enabled.

    Args:
        enabled: Whether polydispersity should be enabled
    """
    self._pd_manager.set_enabled(enabled)

is_pd_enabled()

Check if polydispersity is globally enabled.

Returns:

Type Description
bool

True if polydispersity is enabled, False otherwise

Source code in src/sans_fitter/modeling/parameters.py
def is_pd_enabled(self) -> bool:
    """
    Check if polydispersity is globally enabled.

    Returns:
        True if polydispersity is enabled, False otherwise
    """
    return self._pd_manager.is_enabled()

get_pd_params_for_fitting()

Return polydispersity parameters to include in fitting.

Only returns PD parameters when pd_enabled is True. Returns parameters in the format expected by SasModels: - {param}_pd: polydispersity width - {param}_pd_n: number of quadrature points - {param}_pd_nsigma: number of sigmas - {param}_pd_type: distribution type

Returns:

Type Description
dict[str, Any]

Dictionary of PD parameters ready for fitting

Source code in src/sans_fitter/modeling/parameters.py
def get_pd_params_for_fitting(self) -> dict[str, Any]:
    """
    Return polydispersity parameters to include in fitting.

    Only returns PD parameters when pd_enabled is True.
    Returns parameters in the format expected by SasModels:
    - {param}_pd: polydispersity width
    - {param}_pd_n: number of quadrature points
    - {param}_pd_nsigma: number of sigmas
    - {param}_pd_type: distribution type

    Returns:
        Dictionary of PD parameters ready for fitting
    """
    return self._pd_manager.get_fitting_params()

display_pd_params()

Display polydispersity parameter values and settings.

On the set_models path the canonical prefixed names are translated to their user-facing aliases for display.

Source code in src/sans_fitter/modeling/parameters.py
def display_pd_params(self) -> None:
    """Display polydispersity parameter values and settings.

    On the ``set_models`` path the canonical prefixed names are translated
    to their user-facing aliases for display.
    """
    self._pd_manager.display(name_map=self._canonical_to_alias or None)

PolydispersityManager

Internal class for managing per-parameter polydispersity state.

sans_fitter.modeling.polydispersity.PolydispersityManager

Manage polydispersity configuration and backup/restore state.

Source code in src/sans_fitter/modeling/polydispersity.py
class PolydispersityManager:
    """Manage polydispersity configuration and backup/restore state."""

    def __init__(self) -> None:
        self._param_names: list[str] = []
        self._params: dict[str, dict[str, Any]] = {}
        self._enabled = False
        self._backup: dict[str, Any] | None = None

    @property
    def param_names(self) -> list[str]:
        return self._param_names

    @param_names.setter
    def param_names(self, value: list[str]) -> None:
        self._param_names = list(value)

    @property
    def params(self) -> dict[str, dict[str, Any]]:
        return self._params

    @params.setter
    def params(self, value: dict[str, dict[str, Any]]) -> None:
        self._params = {name: dict(info) for name, info in value.items()}

    @property
    def enabled(self) -> bool:
        return self._enabled

    @enabled.setter
    def enabled(self, value: bool) -> None:
        self._enabled = value

    @property
    def backup_state(self) -> dict[str, Any] | None:
        return self._backup

    @backup_state.setter
    def backup_state(self, value: dict[str, Any] | None) -> None:
        self._backup = value

    def initialize(self, param_names: list[str]) -> None:
        self._param_names = list(param_names)
        self._params = {
            param_name: {
                'pd': PD_DEFAULTS['pd'],
                'pd_n': PD_DEFAULTS['pd_n'],
                'pd_nsigma': PD_DEFAULTS['pd_nsigma'],
                'pd_type': PD_DEFAULTS['pd_type'],
                'vary': PD_DEFAULTS['vary'],
            }
            for param_name in self._param_names
        }
        self._enabled = False

    def get_parameters(self) -> list[str]:
        return list(self._param_names)

    def has_parameters(self) -> bool:
        return len(self._param_names) > 0

    def set_param(
        self,
        base_param: str,
        pd_width: float | None = None,
        pd_n: int | None = None,
        pd_nsigma: float | None = None,
        pd_type: str | None = None,
        vary: bool | None = None,
    ) -> None:
        if base_param not in self._param_names:
            available = ', '.join(self._param_names)
            raise KeyError(
                f"Parameter '{base_param}' does not support polydispersity. "
                f'Available polydisperse parameters: {available}'
            )

        if pd_type is not None and pd_type not in PD_DISTRIBUTION_TYPES:
            raise ValueError(
                f"Invalid pd_type '{pd_type}'. Valid types: {', '.join(PD_DISTRIBUTION_TYPES)}"
            )

        if pd_width is not None and pd_width < 0:
            raise ValueError('pd_width must be non-negative')
        if pd_n is not None and pd_n <= 0:
            raise ValueError('pd_n must be positive')
        if pd_nsigma is not None and pd_nsigma <= 0:
            raise ValueError('pd_nsigma must be positive')

        if pd_width is not None:
            self._params[base_param]['pd'] = pd_width
        if pd_n is not None:
            self._params[base_param]['pd_n'] = pd_n
        if pd_nsigma is not None:
            self._params[base_param]['pd_nsigma'] = pd_nsigma
        if pd_type is not None:
            self._params[base_param]['pd_type'] = pd_type
        if vary is not None:
            self._params[base_param]['vary'] = vary

    def get_param(self, base_param: str) -> dict[str, Any]:
        if base_param not in self._param_names:
            available = ', '.join(self._param_names)
            raise KeyError(
                f"Parameter '{base_param}' does not support polydispersity. "
                f'Available polydisperse parameters: {available}'
            )

        pd_config = self._params[base_param].copy()
        pd_config['active'] = pd_config['pd'] > 0
        return pd_config

    def set_enabled(self, enabled: bool) -> None:
        self._enabled = enabled

    def is_enabled(self) -> bool:
        return self._enabled

    def get_fitting_params(self) -> dict[str, Any]:
        if not self._enabled:
            return {}

        pd_params = {}
        for param_name in self._param_names:
            pd_config = self._params[param_name]
            pd_params[f'{param_name}_pd'] = pd_config['pd']
            pd_params[f'{param_name}_pd_n'] = pd_config['pd_n']
            pd_params[f'{param_name}_pd_nsigma'] = pd_config['pd_nsigma']
            pd_params[f'{param_name}_pd_type'] = pd_config['pd_type']
        return pd_params

    def get_varying_params(self) -> list[str]:
        if not self._enabled:
            return []

        return [
            param_name
            for param_name, pd_config in self._params.items()
            if pd_config.get('vary', False)
        ]

    def display(self, name_map: dict[str, str] | None = None) -> None:
        """Print the polydispersity table.

        Args:
            name_map: Optional canonical-to-display name translation, used on
                the ``set_models`` path where parameters carry alias names.
        """
        if not self._param_names:
            print('No polydisperse parameters available for this model.')
            return

        status = 'ENABLED' if self._enabled else 'DISABLED'
        print(f'\n{"=" * 90}')
        print(f'Polydispersity Status: {status}')
        print(f'{"=" * 90}')
        print(
            f'{"Parameter":<20} {"Width":<10} {"N Points":<10} {"N Sigma":<10} {"Type":<12} {"Vary":<8}'
        )
        print(f'{"-" * 90}')

        for param_name in self._param_names:
            pd_config = self._params[param_name]
            display_name = (name_map or {}).get(param_name, param_name)
            vary_str = '✓' if pd_config.get('vary', False) else '✗'
            print(
                f'{display_name:<20} {pd_config["pd"]:<10.4g} {pd_config["pd_n"]:<10} '
                f'{pd_config["pd_nsigma"]:<10.4g} {pd_config["pd_type"]:<12} {vary_str:<8}'
            )
        print(f'{"=" * 90}\n')

    def backup(self) -> None:
        self._backup = {
            'polydisperse_param_names': list(self._param_names),
            'polydisperse_params': {k: dict(v) for k, v in self._params.items()},
            'pd_enabled': self._enabled,
        }

    def restore(self) -> None:
        if self._backup:
            self._param_names = self._backup['polydisperse_param_names']
            self._params = {k: dict(v) for k, v in self._backup['polydisperse_params'].items()}
            self._enabled = self._backup['pd_enabled']
            self._backup = None

    def has_backup(self) -> bool:
        return self._backup is not None

    def clear(self) -> None:
        self._param_names = []
        self._params = {}
        self._enabled = False
        self._backup = None

initialize(param_names)

Source code in src/sans_fitter/modeling/polydispersity.py
def initialize(self, param_names: list[str]) -> None:
    self._param_names = list(param_names)
    self._params = {
        param_name: {
            'pd': PD_DEFAULTS['pd'],
            'pd_n': PD_DEFAULTS['pd_n'],
            'pd_nsigma': PD_DEFAULTS['pd_nsigma'],
            'pd_type': PD_DEFAULTS['pd_type'],
            'vary': PD_DEFAULTS['vary'],
        }
        for param_name in self._param_names
    }
    self._enabled = False

get_parameters()

Source code in src/sans_fitter/modeling/polydispersity.py
def get_parameters(self) -> list[str]:
    return list(self._param_names)

has_parameters()

Source code in src/sans_fitter/modeling/polydispersity.py
def has_parameters(self) -> bool:
    return len(self._param_names) > 0

set_param(base_param, pd_width=None, pd_n=None, pd_nsigma=None, pd_type=None, vary=None)

Source code in src/sans_fitter/modeling/polydispersity.py
def set_param(
    self,
    base_param: str,
    pd_width: float | None = None,
    pd_n: int | None = None,
    pd_nsigma: float | None = None,
    pd_type: str | None = None,
    vary: bool | None = None,
) -> None:
    if base_param not in self._param_names:
        available = ', '.join(self._param_names)
        raise KeyError(
            f"Parameter '{base_param}' does not support polydispersity. "
            f'Available polydisperse parameters: {available}'
        )

    if pd_type is not None and pd_type not in PD_DISTRIBUTION_TYPES:
        raise ValueError(
            f"Invalid pd_type '{pd_type}'. Valid types: {', '.join(PD_DISTRIBUTION_TYPES)}"
        )

    if pd_width is not None and pd_width < 0:
        raise ValueError('pd_width must be non-negative')
    if pd_n is not None and pd_n <= 0:
        raise ValueError('pd_n must be positive')
    if pd_nsigma is not None and pd_nsigma <= 0:
        raise ValueError('pd_nsigma must be positive')

    if pd_width is not None:
        self._params[base_param]['pd'] = pd_width
    if pd_n is not None:
        self._params[base_param]['pd_n'] = pd_n
    if pd_nsigma is not None:
        self._params[base_param]['pd_nsigma'] = pd_nsigma
    if pd_type is not None:
        self._params[base_param]['pd_type'] = pd_type
    if vary is not None:
        self._params[base_param]['vary'] = vary

get_param(base_param)

Source code in src/sans_fitter/modeling/polydispersity.py
def get_param(self, base_param: str) -> dict[str, Any]:
    if base_param not in self._param_names:
        available = ', '.join(self._param_names)
        raise KeyError(
            f"Parameter '{base_param}' does not support polydispersity. "
            f'Available polydisperse parameters: {available}'
        )

    pd_config = self._params[base_param].copy()
    pd_config['active'] = pd_config['pd'] > 0
    return pd_config

set_enabled(enabled)

Source code in src/sans_fitter/modeling/polydispersity.py
def set_enabled(self, enabled: bool) -> None:
    self._enabled = enabled

is_enabled()

Source code in src/sans_fitter/modeling/polydispersity.py
def is_enabled(self) -> bool:
    return self._enabled

get_fitting_params()

Source code in src/sans_fitter/modeling/polydispersity.py
def get_fitting_params(self) -> dict[str, Any]:
    if not self._enabled:
        return {}

    pd_params = {}
    for param_name in self._param_names:
        pd_config = self._params[param_name]
        pd_params[f'{param_name}_pd'] = pd_config['pd']
        pd_params[f'{param_name}_pd_n'] = pd_config['pd_n']
        pd_params[f'{param_name}_pd_nsigma'] = pd_config['pd_nsigma']
        pd_params[f'{param_name}_pd_type'] = pd_config['pd_type']
    return pd_params

get_varying_params()

Source code in src/sans_fitter/modeling/polydispersity.py
def get_varying_params(self) -> list[str]:
    if not self._enabled:
        return []

    return [
        param_name
        for param_name, pd_config in self._params.items()
        if pd_config.get('vary', False)
    ]

display(name_map=None)

Print the polydispersity table.

Parameters:

Name Type Description Default
name_map dict[str, str] | None

Optional canonical-to-display name translation, used on the set_models path where parameters carry alias names.

None
Source code in src/sans_fitter/modeling/polydispersity.py
def display(self, name_map: dict[str, str] | None = None) -> None:
    """Print the polydispersity table.

    Args:
        name_map: Optional canonical-to-display name translation, used on
            the ``set_models`` path where parameters carry alias names.
    """
    if not self._param_names:
        print('No polydisperse parameters available for this model.')
        return

    status = 'ENABLED' if self._enabled else 'DISABLED'
    print(f'\n{"=" * 90}')
    print(f'Polydispersity Status: {status}')
    print(f'{"=" * 90}')
    print(
        f'{"Parameter":<20} {"Width":<10} {"N Points":<10} {"N Sigma":<10} {"Type":<12} {"Vary":<8}'
    )
    print(f'{"-" * 90}')

    for param_name in self._param_names:
        pd_config = self._params[param_name]
        display_name = (name_map or {}).get(param_name, param_name)
        vary_str = '✓' if pd_config.get('vary', False) else '✗'
        print(
            f'{display_name:<20} {pd_config["pd"]:<10.4g} {pd_config["pd_n"]:<10} '
            f'{pd_config["pd_nsigma"]:<10.4g} {pd_config["pd_type"]:<12} {vary_str:<8}'
        )
    print(f'{"=" * 90}\n')

backup()

Source code in src/sans_fitter/modeling/polydispersity.py
def backup(self) -> None:
    self._backup = {
        'polydisperse_param_names': list(self._param_names),
        'polydisperse_params': {k: dict(v) for k, v in self._params.items()},
        'pd_enabled': self._enabled,
    }

restore()

Source code in src/sans_fitter/modeling/polydispersity.py
def restore(self) -> None:
    if self._backup:
        self._param_names = self._backup['polydisperse_param_names']
        self._params = {k: dict(v) for k, v in self._backup['polydisperse_params'].items()}
        self._enabled = self._backup['pd_enabled']
        self._backup = None

has_backup()

Source code in src/sans_fitter/modeling/polydispersity.py
def has_backup(self) -> bool:
    return self._backup is not None

clear()

Source code in src/sans_fitter/modeling/polydispersity.py
def clear(self) -> None:
    self._param_names = []
    self._params = {}
    self._enabled = False
    self._backup = None

StructureFactorManager

Internal class for managing structure factor selection and parameter backup/restore.

sans_fitter.modeling.structure_factor.StructureFactorManager

Manage structure factor state and form-factor parameter backups.

Source code in src/sans_fitter/modeling/structure_factor.py
class StructureFactorManager:
    """Manage structure factor state and form-factor parameter backups."""

    def __init__(self) -> None:
        self._name: str | None = None
        self._radius_effective_mode = 'unconstrained'
        self._form_factor_params: dict[str, dict[str, Any]] = {}

    @property
    def name(self) -> str | None:
        return self._name

    @name.setter
    def name(self, value: str | None) -> None:
        self._name = value

    @property
    def radius_effective_mode(self) -> str:
        return self._radius_effective_mode

    @radius_effective_mode.setter
    def radius_effective_mode(self, value: str) -> None:
        self._radius_effective_mode = value

    @property
    def backed_up_params(self) -> dict[str, dict[str, Any]]:
        return self._form_factor_params

    @backed_up_params.setter
    def backed_up_params(self, value: dict[str, dict[str, Any]]) -> None:
        self._form_factor_params = {name: dict(info) for name, info in value.items()}

    def backup_params(self, current_params: dict[str, dict[str, Any]]) -> None:
        self._form_factor_params = {name: dict(info) for name, info in current_params.items()}

    def restore_params(self) -> dict[str, dict[str, Any]]:
        restored = {name: dict(info) for name, info in self._form_factor_params.items()}
        self._form_factor_params = {}
        return restored

    def has_backup(self) -> bool:
        return bool(self._form_factor_params)

    def apply(
        self,
        kernel: Any,
        sf_name: str,
        re_mode: str,
        current_params: dict[str, dict[str, Any]],
    ) -> dict[str, dict[str, Any]]:
        if re_mode not in ['unconstrained', 'link_radius']:
            raise ValueError(
                f"Invalid radius_effective_mode '{re_mode}'. Use 'unconstrained' or 'link_radius'."
            )

        if not self._form_factor_params:
            self.backup_params(current_params)

        self._name = sf_name
        self._radius_effective_mode = re_mode

        new_params: dict[str, dict[str, Any]] = {}
        for param in kernel.info.parameters.kernel_parameters:
            if param.name in self._form_factor_params:
                new_params[param.name] = dict(self._form_factor_params[param.name])
            else:
                lo, hi = default_parameter_bounds(param.default, param.limits)
                new_params[param.name] = {
                    'value': param.default,
                    'min': lo,
                    'max': hi,
                    'vary': False,
                    'description': param.description,
                }

        if 'scale' not in new_params:
            if 'scale' in self._form_factor_params:
                new_params['scale'] = dict(self._form_factor_params['scale'])
            else:
                new_params['scale'] = {
                    'value': 1.0,
                    'min': 0.0,
                    'max': np.inf,
                    'vary': False,
                    'description': 'Scale factor for the model intensity',
                }

        if 'background' not in new_params:
            if 'background' in self._form_factor_params:
                new_params['background'] = dict(self._form_factor_params['background'])
            else:
                new_params['background'] = {
                    'value': 0.0,
                    'min': 0.0,
                    'max': np.inf,
                    'vary': False,
                    'description': 'Constant background level',
                }

        if re_mode == 'link_radius':
            if 'radius' in new_params and 'radius_effective' in new_params:
                new_params['radius_effective']['value'] = new_params['radius']['value']
                new_params['radius_effective']['vary'] = False
            else:
                warnings.warn(
                    'Cannot link radius_effective to radius: one or both parameters not found. Using unconstrained mode.',
                    stacklevel=3,
                )
                self._radius_effective_mode = 'unconstrained'

        return new_params

    def remove(self) -> tuple[str, dict[str, dict[str, Any]]]:
        if self._name is None:
            raise ValueError('No structure factor is currently set.')

        sf_name = self._name
        restored_params = self.restore_params()
        self._name = None
        self._radius_effective_mode = 'unconstrained'
        return sf_name, restored_params

    def clear(self) -> None:
        self._name = None
        self._radius_effective_mode = 'unconstrained'
        self._form_factor_params = {}

apply(kernel, sf_name, re_mode, current_params)

Source code in src/sans_fitter/modeling/structure_factor.py
def apply(
    self,
    kernel: Any,
    sf_name: str,
    re_mode: str,
    current_params: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
    if re_mode not in ['unconstrained', 'link_radius']:
        raise ValueError(
            f"Invalid radius_effective_mode '{re_mode}'. Use 'unconstrained' or 'link_radius'."
        )

    if not self._form_factor_params:
        self.backup_params(current_params)

    self._name = sf_name
    self._radius_effective_mode = re_mode

    new_params: dict[str, dict[str, Any]] = {}
    for param in kernel.info.parameters.kernel_parameters:
        if param.name in self._form_factor_params:
            new_params[param.name] = dict(self._form_factor_params[param.name])
        else:
            lo, hi = default_parameter_bounds(param.default, param.limits)
            new_params[param.name] = {
                'value': param.default,
                'min': lo,
                'max': hi,
                'vary': False,
                'description': param.description,
            }

    if 'scale' not in new_params:
        if 'scale' in self._form_factor_params:
            new_params['scale'] = dict(self._form_factor_params['scale'])
        else:
            new_params['scale'] = {
                'value': 1.0,
                'min': 0.0,
                'max': np.inf,
                'vary': False,
                'description': 'Scale factor for the model intensity',
            }

    if 'background' not in new_params:
        if 'background' in self._form_factor_params:
            new_params['background'] = dict(self._form_factor_params['background'])
        else:
            new_params['background'] = {
                'value': 0.0,
                'min': 0.0,
                'max': np.inf,
                'vary': False,
                'description': 'Constant background level',
            }

    if re_mode == 'link_radius':
        if 'radius' in new_params and 'radius_effective' in new_params:
            new_params['radius_effective']['value'] = new_params['radius']['value']
            new_params['radius_effective']['vary'] = False
        else:
            warnings.warn(
                'Cannot link radius_effective to radius: one or both parameters not found. Using unconstrained mode.',
                stacklevel=3,
            )
            self._radius_effective_mode = 'unconstrained'

    return new_params

remove()

Source code in src/sans_fitter/modeling/structure_factor.py
def remove(self) -> tuple[str, dict[str, dict[str, Any]]]:
    if self._name is None:
        raise ValueError('No structure factor is currently set.')

    sf_name = self._name
    restored_params = self.restore_params()
    self._name = None
    self._radius_effective_mode = 'unconstrained'
    return sf_name, restored_params

backup_params(current_params)

Source code in src/sans_fitter/modeling/structure_factor.py
def backup_params(self, current_params: dict[str, dict[str, Any]]) -> None:
    self._form_factor_params = {name: dict(info) for name, info in current_params.items()}

restore_params()

Source code in src/sans_fitter/modeling/structure_factor.py
def restore_params(self) -> dict[str, dict[str, Any]]:
    restored = {name: dict(info) for name, info in self._form_factor_params.items()}
    self._form_factor_params = {}
    return restored

has_backup()

Source code in src/sans_fitter/modeling/structure_factor.py
def has_backup(self) -> bool:
    return bool(self._form_factor_params)

clear()

Source code in src/sans_fitter/modeling/structure_factor.py
def clear(self) -> None:
    self._name = None
    self._radius_effective_mode = 'unconstrained'
    self._form_factor_params = {}

sans_fitter.modeling.structure_factor.default_parameter_bounds(default, limits)

Finite, sign-permissive fallback bounds for a kernel parameter.

Finite declared limits are kept as-is. Each infinite side of a (-inf, inf)-style declaration is replaced by a range derived from the default value: default ± max(10·|default|, 1.0), with the lower side additionally capped at 0 so SLD-like parameters can go negative. This guarantees a non-degenerate range even for zero-default parameters.

Note: this is a deliberate policy — parameters whose old fallback produced [0, 10·default] now get sign-permissive ranges; users who relied on the implicit positivity should set explicit bounds via set_param.

Source code in src/sans_fitter/modeling/structure_factor.py
def default_parameter_bounds(default: float, limits: tuple[float, float]) -> tuple[float, float]:
    """Finite, sign-permissive fallback bounds for a kernel parameter.

    Finite declared limits are kept as-is. Each infinite side of a
    ``(-inf, inf)``-style declaration is replaced by a range derived from the
    default value: ``default ± max(10·|default|, 1.0)``, with the lower side
    additionally capped at 0 so SLD-like parameters can go negative. This
    guarantees a non-degenerate range even for zero-default parameters.

    Note: this is a deliberate policy — parameters whose old fallback produced
    ``[0, 10·default]`` now get sign-permissive ranges; users who relied on
    the implicit positivity should set explicit bounds via ``set_param``.
    """
    lo, hi = limits
    if not np.isfinite(lo):
        lo = min(0.0, default - max(10 * abs(default), 1.0))
    if not np.isfinite(hi):
        hi = default + max(10 * abs(default), 1.0)
    return lo, hi

Polydispersity Constants

The following constants are available in sans_fitter.modeling.parameters:

PD_DISTRIBUTION_TYPES

PD_DISTRIBUTION_TYPES = ['gaussian', 'rectangle', 'lognormal', 'schulz', 'boltzmann']

Supported polydispersity distribution types:

Type Description
gaussian Gaussian/normal distribution (default)
rectangle Uniform/rectangular distribution
lognormal Log-normal distribution
schulz Schulz distribution (common for polymers)
boltzmann Boltzmann distribution

Default Values

Constant Default Value Description
DEFAULT_PD_WIDTH 0.0 Default polydispersity width (monodisperse)
DEFAULT_PD_N 35 Default number of quadrature points
DEFAULT_PD_NSIGMA 3.0 Default number of sigmas to include
DEFAULT_PD_TYPE 'gaussian' Default distribution type