Skip to content

API Reference

Module-level helpers

sans_fitter.fitter.get_all_models()

Fetch all available models from sasmodels.

Returns:

Type Description
list[str]

List of model names

Raises:

Type Description
Exception

Whatever sasmodels.core.list_models() raises. A broken sasmodels installation surfaces as an error rather than as an empty model list.

Source code in src/sans_fitter/fitter.py
def get_all_models() -> list[str]:
    """
    Fetch all available models from sasmodels.

    Returns:
        List of model names

    Raises:
        Exception: Whatever ``sasmodels.core.list_models()`` raises. A broken
            sasmodels installation surfaces as an error rather than as an
            empty model list.
    """
    return sorted(core.list_models())

sans_fitter.fitter.get_structure_factors() cached

Return the structure-factor model names available in sasmodels.

Derived from sasmodels rather than a hardcoded whitelist: every built-in model whose ModelInfo.structure_factor flag is set (via sasmodels.core.load_model_info). New structure factors added upstream (e.g. two_yukawa) are picked up automatically, so this list cannot go stale. The result is cached; the immutable tuple prevents accidental mutation of the cached value.

Returns:

Type Description
tuple[str, ...]

Sorted tuple of structure-factor names.

Source code in src/sans_fitter/fitter.py
@functools.lru_cache(maxsize=1)
def get_structure_factors() -> tuple[str, ...]:
    """Return the structure-factor model names available in sasmodels.

    Derived from sasmodels rather than a hardcoded whitelist: every built-in
    model whose ``ModelInfo.structure_factor`` flag is set (via
    ``sasmodels.core.load_model_info``). New structure factors added upstream
    (e.g. ``two_yukawa``) are picked up automatically, so this list cannot go
    stale. The result is cached; the immutable tuple prevents accidental
    mutation of the cached value.

    Returns:
        Sorted tuple of structure-factor names.
    """
    return tuple(
        sorted(name for name in core.list_models() if core.load_model_info(name).structure_factor)
    )

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
 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
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
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
        # Where self.data came from, recorded at ingestion by load_data() and
        # set_data(); None until one of them runs. See data/provenance.py.
        self._data_source: DataSource | None = None
        self._fit_contract: FitResultContract | None = None
        self._fitted_model = None
        self._full_q_range: tuple[float, float] | None = None

        # Resolution is an analysis choice, like the model and the parameters:
        # it lives on the fitter and survives load_data()/set_data(). The
        # default 'data' means "whatever the loaded dataset carries", so it
        # keeps doing the right thing when the dataset is swapped.
        self._resolution = ResolutionSetting()

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

    def load_data(self, filename: str, dataset: int | str = 0) -> 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.

        Files may hold several datasets (e.g. CanSAS XML with multiple
        ``SASentry`` blocks). Pass *dataset* to select one by 0-based index or
        by name (title, run id or filename); the first dataset is used by
        default. If the file contains more than one dataset, a warning lists
        them all.

        Args:
            filename: Path to the data file
            dataset: Which dataset to load — a 0-based index or a name (title,
                run id or filename). Defaults to the first dataset.

        Raises:
            FileNotFoundError: If the file doesn't exist
            ValueError: If the data cannot be loaded or is invalid
        """
        loaded = load_sans_dataset(filename, dataset=dataset)
        self.data = loaded.data
        # Recorded here because nothing downstream can recover it: the selector
        # is resolved to a position and dropped, and the dataset's own
        # .filename metadata is not an authoritative path.
        self._data_source = DataSource.from_file(
            filename,
            self.data,
            requested=dataset,
            index=loaded.index,
            n_datasets=loaded.n_datasets,
        )
        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)

        logger.info(
            f'{OK} Loaded data from {filename}\n'
            f'  Q range: {self.data.qmin:.4f} to {self.data.qmax:.4f} {INVERSE_ANGSTROM}\n'
            f'  Data points: {len(self.data.x)}\n'
            f'  Error (dI) column: {"yes" if has_dy else "no"}\n'
            f'  Resolution (dQ) column: {"yes" if has_dx else "no"}\n'
            f'  Resolution mode: {self._resolution.describe()}'
        )

    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)
        # Replaces any file provenance: this dataset did not come from a path,
        # and inferring one from its metadata would be wrong (data_ops results
        # carry an operation string in .filename, not a file).
        self._data_source = DataSource.from_memory(self.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'

        logger.info(
            f'{OK} Data set: {label}\n'
            f'  Q range: {self.data.qmin:.4f} to {self.data.qmax:.4f} {INVERSE_ANGSTROM}\n'
            f'  Data points: {len(self.data.x)}\n'
            f'  Error (dI) column: {"yes" if has_dy else "no"}\n'
            f'  Resolution (dQ) column: {"yes" if has_dx else "no"}\n'
            f'  Resolution mode: {self._resolution.describe()}'
        )

    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.'
            )

        logger.info(
            f'{OK} Q range for fitting: {new_qmin:.6g} to {new_qmax:.6g} {INVERSE_ANGSTROM}\n'
            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())
        logger.info(
            f'{OK} Q range reset to {self.data.qmin:.6g} to {self.data.qmax:.6g} '
            f'{INVERSE_ANGSTROM}\n  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_resolution(
        self,
        mode: str = 'data',
        dq_over_q: float | None = None,
        slit_length: float | None = None,
        slit_width: float | None = None,
    ) -> None:
        """
        State how instrument resolution is applied when the model is evaluated.

        Resolution smearing changes the fitted parameters, so it is a stated
        choice rather than a property of the input file. The four modes mirror
        SasView's Fit Page (*None* / *Use dQ Data* / *Custom Pinhole* /
        *Custom Slit*)::

            fitter.set_resolution('data')                     # default
            fitter.set_resolution('none')
            fitter.set_resolution('pinhole', dq_over_q=0.10)
            fitter.set_resolution('slit', slit_length=0.05)

        The setting is applied to a **copy** of the dataset made for
        evaluation; ``fitter.data`` is never modified. It reaches
        ``fit(engine='bumps')``, ``fit(engine='lmfit')``, ``fit_bayesian()``
        and the post-fit curve displays alike.

        The mode is fitter state, not data state: it **persists** across
        ``load_data()`` / ``set_data()``, exactly as the model, the parameters
        and the links do. Mode ``'data'`` already means "use *this* dataset's
        columns", so swapping datasets under the default needs no reset; the
        load summary reports the active mode so a custom width cannot be
        applied to a new file unnoticed.

        Args:
            mode: Which resolution to apply.

                - ``'data'`` (default): the dataset's own resolution columns.
                  A dQ (pinhole) column wins over slit columns, as in
                  sasmodels. A dataset carrying only ``dxl``/``dxw`` is smeared
                  with slit geometry. A dataset with no resolution columns
                  warns and is evaluated unsmeared.
                - ``'none'``: perfect resolution; any columns in the file are
                  ignored.
                - ``'pinhole'``: constant relative width, ``dx = dq_over_q·q``.
                - ``'slit'``: constant slit geometry.
            dq_over_q: Relative pinhole width **σ_q/q, a Gaussian 1-σ** — the
                same quantity as the file's dQ column and as
                :func:`sans_fitter.examples.simulate`'s ``dq``. **It is not
                FWHM.** Required by ``'pinhole'``, rejected by other modes.
            slit_length: Slit length along q, an absolute width in Å⁻¹
                (sasmodels' ``dxl``). **Required** by ``'slit'`` — sasmodels
                does not implement smearing from a slit width alone.
            slit_width: Slit width perpendicular to q, an absolute width in
                Å⁻¹ (sasmodels' ``dxw``). Optional under ``'slit'``; omit it
                for the usual long-slit (USANS) geometry.

        Raises:
            ValueError: If the mode is unknown, an argument does not belong to
                the mode, a required argument is missing, or a width is
                non-finite, negative or degenerate (a pinhole or slit length of
                zero). Every check runs before any state is touched, so a
                rejected call leaves the fitter exactly as it was.

        Note:
            Per-point custom widths, constant *absolute* σ_q, 2D/oriented
            resolution and fittable resolution parameters are out of scope.
            P(r) inversion is unaffected: it reads ``fitter.data``, which this
            setting deliberately leaves alone.
        """
        self._resolution = validate_resolution(
            mode, dq_over_q=dq_over_q, slit_length=slit_length, slit_width=slit_width
        )
        logger.info(f'{OK} Resolution: {self._resolution.describe()}')

    def get_resolution(self) -> dict[str, Any]:
        """
        Return the active resolution setting.

        Works before any data is loaded — the mode is fitter state, and the
        evaluation copy it describes is only built at fit time.

        Returns:
            A fresh dict with all four keys always present, e.g.
            ``{'mode': 'pinhole', 'dq_over_q': 0.1, 'slit_length': None,
            'slit_width': None}``. Mutating it does not affect the fitter.
        """
        return self._resolution.as_dict()

    def _evaluation_data(self, data: Any = None, *, warn: bool = True) -> Any:
        """Return the dataset copy that sasmodels should be handed.

        The single seam through which every calculator built from fitter state
        gets its data. Keeping it single is what lets the engines stay
        untouched, and it is where the ``dy`` rewriting of the data-weighting
        work will go too: copy once, apply resolution, then weighting.

        Args:
            data: Dataset to base the copy on. Defaults to ``self.data``.
            warn: Whether mode ``'data'`` may warn about missing or shadowed
                resolution columns. Post-fit evaluation paths pass ``False``
                because the fit they follow has already warned.
        """
        return apply_resolution(self.data if data is None else data, self._resolution, warn=warn)

    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)

            logger.info(
                f"{OK} Model '{model_name}' loaded successfully\n"
                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))

        lines = [
            f'{OK} Combined {len(components)} models: {expression}',
            f'  Components: {", ".join(m for m, _n in components)}',
        ]
        if shared:
            lines.append(f'  Shared parameters: {", ".join(shared)}')
        lines.append(f'  Available parameters: {len(self._param_manager.params)}')
        logger.info('\n'.join(lines))

    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.

        This is the same mechanism as
        ``set_structure_factor(..., radius_effective_mode='link_radius')``,
        which links ``'radius_effective'`` to ``'radius'``; ``get_links()``
        reports both alike.

        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)
        logger.info(f'{OK} Linked {name} {ARROW} {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)
        logger.info(f'{OK} 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.

        Available structure factors are queried from sasmodels at runtime —
        see :func:`get_structure_factors` for the full, up-to-date list
        (e.g. 'hardsphere', 'hayter_msa', 'squarewell', 'stickyhardsphere').

        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 name or radius_effective_mode is invalid. Every check
                runs before any state is touched, so a rejected call leaves the
                fitter exactly as it was.
        """
        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 against sasmodels (cached query)
        supported_sf = get_structure_factors()
        if structure_factor_name not in supported_sf:
            raise ValueError(
                f"Unsupported structure factor '{structure_factor_name}'. "
                f'Supported: {", ".join(supported_sf)}'
            )

        # Validate the mode here, before anything is swapped. Leaving it to
        # update_for_product_model would abort *after* self.kernel had become
        # the product model while params still described the form factor — a
        # desynchronized state in which the next fit silently evaluates the
        # product kernel with default structure-factor parameters.
        validate_radius_effective_mode(radius_effective_mode)

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

        try:
            # Load the product model. Held locally until the parameter manager
            # has accepted it, so a failure cannot desynchronize the two.
            product_kernel = load_model(full_model_name, dtype='single', platform='dll')

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

            lines = []
            if radius_effective_mode == 'link_radius':
                lines.append("  Note: 'radius_effective' linked to 'radius' value")
            lines.append(
                f"{OK} Structure factor '{structure_factor_name}' applied to '{self.model_name}'"
            )
            lines.append(f'  Product model: {full_model_name}')
            lines.append(f'  Total parameters: {len(self.params)}')
            logger.info('\n'.join(lines))

        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. Held locally until the
        # parameter manager has restored its side, for the same reason as in
        # set_structure_factor: kernel and params must never disagree.
        try:
            form_factor_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()
            self.kernel = form_factor_kernel

            logger.info(
                f"{OK} Structure factor '{sf_name}' removed\n"
                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
        # Record what smeared this fit: the parameter values only mean
        # something alongside the resolution that produced them, and χ² is not
        # comparable across modes.
        self._fit_contract.resolution = self._resolution.as_dict()

        # 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).
        to_display = self._param_manager.to_display_name
        self._fit_contract.parameters = {
            to_display(name): dict(info)
            | {'linked_to': to_display(info['linked_to']) if info['linked_to'] else None}
            for name, info in self._fit_contract.parameters.items()
        }

        # Covariance labels are canonical too, and land next to the parameter
        # names in the same translation (Boundary 2 of the alias layer).
        self._fit_contract.cov_labels = [to_display(name) for name in self._fit_contract.cov_labels]

        # Record the configuration and data this result belongs to. No setter
        # clears a fit result, so without this a later save_analysis() could
        # pair the current settings with a chi-squared that a different set
        # produced. Taken after apply_fitted_values, so it describes the fit.
        self._fit_contract.fit_context = build_fit_context(
            self._param_manager.export_config(),
            self._resolution.as_dict(),
            self.get_q_range(),
            self._fit_contract.n_points,
            None if self.data is None else fingerprint_arrays(self.data),
        )

        # 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_contract.on_bounds = self._find_parameters_on_bounds()

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

        logger.info(f'\n{OK} Fit completed!\n{self.get_fit_report()}')

        self._warn_about_bounds(self._fit_contract.on_bounds)
        if self._fit_contract.converged is False:
            warnings.warn(
                f'The optimizer did not report convergence: {self._fit_contract.message}. '
                'The reported parameters are wherever it stopped; increase the '
                'iteration budget or revisit the starting values.',
                stacklevel=3,
            )

        return self.fit_result

    def _find_parameters_on_bounds(self) -> list[tuple[str, str]]:
        """Varied parameters whose fitted value sits on one of their bounds.

        Only the optimizer's own dimensions can hit a wall, so fixed and linked
        parameters are skipped even when their value happens to equal a bound.
        Polydispersity widths are bounded [0, 1] by both engines rather than by
        ``ParameterManager``, so their limits are supplied here.

        Names are user-facing: this runs after the display-name translation.
        """
        hits: list[tuple[str, str]] = []
        for name, info in self._fit_contract.parameters.items():
            if info.get('fixed', False) or info.get('linked_to') is not None:
                continue
            value = info.get('value')
            if value is None:
                continue

            bounds = self._param_manager.params.get(name)
            if bounds is not None:
                lo, hi = bounds.get('min'), bounds.get('max')
            elif name.endswith('_pd'):
                # PD widths are not in the parameter table; both engines bound
                # them to [0, 1] themselves.
                lo, hi = 0.0, 1.0
            else:
                continue
            if lo is not None and at_bound(value, lo):
                hits.append((name, 'min'))
            if hi is not None and at_bound(value, hi):
                hits.append((name, 'max'))
        return hits

    def _warn_about_bounds(self, on_bounds: list[tuple[str, str]]) -> None:
        """Warn once about every fitted parameter resting on a bound.

        A warning rather than a log line, so it survives
        ``set_verbosity('quiet')`` and shows up in a notebook. The wording is
        deliberately neutral: an optimum at a bound can be physically correct (a
        non-negative background at zero), so the claim is that the estimate is
        constrained by the configured domain, not that a better one lies outside it.
        """
        if not on_bounds:
            return
        hits = ', '.join(
            f'{name} = {self._fit_contract.parameters[name]["value"]:.6g} ({side})'
            for name, side in on_bounds
        )
        warnings.warn(
            f'Fitted parameter(s) at a bound: {hits}. The estimate may be constrained '
            'by these limits; review or widen the bounds if the boundary was not '
            'intentional. Uncertainties from a symmetric covariance estimate are '
            'unreliable at an active bound.',
            stacklevel=3,
        )

    def get_fit_report(self) -> FitReport:
        """
        Return a :class:`~sans_fitter.report.FitReport` for the last fit.

        The report carries the goodness-of-fit statistics, the parameter table, the
        covariance and correlation matrices, the convergence verdict and any
        parameter resting on a bound. It renders itself as a table in a notebook
        (``_repr_html_``), as plain text (``print(report)``) and as Markdown
        (``report.to_markdown()``), and serializes through ``report.to_dict()``.

        Returns:
            A snapshot of the current fit result. Mutating the fitter afterwards
            does not change a report already returned.

        Raises:
            ValueError: If no fit has been run in this session. A theory preview
                (``plot_model()``) does not produce a fit result.
        """
        if self._fit_contract is None:
            raise ValueError(
                'No fit result available. Run fit() or fit_bayesian() first; '
                'plot_model() previews the theory without fitting.'
            )
        return FitReport.from_contract(self._fit_contract, self.model_name)

    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

        # Built once and shared by every component: same resolution as the
        # fit, so the parts and the total are smeared alike.
        evaluation_data = self._evaluation_data(warn=False)

        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')
            # Evaluated through the same resolution as the fit: otherwise a
            # slit or custom-pinhole fit would overlay sharp component curves
            # under a smeared total.
            calculator = DirectModel(evaluation_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 _legacy_quality_block(
        self, fit_result: dict[str, Any], fit_index: np.ndarray
    ) -> dict[str, Any]:
        """Goodness-of-fit fields for a contract rebuilt from a result dictionary.

        A dictionary that already carries ``reduced_chisq`` came from this version
        and is read verbatim. An older one carries a single ``chisq`` whose meaning
        depended on the engine — bumps stored χ²/dof, the scipy engine stored the
        raw sum — so the conversion is determined, not guessed: recover the counts
        from the fit index and the parameter block, then derive whichever of the two
        χ² values the dictionary does not hold.
        """
        if 'reduced_chisq' in fit_result:
            return {
                'chisq': fit_result['chisq'],
                'reduced_chisq': fit_result['reduced_chisq'],
                'n_points': fit_result['n_points'],
                'n_free': fit_result['n_free'],
                'dof': fit_result['dof'],
                'weighting_note': fit_result.get('weighting_note', 'dI'),
                'converged': fit_result.get('converged'),
                'message': fit_result.get('message', ''),
                'cov': fit_result.get('cov'),
                'cov_labels': list(fit_result.get('cov_labels', [])),
                'cov_source': fit_result.get('cov_source'),
                'on_bounds': list(fit_result.get('on_bounds', [])),
            }

        legacy_chisq = float(fit_result['chisq'])
        n_points = int(np.asarray(fit_index, dtype=bool).sum())
        n_free = sum(1 for info in fit_result['parameters'].values() if not info.get('fixed', True))
        dof = n_points - n_free
        if fit_result['engine'] == 'bumps':
            raw = legacy_chisq * dof if dof > 0 else float('nan')
            reduced = legacy_chisq
        else:
            raw = legacy_chisq
            reduced = reduced_chisq(legacy_chisq, dof)
        return {
            'chisq': raw,
            'reduced_chisq': reduced,
            'n_points': n_points,
            'n_free': n_free,
            'dof': dof,
            'weighting_note': 'dI',
            'message': 'legacy result adapted from fit_result',
        }

    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':
            fit_index = extract_fit_index(self._fitted_model.active_model)
            curve = np.asarray(self._fitted_model.active_model.theory())
            resolved_index = resolve_fit_index(fit_index, len(self.data.x))
            return FitResultContract(
                engine=self.fit_result['engine'],
                method=self.fit_result['method'],
                parameters=self.fit_result['parameters'],
                artifacts=FitArtifacts(fitted_curve=curve, fit_index=fit_index),
                resolution=self._resolution.as_dict(),
                **self._legacy_quality_block(self.fit_result, resolved_index),
            )

        # Re-evaluating legacy lmfit runtime state: use the same evaluation
        # copy the fit itself used, or the re-plotted/saved curve would be
        # unsmeared while the fit was smeared.
        calculator = DirectModel(self._evaluation_data(warn=False), self.kernel)
        par_dict = {name: info['value'] for name, info in self.fit_result['parameters'].items()}
        fit_index = extract_fit_index(calculator)
        resolved_index = resolve_fit_index(fit_index, len(self.data.x))
        return FitResultContract(
            engine=self.fit_result['engine'],
            method=self.fit_result['method'],
            parameters=self.fit_result['parameters'],
            artifacts=FitArtifacts(
                fitted_curve=np.asarray(calculator(**par_dict)),
                fit_index=fit_index,
            ),
            resolution=self._resolution.as_dict(),
            **self._legacy_quality_block(self.fit_result, resolved_index),
        )

    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 ``engine``, ``method``, ``parameters`` and an
            engine-independent goodness-of-fit block: ``chisq`` (the **raw**
            weighted sum of squared residuals), ``reduced_chisq``
            (``chisq / dof``, not a number when ``dof <= 0``), ``n_points``,
            ``n_free``, ``dof``, ``converged`` (``None`` on the bumps engine,
            which reports success unconditionally), ``message``,
            ``weighting_note``, ``cov`` / ``cov_labels`` / ``cov_source``, and
            ``on_bounds``. Call :meth:`get_fit_report` for the same information
            as a self-rendering object.

            The ``parameters`` block is engine-independent too: one entry per
            model parameter, each carrying ``value``, ``stderr``, ``formatted``,
            a ``fixed`` flag (``False`` only for the parameters the optimizer
            varied) and ``linked_to`` (the parameter it follows, or ``None``).
            A follower reports its target's fitted value.

            Changed in 0.4: ``chisq`` from the bumps engine used to be
            χ²/dof. Use ``reduced_chisq`` for that value.

        Raises:
            ValueError: If data or model not loaded, or invalid engine
            NotImplementedError: If a composite model is used with an engine
                other than 'bumps'.
        """
        self._require_data()
        self._require_model()

        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)
        self._log_active_resolution()

        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 _log_active_resolution(self) -> None:
        """Report the resolution a fit is about to use, before the engine runs.

        Before, not after: the engine's own "Initial χ²" line is already a
        smeared number, so the reader needs to know what smeared it first.
        """
        logger.info(f'Resolution: {self._resolution.describe()}')

    def _check_composite_engine_support(self, engine: str) -> None:
        """Gate composite models 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. Parameter links are *not* gated: both engines apply
        them on every model evaluation. shared= needs no gate of its own — it
        only exists on composite models, which this check already covers.
        """
        if engine == 'bumps':
            return
        snapshot = self._param_manager.snapshot_fit_state()
        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._evaluation_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._evaluation_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:
            The same dictionary shape as :meth:`fit`, including the
            goodness-of-fit block. ``cov`` here is the sample covariance of the
            posterior draw rather than a Jacobian estimate, and ``converged`` is
            None — a sampler has no optimizer verdict, so the ``message``
            carries the sampler settings and the largest R-hat instead. The
            posterior itself is available via get_posterior(), and
            get_fit_report() renders everything as a table.

        Raises:
            ValueError: If data or model is not loaded, or no parameter varies.
            NotImplementedError: If a composite model is used — the DREAM path
                does not support them yet.
        """
        self._require_data()
        self._require_model()

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

        engine_output = fit_bumps_dream(
            data=self._evaluation_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)

    # =========================================================================
    # Theory preview (model evaluation without fitting)
    # =========================================================================

    def _require_data(self) -> None:
        if self.data is None:
            raise ValueError('No data loaded. Use load_data() first.')

    def _require_model(self) -> None:
        if self.kernel is None:
            raise ValueError('No model loaded. Use set_model() first.')

    def _theory_target(self, q: np.ndarray | None, dq: float | None) -> Any:
        """The dataset to evaluate on: an empty one on *q*, else the loaded data."""
        if q is not None:
            return theory_data(q, dq)
        if dq is not None:
            raise ValueError(
                "dq applies to an explicit q grid only; the loaded data's own "
                'resolution columns are used when q is omitted.'
            )
        self._require_data()
        # The same copy a fit is handed, so a preview under set_resolution('none' |
        # 'pinhole' | 'slit') is smeared exactly like the fit that follows it. Without
        # this the preview would apply the file's own columns and its chi-squared would
        # not match the "Initial chi-squared" a bumps fit prints. warn=False: the fit
        # itself warns about missing or shadowed columns.
        return self._evaluation_data(warn=False)

    def _evaluate(
        self, data: Any, overrides: dict[str, float] | None = None
    ) -> tuple[np.ndarray, np.ndarray]:
        """Evaluate the model on *data* at the current parameters (+ overrides)."""
        self._require_model()
        pars = build_model_parameters(self._param_manager.snapshot_fit_state(), overrides)
        return evaluate_theory(data, self.kernel, pars)

    def calculate(self, q: np.ndarray | None = None, dq: float | None = None) -> np.ndarray:
        """
        Evaluate the model at the current parameter values, without fitting.

        Args:
            q: Q values in Å⁻¹ to evaluate on. When omitted, the loaded
                dataset's Q values are used and its resolution (dQ or slit
                columns) is applied, exactly as during a fit.
            dq: Relative resolution width ΔQ/Q applied to the *q* grid.
                Only valid together with *q*; the dataset carries its own
                resolution.

        Returns:
            Intensities as a float64 array. On the data grid the result has
            one entry per data point, with NaN where a point is excluded from
            the fit (outside the Q range, masked, or NaN), so it can be
            plotted directly against ``fitter.data.x``.

        Raises:
            ValueError: If no model is loaded, if no data is loaded and *q* is
                omitted, if *dq* is given without *q*, or if *q*/*dq* are
                invalid.
        """
        curve, fit_index = self._evaluate(self._theory_target(q, dq))
        return scatter_to_full_length(curve, fit_index)

    def plot_model(
        self,
        show_residuals: bool = True,
        log_scale: bool = True,
        show: bool | None = None,
        show_components: bool = False,
    ) -> Figure:
        """
        Plot the data and the model at the current parameters, without fitting.

        The notebook equivalent of watching SasView redraw the theory as you
        change a parameter: it answers "are my starting values sane?" before
        committing to a fit. Fit results are untouched — ``plot_results()``
        keeps showing the last real fit.

        The reported goodness of fit is χ²/dof — the same convention every
        engine reports as ``result['reduced_chisq']``, and the same number the
        bumps engine prints as "Initial χ²" at the start of a fit. The model is
        evaluated through the active resolution mode, exactly as a fit would,
        so the preview and the fit that follows it are directly comparable. The
        value is reported as not available when the data carries no intensity
        uncertainties, and when the free parameters outnumber the fitted points.

        Args:
            show_residuals: If True, show residuals in a separate panel.
            log_scale: If True, use log scale for both axes.
            show: Same display convention as plot_results().
            show_components: If True and the model is a '+' mixture, overlay
                one dashed curve per component.

        Returns:
            Plotly Figure object

        Raises:
            ValueError: If no data or no model is loaded.
        """
        self._require_data()
        evaluation_data = self._evaluation_data(warn=False)
        curve, fit_index = self._evaluate(evaluation_data)

        fit_state = self._param_manager.snapshot_fit_state()
        n_free = len(fit_state.varying_params) + len(fit_state.varying_pd_params)
        quality = preview_quality(evaluation_data, curve, fit_index, n_free)

        # Current values only: nothing was estimated, so there is no stderr.
        contract = FitResultContract(
            engine=PREVIEW_ENGINE,
            method=PREVIEW_ENGINE,
            chisq=quality.chisq,
            reduced_chisq=quality.reduced_chisq,
            n_points=quality.n_points,
            n_free=quality.n_free,
            dof=quality.dof,
            weighting_note='dI',
            parameters={
                self._param_manager.to_display_name(name): {'value': info['value']}
                for name, info in fit_state.params.items()
            },
            resolution=self._resolution.as_dict(),
            artifacts=FitArtifacts(
                fitted_curve=curve,
                fit_index=fit_index,
                residuals=quality.residuals,
                component_curves=self._compute_component_curves() if show_components else None,
            ),
        )

        logger.info(
            f'{OK} Model preview: {self.model_name} — '
            f'{format_reduced_chisq(quality.reduced_chisq, quality.dof, CHI_SQUARED)} at current '
            f'parameters ({quality.n_points} points, {n_free} free)'
        )

        return plot_fit(
            data=self.data,
            fit_result=contract,
            model_name=self.model_name,
            show_residuals=show_residuals,
            log_scale=log_scale,
            show=show,
            show_components=show_components,
        )

    def compare(
        self,
        cases: dict[str, dict[str, float]] | None = None,
        q: np.ndarray | None = None,
        dq: float | None = None,
        log_scale: bool = True,
        show: bool | None = None,
        show_data: bool = True,
        **sweep: Sequence[float],
    ) -> Figure:
        """
        Overlay theory curves for several parameter sets on one plot.

        Each case starts from the current parameters and applies its own
        overrides; the fitter's own parameters are never changed.

        Args:
            cases: Label -> parameter overrides, e.g.
                ``{'thin': {'radius': 20}, 'thick': {'radius': 40}}``. An
                empty override dict means the current parameters. Override
                names accept aliases, canonical names, shared names and
                polydispersity widths (``radius_pd``).
            q: Q values to evaluate on. When omitted, the loaded dataset's Q
                values and resolution are used.
            dq: Relative resolution width ΔQ/Q for the *q* grid.
            log_scale: If True, use log scale for both axes.
            show: Same display convention as plot_results().
            show_data: If True and data is loaded, draw the measured points.
                With an explicit *q*, the data keeps its own Q grid.
            **sweep: One parameter name mapped to a sequence of values, as an
                alternative to *cases*: ``compare(radius=[20, 30, 40])``.

        Returns:
            Plotly Figure object

        Raises:
            ValueError: If no model is loaded, if neither or both spellings
                are used, if more than one sweep parameter is given, or if
                data is required but not loaded.
            KeyError: If an override names an unknown parameter.
        """
        self._require_model()

        if cases and sweep:
            raise ValueError('Pass either cases or a parameter sweep, not both.')
        if sweep:
            if len(sweep) > 1:
                raise ValueError(
                    f'Only one parameter can be swept at a time (got {", ".join(sweep)}). '
                    'Use the cases= form to vary several parameters.'
                )
            name, values = next(iter(sweep.items()))
            cases = {f'{name} = {value:g}': {name: value} for value in values}
        if not cases:
            raise ValueError(
                "Nothing to compare. Pass cases={'label': {...}} or a sweep such as "
                'radius=[20, 30, 40].'
            )

        target = self._theory_target(q, dq)
        curves: dict[str, np.ndarray] = {}
        for label, overrides in cases.items():
            curve, fit_index = self._evaluate(
                target, self._param_manager.canonical_overrides(overrides)
            )
            curves[label] = scatter_to_full_length(curve, fit_index)

        return plotting.plot_model_comparison(
            x=np.asarray(target.x),
            curves=curves,
            data=self.data if (show_data and self.data is not None) else None,
            model_name=self.model_name,
            log_scale=log_scale,
            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,
        )

        logger.info(f'{OK} Results saved to {filename}')

    # =========================================================================
    # Persistence and reporting
    # =========================================================================

    def save_analysis(self, filename: str, include_result: bool = True) -> None:
        """
        Save the complete analysis setup, and the last fit result, as JSON.

        What ``save_results()`` writes is the *outcome* of a fit. This writes
        how the fit was set up: the model expression and its component names,
        every parameter value, bound and vary flag, polydispersity, links, the
        structure factor, the resolution mode and the Q range. Reload it with
        :meth:`load_analysis`.

        JSON rather than pickle, so the file is readable, diffable, reviewable
        in a pull request, and safe to accept from a collaborator.

        **The result is only saved while it still describes the setup.** No
        setter clears a fit result, so a fitter can hold one produced by
        settings that have since changed (a parameter edited, the Q range
        restricted, the resolution switched, the data replaced). Saving the
        two together would pair a chi-squared with a configuration that never
        produced it. When they disagree, the setup is written, the result is
        left out, and the log line says which facet moved. Fit before you save,
        or save before you experiment.

        Args:
            filename: Output path for the JSON analysis file.
            include_result: When False, write the setup alone. Useful as a
                template: a configured model with no sample-specific outcome,
                ready to apply to the next dataset.

        Raises:
            AnalysisFileError: If no model is set, or the target directory
                does not exist. A subclass of ValueError.
        """
        document = write_analysis(self, filename, include_result=include_result)

        lines = [f'{OK} Analysis saved to {filename}']
        if document['result'] is not None:
            lines.append('  Fit result included')
        elif document['result_omitted'] is not None:
            lines.append(f'  Fit result NOT included: {document["result_omitted"]}')
        elif include_result:
            lines.append('  No fit result to include')
        else:
            lines.append('  Setup only (include_result=False)')
        logger.info('\n'.join(lines))

    @classmethod
    def load_analysis(
        cls,
        filename: str,
        data: Any = None,
        allow_custom_models: bool = False,
    ) -> 'SANSFitter':
        """
        Rebuild a fitter from a file written by :meth:`save_analysis`.

        The data file is looked for next to the analysis file first (by the
        relative path recorded when it was saved) and then at its original
        absolute path, so an analysis that travelled together with its data
        loads on another machine.

        Args:
            filename: Path to the JSON analysis file.
            data: Dataset to use instead of the recorded one: a path, or an
                in-memory dataset. Required for an analysis saved from
                ``set_data()``, whose dataset has no file to reload. An
                explicit value that cannot be used is an error rather than a
                silent fall back to the recorded sample.
            allow_custom_models: Permit a model expression that is not built
                into sasmodels. Off by default: loading such an expression
                imports a plugin module named by the file, so it is code
                execution chosen by whoever wrote the file, not by you.

        Returns:
            A configured fitter. When the saved result still describes the
            restored setup and the same data, it is attached too, so
            ``plot_results()``, ``save_results()`` and ``get_fit_report()``
            work without refitting.

        Raises:
            AnalysisFileError: If the file is missing, malformed, written by a
                newer schema, describes a different model, or its data cannot
                be found. A subclass of ValueError.
        """
        return read_analysis(cls, filename, data=data, allow_custom_models=allow_custom_models)

    def report(
        self,
        filename: str | None = None,
        fmt: str | None = None,
        offline: bool = False,
    ) -> Report:
        """
        Render a shareable report: settings, result tables and the fit plot.

        One document holding everything a colleague needs to judge the fit: the
        model and data it used, how it was smeared and weighted, the Q range,
        the goodness-of-fit and parameter tables from
        :meth:`get_fit_report`, and the plot. After ``fit_bayesian()`` the
        posterior summary comes along with it.

        Before any fit this produces a configuration report instead of raising:
        the settings and the current parameter values, with a theory preview in
        place of the fit plot. That is also what an analysis loaded with
        ``include_result=False`` renders.

        **HTML needs nothing beyond the standard dependencies; Markdown needs
        an image renderer.** A Markdown report references a sidecar PNG, named
        after the report file so two reports in one directory cannot overwrite
        each other's figure. With no usable renderer the figure is left out and
        a warning says how to install one. HTML embeds the interactive plot
        either way.

        Args:
            filename: Where to write. The extension chooses the format
                (``.html``/``.htm`` or ``.md``/``.markdown``). When omitted,
                nothing is written and the report is only returned.
            fmt: Format override, ``'html'`` or ``'markdown'``. Required to
                pick a format when *filename* is omitted; defaults to HTML.
            offline: Embed the Plotly library in the HTML instead of loading it
                from a CDN. Produces a much larger file that needs no network.

        Returns:
            A Report. ``str()`` gives the Markdown, ``to_html()`` the HTML, and
            a notebook renders it directly.

        Raises:
            ValueError: If the extension does not name a supported format, or
                the target directory does not exist.
        """
        resolved = fmt or (format_for(filename) if filename else 'html')
        if resolved not in ('html', 'markdown'):
            raise ValueError(f"Unknown report format '{resolved}'. Use 'html' or 'markdown'.")

        stem = os.path.splitext(os.path.basename(filename))[0] if filename else 'report'
        report = render_report(self, offline=offline, asset_stem=stem)

        if resolved == 'markdown' and filename is None and report.assets:
            # Nowhere to put the sidecar, so the figure cannot be referenced.
            report = render_report(self, offline=offline, asset_stem=stem, include_figure=False)

        warn_if_no_image(report, resolved)
        if filename is not None:
            report.write(filename, fmt=resolved)
            logger.info(f'{OK} Report written to {filename}')
        return report

load_data(filename, dataset=0)

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.

Files may hold several datasets (e.g. CanSAS XML with multiple SASentry blocks). Pass dataset to select one by 0-based index or by name (title, run id or filename); the first dataset is used by default. If the file contains more than one dataset, a warning lists them all.

Parameters:

Name Type Description Default
filename str

Path to the data file

required
dataset int | str

Which dataset to load — a 0-based index or a name (title, run id or filename). Defaults to the first dataset.

0

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, dataset: int | str = 0) -> 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.

    Files may hold several datasets (e.g. CanSAS XML with multiple
    ``SASentry`` blocks). Pass *dataset* to select one by 0-based index or
    by name (title, run id or filename); the first dataset is used by
    default. If the file contains more than one dataset, a warning lists
    them all.

    Args:
        filename: Path to the data file
        dataset: Which dataset to load — a 0-based index or a name (title,
            run id or filename). Defaults to the first dataset.

    Raises:
        FileNotFoundError: If the file doesn't exist
        ValueError: If the data cannot be loaded or is invalid
    """
    loaded = load_sans_dataset(filename, dataset=dataset)
    self.data = loaded.data
    # Recorded here because nothing downstream can recover it: the selector
    # is resolved to a position and dropped, and the dataset's own
    # .filename metadata is not an authoritative path.
    self._data_source = DataSource.from_file(
        filename,
        self.data,
        requested=dataset,
        index=loaded.index,
        n_datasets=loaded.n_datasets,
    )
    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)

    logger.info(
        f'{OK} Loaded data from {filename}\n'
        f'  Q range: {self.data.qmin:.4f} to {self.data.qmax:.4f} {INVERSE_ANGSTROM}\n'
        f'  Data points: {len(self.data.x)}\n'
        f'  Error (dI) column: {"yes" if has_dy else "no"}\n'
        f'  Resolution (dQ) column: {"yes" if has_dx else "no"}\n'
        f'  Resolution mode: {self._resolution.describe()}'
    )

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)
    # Replaces any file provenance: this dataset did not come from a path,
    # and inferring one from its metadata would be wrong (data_ops results
    # carry an operation string in .filename, not a file).
    self._data_source = DataSource.from_memory(self.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'

    logger.info(
        f'{OK} Data set: {label}\n'
        f'  Q range: {self.data.qmin:.4f} to {self.data.qmax:.4f} {INVERSE_ANGSTROM}\n'
        f'  Data points: {len(self.data.x)}\n'
        f'  Error (dI) column: {"yes" if has_dy else "no"}\n'
        f'  Resolution (dQ) column: {"yes" if has_dx else "no"}\n'
        f'  Resolution mode: {self._resolution.describe()}'
    )

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.'
        )

    logger.info(
        f'{OK} Q range for fitting: {new_qmin:.6g} to {new_qmax:.6g} {INVERSE_ANGSTROM}\n'
        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())
    logger.info(
        f'{OK} Q range reset to {self.data.qmin:.6g} to {self.data.qmax:.6g} '
        f'{INVERSE_ANGSTROM}\n  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_resolution(mode='data', dq_over_q=None, slit_length=None, slit_width=None)

State how instrument resolution is applied when the model is evaluated.

Resolution smearing changes the fitted parameters, so it is a stated choice rather than a property of the input file. The four modes mirror SasView's Fit Page (None / Use dQ Data / Custom Pinhole / Custom Slit)::

fitter.set_resolution('data')                     # default
fitter.set_resolution('none')
fitter.set_resolution('pinhole', dq_over_q=0.10)
fitter.set_resolution('slit', slit_length=0.05)

The setting is applied to a copy of the dataset made for evaluation; fitter.data is never modified. It reaches fit(engine='bumps'), fit(engine='lmfit'), fit_bayesian() and the post-fit curve displays alike.

The mode is fitter state, not data state: it persists across load_data() / set_data(), exactly as the model, the parameters and the links do. Mode 'data' already means "use this dataset's columns", so swapping datasets under the default needs no reset; the load summary reports the active mode so a custom width cannot be applied to a new file unnoticed.

Parameters:

Name Type Description Default
mode str

Which resolution to apply.

  • 'data' (default): the dataset's own resolution columns. A dQ (pinhole) column wins over slit columns, as in sasmodels. A dataset carrying only dxl/dxw is smeared with slit geometry. A dataset with no resolution columns warns and is evaluated unsmeared.
  • 'none': perfect resolution; any columns in the file are ignored.
  • 'pinhole': constant relative width, dx = dq_over_q·q.
  • 'slit': constant slit geometry.
'data'
dq_over_q float | None

Relative pinhole width σ_q/q, a Gaussian 1-σ — the same quantity as the file's dQ column and as :func:sans_fitter.examples.simulate's dq. It is not FWHM. Required by 'pinhole', rejected by other modes.

None
slit_length float | None

Slit length along q, an absolute width in Å⁻¹ (sasmodels' dxl). Required by 'slit' — sasmodels does not implement smearing from a slit width alone.

None
slit_width float | None

Slit width perpendicular to q, an absolute width in Å⁻¹ (sasmodels' dxw). Optional under 'slit'; omit it for the usual long-slit (USANS) geometry.

None

Raises:

Type Description
ValueError

If the mode is unknown, an argument does not belong to the mode, a required argument is missing, or a width is non-finite, negative or degenerate (a pinhole or slit length of zero). Every check runs before any state is touched, so a rejected call leaves the fitter exactly as it was.

Note

Per-point custom widths, constant absolute σ_q, 2D/oriented resolution and fittable resolution parameters are out of scope. P(r) inversion is unaffected: it reads fitter.data, which this setting deliberately leaves alone.

Source code in src/sans_fitter/fitter.py
def set_resolution(
    self,
    mode: str = 'data',
    dq_over_q: float | None = None,
    slit_length: float | None = None,
    slit_width: float | None = None,
) -> None:
    """
    State how instrument resolution is applied when the model is evaluated.

    Resolution smearing changes the fitted parameters, so it is a stated
    choice rather than a property of the input file. The four modes mirror
    SasView's Fit Page (*None* / *Use dQ Data* / *Custom Pinhole* /
    *Custom Slit*)::

        fitter.set_resolution('data')                     # default
        fitter.set_resolution('none')
        fitter.set_resolution('pinhole', dq_over_q=0.10)
        fitter.set_resolution('slit', slit_length=0.05)

    The setting is applied to a **copy** of the dataset made for
    evaluation; ``fitter.data`` is never modified. It reaches
    ``fit(engine='bumps')``, ``fit(engine='lmfit')``, ``fit_bayesian()``
    and the post-fit curve displays alike.

    The mode is fitter state, not data state: it **persists** across
    ``load_data()`` / ``set_data()``, exactly as the model, the parameters
    and the links do. Mode ``'data'`` already means "use *this* dataset's
    columns", so swapping datasets under the default needs no reset; the
    load summary reports the active mode so a custom width cannot be
    applied to a new file unnoticed.

    Args:
        mode: Which resolution to apply.

            - ``'data'`` (default): the dataset's own resolution columns.
              A dQ (pinhole) column wins over slit columns, as in
              sasmodels. A dataset carrying only ``dxl``/``dxw`` is smeared
              with slit geometry. A dataset with no resolution columns
              warns and is evaluated unsmeared.
            - ``'none'``: perfect resolution; any columns in the file are
              ignored.
            - ``'pinhole'``: constant relative width, ``dx = dq_over_q·q``.
            - ``'slit'``: constant slit geometry.
        dq_over_q: Relative pinhole width **σ_q/q, a Gaussian 1-σ** — the
            same quantity as the file's dQ column and as
            :func:`sans_fitter.examples.simulate`'s ``dq``. **It is not
            FWHM.** Required by ``'pinhole'``, rejected by other modes.
        slit_length: Slit length along q, an absolute width in Å⁻¹
            (sasmodels' ``dxl``). **Required** by ``'slit'`` — sasmodels
            does not implement smearing from a slit width alone.
        slit_width: Slit width perpendicular to q, an absolute width in
            Å⁻¹ (sasmodels' ``dxw``). Optional under ``'slit'``; omit it
            for the usual long-slit (USANS) geometry.

    Raises:
        ValueError: If the mode is unknown, an argument does not belong to
            the mode, a required argument is missing, or a width is
            non-finite, negative or degenerate (a pinhole or slit length of
            zero). Every check runs before any state is touched, so a
            rejected call leaves the fitter exactly as it was.

    Note:
        Per-point custom widths, constant *absolute* σ_q, 2D/oriented
        resolution and fittable resolution parameters are out of scope.
        P(r) inversion is unaffected: it reads ``fitter.data``, which this
        setting deliberately leaves alone.
    """
    self._resolution = validate_resolution(
        mode, dq_over_q=dq_over_q, slit_length=slit_length, slit_width=slit_width
    )
    logger.info(f'{OK} Resolution: {self._resolution.describe()}')

get_resolution()

Return the active resolution setting.

Works before any data is loaded — the mode is fitter state, and the evaluation copy it describes is only built at fit time.

Returns:

Type Description
dict[str, Any]

A fresh dict with all four keys always present, e.g.

dict[str, Any]

``{'mode': 'pinhole', 'dq_over_q': 0.1, 'slit_length': None,

dict[str, Any]

'slit_width': None}``. Mutating it does not affect the fitter.

Source code in src/sans_fitter/fitter.py
def get_resolution(self) -> dict[str, Any]:
    """
    Return the active resolution setting.

    Works before any data is loaded — the mode is fitter state, and the
    evaluation copy it describes is only built at fit time.

    Returns:
        A fresh dict with all four keys always present, e.g.
        ``{'mode': 'pinhole', 'dq_over_q': 0.1, 'slit_length': None,
        'slit_width': None}``. Mutating it does not affect the fitter.
    """
    return self._resolution.as_dict()

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)

        logger.info(
            f"{OK} Model '{model_name}' loaded successfully\n"
            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))

    lines = [
        f'{OK} Combined {len(components)} models: {expression}',
        f'  Components: {", ".join(m for m, _n in components)}',
    ]
    if shared:
        lines.append(f'  Shared parameters: {", ".join(shared)}')
    lines.append(f'  Available parameters: {len(self._param_manager.params)}')
    logger.info('\n'.join(lines))

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.

This is the same mechanism as set_structure_factor(..., radius_effective_mode='link_radius'), which links 'radius_effective' to 'radius'; get_links() reports both alike.

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.

    This is the same mechanism as
    ``set_structure_factor(..., radius_effective_mode='link_radius')``,
    which links ``'radius_effective'`` to ``'radius'``; ``get_links()``
    reports both alike.

    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)
    logger.info(f'{OK} Linked {name} {ARROW} {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)
    logger.info(f'{OK} 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.

Available structure factors are queried from sasmodels at runtime — see :func:get_structure_factors for the full, up-to-date list (e.g. 'hardsphere', 'hayter_msa', 'squarewell', 'stickyhardsphere').

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 name or radius_effective_mode is invalid. Every check runs before any state is touched, so a rejected call leaves the fitter exactly as it was.

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.

    Available structure factors are queried from sasmodels at runtime —
    see :func:`get_structure_factors` for the full, up-to-date list
    (e.g. 'hardsphere', 'hayter_msa', 'squarewell', 'stickyhardsphere').

    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 name or radius_effective_mode is invalid. Every check
            runs before any state is touched, so a rejected call leaves the
            fitter exactly as it was.
    """
    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 against sasmodels (cached query)
    supported_sf = get_structure_factors()
    if structure_factor_name not in supported_sf:
        raise ValueError(
            f"Unsupported structure factor '{structure_factor_name}'. "
            f'Supported: {", ".join(supported_sf)}'
        )

    # Validate the mode here, before anything is swapped. Leaving it to
    # update_for_product_model would abort *after* self.kernel had become
    # the product model while params still described the form factor — a
    # desynchronized state in which the next fit silently evaluates the
    # product kernel with default structure-factor parameters.
    validate_radius_effective_mode(radius_effective_mode)

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

    try:
        # Load the product model. Held locally until the parameter manager
        # has accepted it, so a failure cannot desynchronize the two.
        product_kernel = load_model(full_model_name, dtype='single', platform='dll')

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

        lines = []
        if radius_effective_mode == 'link_radius':
            lines.append("  Note: 'radius_effective' linked to 'radius' value")
        lines.append(
            f"{OK} Structure factor '{structure_factor_name}' applied to '{self.model_name}'"
        )
        lines.append(f'  Product model: {full_model_name}')
        lines.append(f'  Total parameters: {len(self.params)}')
        logger.info('\n'.join(lines))

    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. Held locally until the
    # parameter manager has restored its side, for the same reason as in
    # set_structure_factor: kernel and params must never disagree.
    try:
        form_factor_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()
        self.kernel = form_factor_kernel

        logger.info(
            f"{OK} Structure factor '{sf_name}' removed\n"
            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)

calculate(q=None, dq=None)

Evaluate the model at the current parameter values, without fitting.

Parameters:

Name Type Description Default
q ndarray | None

Q values in Å⁻¹ to evaluate on. When omitted, the loaded dataset's Q values are used and its resolution (dQ or slit columns) is applied, exactly as during a fit.

None
dq float | None

Relative resolution width ΔQ/Q applied to the q grid. Only valid together with q; the dataset carries its own resolution.

None

Returns:

Type Description
ndarray

Intensities as a float64 array. On the data grid the result has

ndarray

one entry per data point, with NaN where a point is excluded from

ndarray

the fit (outside the Q range, masked, or NaN), so it can be

ndarray

plotted directly against fitter.data.x.

Raises:

Type Description
ValueError

If no model is loaded, if no data is loaded and q is omitted, if dq is given without q, or if q/dq are invalid.

Source code in src/sans_fitter/fitter.py
def calculate(self, q: np.ndarray | None = None, dq: float | None = None) -> np.ndarray:
    """
    Evaluate the model at the current parameter values, without fitting.

    Args:
        q: Q values in Å⁻¹ to evaluate on. When omitted, the loaded
            dataset's Q values are used and its resolution (dQ or slit
            columns) is applied, exactly as during a fit.
        dq: Relative resolution width ΔQ/Q applied to the *q* grid.
            Only valid together with *q*; the dataset carries its own
            resolution.

    Returns:
        Intensities as a float64 array. On the data grid the result has
        one entry per data point, with NaN where a point is excluded from
        the fit (outside the Q range, masked, or NaN), so it can be
        plotted directly against ``fitter.data.x``.

    Raises:
        ValueError: If no model is loaded, if no data is loaded and *q* is
            omitted, if *dq* is given without *q*, or if *q*/*dq* are
            invalid.
    """
    curve, fit_index = self._evaluate(self._theory_target(q, dq))
    return scatter_to_full_length(curve, fit_index)

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

Plot the data and the model at the current parameters, without fitting.

The notebook equivalent of watching SasView redraw the theory as you change a parameter: it answers "are my starting values sane?" before committing to a fit. Fit results are untouched — plot_results() keeps showing the last real fit.

The reported goodness of fit is χ²/dof — the same convention every engine reports as result['reduced_chisq'], and the same number the bumps engine prints as "Initial χ²" at the start of a fit. The model is evaluated through the active resolution mode, exactly as a fit would, so the preview and the fit that follows it are directly comparable. The value is reported as not available when the data carries no intensity uncertainties, and when the free parameters outnumber the fitted points.

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

Same display convention as plot_results().

None
show_components bool

If True and the model is a '+' mixture, overlay one dashed curve per component.

False

Returns:

Type Description
Figure

Plotly Figure object

Raises:

Type Description
ValueError

If no data or no model is loaded.

Source code in src/sans_fitter/fitter.py
def plot_model(
    self,
    show_residuals: bool = True,
    log_scale: bool = True,
    show: bool | None = None,
    show_components: bool = False,
) -> Figure:
    """
    Plot the data and the model at the current parameters, without fitting.

    The notebook equivalent of watching SasView redraw the theory as you
    change a parameter: it answers "are my starting values sane?" before
    committing to a fit. Fit results are untouched — ``plot_results()``
    keeps showing the last real fit.

    The reported goodness of fit is χ²/dof — the same convention every
    engine reports as ``result['reduced_chisq']``, and the same number the
    bumps engine prints as "Initial χ²" at the start of a fit. The model is
    evaluated through the active resolution mode, exactly as a fit would,
    so the preview and the fit that follows it are directly comparable. The
    value is reported as not available when the data carries no intensity
    uncertainties, and when the free parameters outnumber the fitted points.

    Args:
        show_residuals: If True, show residuals in a separate panel.
        log_scale: If True, use log scale for both axes.
        show: Same display convention as plot_results().
        show_components: If True and the model is a '+' mixture, overlay
            one dashed curve per component.

    Returns:
        Plotly Figure object

    Raises:
        ValueError: If no data or no model is loaded.
    """
    self._require_data()
    evaluation_data = self._evaluation_data(warn=False)
    curve, fit_index = self._evaluate(evaluation_data)

    fit_state = self._param_manager.snapshot_fit_state()
    n_free = len(fit_state.varying_params) + len(fit_state.varying_pd_params)
    quality = preview_quality(evaluation_data, curve, fit_index, n_free)

    # Current values only: nothing was estimated, so there is no stderr.
    contract = FitResultContract(
        engine=PREVIEW_ENGINE,
        method=PREVIEW_ENGINE,
        chisq=quality.chisq,
        reduced_chisq=quality.reduced_chisq,
        n_points=quality.n_points,
        n_free=quality.n_free,
        dof=quality.dof,
        weighting_note='dI',
        parameters={
            self._param_manager.to_display_name(name): {'value': info['value']}
            for name, info in fit_state.params.items()
        },
        resolution=self._resolution.as_dict(),
        artifacts=FitArtifacts(
            fitted_curve=curve,
            fit_index=fit_index,
            residuals=quality.residuals,
            component_curves=self._compute_component_curves() if show_components else None,
        ),
    )

    logger.info(
        f'{OK} Model preview: {self.model_name} — '
        f'{format_reduced_chisq(quality.reduced_chisq, quality.dof, CHI_SQUARED)} at current '
        f'parameters ({quality.n_points} points, {n_free} free)'
    )

    return plot_fit(
        data=self.data,
        fit_result=contract,
        model_name=self.model_name,
        show_residuals=show_residuals,
        log_scale=log_scale,
        show=show,
        show_components=show_components,
    )

compare(cases=None, q=None, dq=None, log_scale=True, show=None, show_data=True, **sweep)

Overlay theory curves for several parameter sets on one plot.

Each case starts from the current parameters and applies its own overrides; the fitter's own parameters are never changed.

Parameters:

Name Type Description Default
cases dict[str, dict[str, float]] | None

Label -> parameter overrides, e.g. {'thin': {'radius': 20}, 'thick': {'radius': 40}}. An empty override dict means the current parameters. Override names accept aliases, canonical names, shared names and polydispersity widths (radius_pd).

None
q ndarray | None

Q values to evaluate on. When omitted, the loaded dataset's Q values and resolution are used.

None
dq float | None

Relative resolution width ΔQ/Q for the q grid.

None
log_scale bool

If True, use log scale for both axes.

True
show bool | None

Same display convention as plot_results().

None
show_data bool

If True and data is loaded, draw the measured points. With an explicit q, the data keeps its own Q grid.

True
**sweep Sequence[float]

One parameter name mapped to a sequence of values, as an alternative to cases: compare(radius=[20, 30, 40]).

{}

Returns:

Type Description
Figure

Plotly Figure object

Raises:

Type Description
ValueError

If no model is loaded, if neither or both spellings are used, if more than one sweep parameter is given, or if data is required but not loaded.

KeyError

If an override names an unknown parameter.

Source code in src/sans_fitter/fitter.py
def compare(
    self,
    cases: dict[str, dict[str, float]] | None = None,
    q: np.ndarray | None = None,
    dq: float | None = None,
    log_scale: bool = True,
    show: bool | None = None,
    show_data: bool = True,
    **sweep: Sequence[float],
) -> Figure:
    """
    Overlay theory curves for several parameter sets on one plot.

    Each case starts from the current parameters and applies its own
    overrides; the fitter's own parameters are never changed.

    Args:
        cases: Label -> parameter overrides, e.g.
            ``{'thin': {'radius': 20}, 'thick': {'radius': 40}}``. An
            empty override dict means the current parameters. Override
            names accept aliases, canonical names, shared names and
            polydispersity widths (``radius_pd``).
        q: Q values to evaluate on. When omitted, the loaded dataset's Q
            values and resolution are used.
        dq: Relative resolution width ΔQ/Q for the *q* grid.
        log_scale: If True, use log scale for both axes.
        show: Same display convention as plot_results().
        show_data: If True and data is loaded, draw the measured points.
            With an explicit *q*, the data keeps its own Q grid.
        **sweep: One parameter name mapped to a sequence of values, as an
            alternative to *cases*: ``compare(radius=[20, 30, 40])``.

    Returns:
        Plotly Figure object

    Raises:
        ValueError: If no model is loaded, if neither or both spellings
            are used, if more than one sweep parameter is given, or if
            data is required but not loaded.
        KeyError: If an override names an unknown parameter.
    """
    self._require_model()

    if cases and sweep:
        raise ValueError('Pass either cases or a parameter sweep, not both.')
    if sweep:
        if len(sweep) > 1:
            raise ValueError(
                f'Only one parameter can be swept at a time (got {", ".join(sweep)}). '
                'Use the cases= form to vary several parameters.'
            )
        name, values = next(iter(sweep.items()))
        cases = {f'{name} = {value:g}': {name: value} for value in values}
    if not cases:
        raise ValueError(
            "Nothing to compare. Pass cases={'label': {...}} or a sweep such as "
            'radius=[20, 30, 40].'
        )

    target = self._theory_target(q, dq)
    curves: dict[str, np.ndarray] = {}
    for label, overrides in cases.items():
        curve, fit_index = self._evaluate(
            target, self._param_manager.canonical_overrides(overrides)
        )
        curves[label] = scatter_to_full_length(curve, fit_index)

    return plotting.plot_model_comparison(
        x=np.asarray(target.x),
        curves=curves,
        data=self.data if (show_data and self.data is not None) else None,
        model_name=self.model_name,
        log_scale=log_scale,
        show=show,
    )

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 engine, method, parameters and an

dict[str, Any]

engine-independent goodness-of-fit block: chisq (the raw

dict[str, Any]

weighted sum of squared residuals), reduced_chisq

dict[str, Any]

(chisq / dof, not a number when dof <= 0), n_points,

dict[str, Any]

n_free, dof, converged (None on the bumps engine,

dict[str, Any]

which reports success unconditionally), message,

dict[str, Any]

weighting_note, cov / cov_labels / cov_source, and

dict[str, Any]

on_bounds. Call :meth:get_fit_report for the same information

dict[str, Any]

as a self-rendering object.

dict[str, Any]

The parameters block is engine-independent too: one entry per

dict[str, Any]

model parameter, each carrying value, stderr, formatted,

dict[str, Any]

a fixed flag (False only for the parameters the optimizer

dict[str, Any]

varied) and linked_to (the parameter it follows, or None).

dict[str, Any]

A follower reports its target's fitted value.

dict[str, Any]

Changed in 0.4: chisq from the bumps engine used to be

dict[str, Any]

χ²/dof. Use reduced_chisq for that value.

Raises:

Type Description
ValueError

If data or model not loaded, or invalid engine

NotImplementedError

If a composite model is 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 ``engine``, ``method``, ``parameters`` and an
        engine-independent goodness-of-fit block: ``chisq`` (the **raw**
        weighted sum of squared residuals), ``reduced_chisq``
        (``chisq / dof``, not a number when ``dof <= 0``), ``n_points``,
        ``n_free``, ``dof``, ``converged`` (``None`` on the bumps engine,
        which reports success unconditionally), ``message``,
        ``weighting_note``, ``cov`` / ``cov_labels`` / ``cov_source``, and
        ``on_bounds``. Call :meth:`get_fit_report` for the same information
        as a self-rendering object.

        The ``parameters`` block is engine-independent too: one entry per
        model parameter, each carrying ``value``, ``stderr``, ``formatted``,
        a ``fixed`` flag (``False`` only for the parameters the optimizer
        varied) and ``linked_to`` (the parameter it follows, or ``None``).
        A follower reports its target's fitted value.

        Changed in 0.4: ``chisq`` from the bumps engine used to be
        χ²/dof. Use ``reduced_chisq`` for that value.

    Raises:
        ValueError: If data or model not loaded, or invalid engine
        NotImplementedError: If a composite model is used with an engine
            other than 'bumps'.
    """
    self._require_data()
    self._require_model()

    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)
    self._log_active_resolution()

    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]

The same dictionary shape as :meth:fit, including the

dict[str, Any]

goodness-of-fit block. cov here is the sample covariance of the

dict[str, Any]

posterior draw rather than a Jacobian estimate, and converged is

dict[str, Any]

None — a sampler has no optimizer verdict, so the message

dict[str, Any]

carries the sampler settings and the largest R-hat instead. The

dict[str, Any]

posterior itself is available via get_posterior(), and

dict[str, Any]

get_fit_report() renders everything as a table.

Raises:

Type Description
ValueError

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

NotImplementedError

If a composite model is 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:
        The same dictionary shape as :meth:`fit`, including the
        goodness-of-fit block. ``cov`` here is the sample covariance of the
        posterior draw rather than a Jacobian estimate, and ``converged`` is
        None — a sampler has no optimizer verdict, so the ``message``
        carries the sampler settings and the largest R-hat instead. The
        posterior itself is available via get_posterior(), and
        get_fit_report() renders everything as a table.

    Raises:
        ValueError: If data or model is not loaded, or no parameter varies.
        NotImplementedError: If a composite model is used — the DREAM path
            does not support them yet.
    """
    self._require_data()
    self._require_model()

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

    engine_output = fit_bumps_dream(
        data=self._evaluation_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_fit_report()

Return a :class:~sans_fitter.report.FitReport for the last fit.

The report carries the goodness-of-fit statistics, the parameter table, the covariance and correlation matrices, the convergence verdict and any parameter resting on a bound. It renders itself as a table in a notebook (_repr_html_), as plain text (print(report)) and as Markdown (report.to_markdown()), and serializes through report.to_dict().

Returns:

Type Description
FitReport

A snapshot of the current fit result. Mutating the fitter afterwards

FitReport

does not change a report already returned.

Raises:

Type Description
ValueError

If no fit has been run in this session. A theory preview (plot_model()) does not produce a fit result.

Source code in src/sans_fitter/fitter.py
def get_fit_report(self) -> FitReport:
    """
    Return a :class:`~sans_fitter.report.FitReport` for the last fit.

    The report carries the goodness-of-fit statistics, the parameter table, the
    covariance and correlation matrices, the convergence verdict and any
    parameter resting on a bound. It renders itself as a table in a notebook
    (``_repr_html_``), as plain text (``print(report)``) and as Markdown
    (``report.to_markdown()``), and serializes through ``report.to_dict()``.

    Returns:
        A snapshot of the current fit result. Mutating the fitter afterwards
        does not change a report already returned.

    Raises:
        ValueError: If no fit has been run in this session. A theory preview
            (``plot_model()``) does not produce a fit result.
    """
    if self._fit_contract is None:
        raise ValueError(
            'No fit result available. Run fit() or fit_bayesian() first; '
            'plot_model() previews the theory without fitting.'
        )
    return FitReport.from_contract(self._fit_contract, self.model_name)

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,
    )

    logger.info(f'{OK} Results saved to {filename}')

save_analysis(filename, include_result=True)

Save the complete analysis setup, and the last fit result, as JSON.

What save_results() writes is the outcome of a fit. This writes how the fit was set up: the model expression and its component names, every parameter value, bound and vary flag, polydispersity, links, the structure factor, the resolution mode and the Q range. Reload it with :meth:load_analysis.

JSON rather than pickle, so the file is readable, diffable, reviewable in a pull request, and safe to accept from a collaborator.

The result is only saved while it still describes the setup. No setter clears a fit result, so a fitter can hold one produced by settings that have since changed (a parameter edited, the Q range restricted, the resolution switched, the data replaced). Saving the two together would pair a chi-squared with a configuration that never produced it. When they disagree, the setup is written, the result is left out, and the log line says which facet moved. Fit before you save, or save before you experiment.

Parameters:

Name Type Description Default
filename str

Output path for the JSON analysis file.

required
include_result bool

When False, write the setup alone. Useful as a template: a configured model with no sample-specific outcome, ready to apply to the next dataset.

True

Raises:

Type Description
AnalysisFileError

If no model is set, or the target directory does not exist. A subclass of ValueError.

Source code in src/sans_fitter/fitter.py
def save_analysis(self, filename: str, include_result: bool = True) -> None:
    """
    Save the complete analysis setup, and the last fit result, as JSON.

    What ``save_results()`` writes is the *outcome* of a fit. This writes
    how the fit was set up: the model expression and its component names,
    every parameter value, bound and vary flag, polydispersity, links, the
    structure factor, the resolution mode and the Q range. Reload it with
    :meth:`load_analysis`.

    JSON rather than pickle, so the file is readable, diffable, reviewable
    in a pull request, and safe to accept from a collaborator.

    **The result is only saved while it still describes the setup.** No
    setter clears a fit result, so a fitter can hold one produced by
    settings that have since changed (a parameter edited, the Q range
    restricted, the resolution switched, the data replaced). Saving the
    two together would pair a chi-squared with a configuration that never
    produced it. When they disagree, the setup is written, the result is
    left out, and the log line says which facet moved. Fit before you save,
    or save before you experiment.

    Args:
        filename: Output path for the JSON analysis file.
        include_result: When False, write the setup alone. Useful as a
            template: a configured model with no sample-specific outcome,
            ready to apply to the next dataset.

    Raises:
        AnalysisFileError: If no model is set, or the target directory
            does not exist. A subclass of ValueError.
    """
    document = write_analysis(self, filename, include_result=include_result)

    lines = [f'{OK} Analysis saved to {filename}']
    if document['result'] is not None:
        lines.append('  Fit result included')
    elif document['result_omitted'] is not None:
        lines.append(f'  Fit result NOT included: {document["result_omitted"]}')
    elif include_result:
        lines.append('  No fit result to include')
    else:
        lines.append('  Setup only (include_result=False)')
    logger.info('\n'.join(lines))

load_analysis(filename, data=None, allow_custom_models=False) classmethod

Rebuild a fitter from a file written by :meth:save_analysis.

The data file is looked for next to the analysis file first (by the relative path recorded when it was saved) and then at its original absolute path, so an analysis that travelled together with its data loads on another machine.

Parameters:

Name Type Description Default
filename str

Path to the JSON analysis file.

required
data Any

Dataset to use instead of the recorded one: a path, or an in-memory dataset. Required for an analysis saved from set_data(), whose dataset has no file to reload. An explicit value that cannot be used is an error rather than a silent fall back to the recorded sample.

None
allow_custom_models bool

Permit a model expression that is not built into sasmodels. Off by default: loading such an expression imports a plugin module named by the file, so it is code execution chosen by whoever wrote the file, not by you.

False

Returns:

Type Description
SANSFitter

A configured fitter. When the saved result still describes the

SANSFitter

restored setup and the same data, it is attached too, so

SANSFitter

plot_results(), save_results() and get_fit_report()

SANSFitter

work without refitting.

Raises:

Type Description
AnalysisFileError

If the file is missing, malformed, written by a newer schema, describes a different model, or its data cannot be found. A subclass of ValueError.

Source code in src/sans_fitter/fitter.py
@classmethod
def load_analysis(
    cls,
    filename: str,
    data: Any = None,
    allow_custom_models: bool = False,
) -> 'SANSFitter':
    """
    Rebuild a fitter from a file written by :meth:`save_analysis`.

    The data file is looked for next to the analysis file first (by the
    relative path recorded when it was saved) and then at its original
    absolute path, so an analysis that travelled together with its data
    loads on another machine.

    Args:
        filename: Path to the JSON analysis file.
        data: Dataset to use instead of the recorded one: a path, or an
            in-memory dataset. Required for an analysis saved from
            ``set_data()``, whose dataset has no file to reload. An
            explicit value that cannot be used is an error rather than a
            silent fall back to the recorded sample.
        allow_custom_models: Permit a model expression that is not built
            into sasmodels. Off by default: loading such an expression
            imports a plugin module named by the file, so it is code
            execution chosen by whoever wrote the file, not by you.

    Returns:
        A configured fitter. When the saved result still describes the
        restored setup and the same data, it is attached too, so
        ``plot_results()``, ``save_results()`` and ``get_fit_report()``
        work without refitting.

    Raises:
        AnalysisFileError: If the file is missing, malformed, written by a
            newer schema, describes a different model, or its data cannot
            be found. A subclass of ValueError.
    """
    return read_analysis(cls, filename, data=data, allow_custom_models=allow_custom_models)

report(filename=None, fmt=None, offline=False)

Render a shareable report: settings, result tables and the fit plot.

One document holding everything a colleague needs to judge the fit: the model and data it used, how it was smeared and weighted, the Q range, the goodness-of-fit and parameter tables from :meth:get_fit_report, and the plot. After fit_bayesian() the posterior summary comes along with it.

Before any fit this produces a configuration report instead of raising: the settings and the current parameter values, with a theory preview in place of the fit plot. That is also what an analysis loaded with include_result=False renders.

HTML needs nothing beyond the standard dependencies; Markdown needs an image renderer. A Markdown report references a sidecar PNG, named after the report file so two reports in one directory cannot overwrite each other's figure. With no usable renderer the figure is left out and a warning says how to install one. HTML embeds the interactive plot either way.

Parameters:

Name Type Description Default
filename str | None

Where to write. The extension chooses the format (.html/.htm or .md/.markdown). When omitted, nothing is written and the report is only returned.

None
fmt str | None

Format override, 'html' or 'markdown'. Required to pick a format when filename is omitted; defaults to HTML.

None
offline bool

Embed the Plotly library in the HTML instead of loading it from a CDN. Produces a much larger file that needs no network.

False

Returns:

Type Description
Report

A Report. str() gives the Markdown, to_html() the HTML, and

Report

a notebook renders it directly.

Raises:

Type Description
ValueError

If the extension does not name a supported format, or the target directory does not exist.

Source code in src/sans_fitter/fitter.py
def report(
    self,
    filename: str | None = None,
    fmt: str | None = None,
    offline: bool = False,
) -> Report:
    """
    Render a shareable report: settings, result tables and the fit plot.

    One document holding everything a colleague needs to judge the fit: the
    model and data it used, how it was smeared and weighted, the Q range,
    the goodness-of-fit and parameter tables from
    :meth:`get_fit_report`, and the plot. After ``fit_bayesian()`` the
    posterior summary comes along with it.

    Before any fit this produces a configuration report instead of raising:
    the settings and the current parameter values, with a theory preview in
    place of the fit plot. That is also what an analysis loaded with
    ``include_result=False`` renders.

    **HTML needs nothing beyond the standard dependencies; Markdown needs
    an image renderer.** A Markdown report references a sidecar PNG, named
    after the report file so two reports in one directory cannot overwrite
    each other's figure. With no usable renderer the figure is left out and
    a warning says how to install one. HTML embeds the interactive plot
    either way.

    Args:
        filename: Where to write. The extension chooses the format
            (``.html``/``.htm`` or ``.md``/``.markdown``). When omitted,
            nothing is written and the report is only returned.
        fmt: Format override, ``'html'`` or ``'markdown'``. Required to
            pick a format when *filename* is omitted; defaults to HTML.
        offline: Embed the Plotly library in the HTML instead of loading it
            from a CDN. Produces a much larger file that needs no network.

    Returns:
        A Report. ``str()`` gives the Markdown, ``to_html()`` the HTML, and
        a notebook renders it directly.

    Raises:
        ValueError: If the extension does not name a supported format, or
            the target directory does not exist.
    """
    resolved = fmt or (format_for(filename) if filename else 'html')
    if resolved not in ('html', 'markdown'):
        raise ValueError(f"Unknown report format '{resolved}'. Use 'html' or 'markdown'.")

    stem = os.path.splitext(os.path.basename(filename))[0] if filename else 'report'
    report = render_report(self, offline=offline, asset_stem=stem)

    if resolved == 'markdown' and filename is None and report.assets:
        # Nowhere to put the sidecar, so the figure cannot be referenced.
        report = render_report(self, offline=offline, asset_stem=stem, include_figure=False)

    warn_if_no_image(report, resolved)
    if filename is not None:
        report.write(filename, fmt=resolved)
        logger.info(f'{OK} Report written to {filename}')
    return report

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_radius → A_radius). Parameters listed in shared= collapse to a single unprefixed name (sld → A_sld + B_sld); their prefixed aliases remain addressable for polydispersity configuration.

FitReport

Goodness-of-fit summary returned by SANSFitter.get_fit_report(): the χ² statistics, the parameter table, the covariance and correlation matrices, the convergence verdict and any parameter resting on a bound. Renders itself as a table in a notebook, as plain text, and as Markdown.

sans_fitter.report.FitReport dataclass

Goodness-of-fit summary for one completed fit.

Built by :meth:sans_fitter.SANSFitter.get_fit_report. Not exported at the top level of the package, but a public type all the same: the import path sans_fitter.report.FitReport and the methods below are stable.

Source code in src/sans_fitter/report.py
 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
@dataclass(slots=True)
class FitReport:
    """Goodness-of-fit summary for one completed fit.

    Built by :meth:`sans_fitter.SANSFitter.get_fit_report`. Not exported at the
    top level of the package, but a public type all the same: the import path
    ``sans_fitter.report.FitReport`` and the methods below are stable.
    """

    model: str
    engine: str
    method: str
    resolution: str
    weighting_note: str
    chisq: float
    reduced_chisq: float
    n_points: int
    n_free: int
    dof: int
    converged: bool | None
    message: str
    parameters: dict[str, dict[str, Any]]
    cov_labels: list[str] = field(default_factory=list)
    cov: np.ndarray | None = None
    cov_source: str | None = None
    on_bounds: list[tuple[str, str]] = field(default_factory=list)
    posterior: PosteriorSummary | None = None

    def __post_init__(self) -> None:
        if self.cov is not None:
            matrix = np.asarray(self.cov, dtype=float)
            expected = (len(self.cov_labels), len(self.cov_labels))
            if matrix.ndim != 2 or matrix.shape != expected:
                raise ValueError(
                    f'Covariance matrix shape {matrix.shape} does not match the '
                    f'{len(self.cov_labels)} label(s) {self.cov_labels} '
                    f'(expected {expected}).'
                )
            self.cov = matrix

    @classmethod
    def from_contract(cls, contract: FitResultContract, model_name: str) -> 'FitReport':
        """Snapshot *contract* into a report.

        Nothing mutable is shared with the fitter: the parameter block is copied
        one level deep (each entry becomes a fresh dictionary), the covariance is
        copied, and the posterior is deep-copied — its ``samples`` array and its
        per-parameter statistic and diagnostic dictionaries are all mutable, and a
        caller holding the object from ``get_posterior()`` would otherwise be able
        to change a report that was already handed out.
        """
        resolution = 'not recorded'
        if contract.resolution is not None:
            try:
                resolution = ResolutionSetting(**contract.resolution).describe()
            except TypeError:  # a setting shape from another version — show it raw
                resolution = str(contract.resolution)

        return cls(
            model=model_name or 'unknown model',
            engine=contract.engine,
            method=contract.method,
            resolution=resolution,
            weighting_note=contract.weighting_note,
            chisq=contract.chisq,
            reduced_chisq=contract.reduced_chisq,
            n_points=contract.n_points,
            n_free=contract.n_free,
            dof=contract.dof,
            converged=contract.converged,
            message=contract.message,
            parameters={name: dict(info) for name, info in contract.parameters.items()},
            cov_labels=list(contract.cov_labels),
            cov=None if contract.cov is None else np.array(contract.cov, copy=True),
            cov_source=contract.cov_source,
            on_bounds=[tuple(hit) for hit in contract.on_bounds],
            posterior=(
                None
                if contract.artifacts.posterior is None
                else copy.deepcopy(contract.artifacts.posterior)
            ),
        )

    # ------------------------------------------------------------------
    # Derived quantities
    # ------------------------------------------------------------------

    @property
    def corr(self) -> np.ndarray | None:
        """Correlation matrix derived from :attr:`cov`, or None when unavailable."""
        if self.cov is None:
            return None
        return correlation_matrix(self.cov)

    def strongly_correlated(
        self, threshold: float = DEFAULT_CORRELATION_THRESHOLD
    ) -> list[tuple[str, str, float]]:
        """Parameter pairs whose correlation magnitude reaches *threshold*.

        Walks the strict upper triangle, so each pair appears once, and skips cells
        that are not finite (a zero-variance dimension). The returned coefficient
        keeps its sign: an anti-correlation of -0.99 is as informative as +0.99.

        Args:
            threshold: Magnitude at or above which a pair is reported, in [0, 1].

        Raises:
            ValueError: If *threshold* lies outside [0, 1].
        """
        if not 0.0 <= threshold <= 1.0:
            raise ValueError(f'threshold must be between 0 and 1, got {threshold}.')
        corr = self.corr
        if corr is None:
            return []

        pairs: list[tuple[str, str, float]] = []
        for i in range(len(self.cov_labels)):
            for j in range(i + 1, len(self.cov_labels)):
                rho = float(corr[i, j])
                if math.isfinite(rho) and abs(rho) >= threshold:
                    pairs.append((self.cov_labels[i], self.cov_labels[j], rho))
        return sorted(pairs, key=lambda item: abs(item[2]), reverse=True)

    def _bound_side(self, name: str) -> str | None:
        for hit_name, side in self.on_bounds:
            if hit_name == name:
                return side
        return None

    def _parameter_rows(self, arrow: str = ARROW) -> list[tuple[str, str, str]]:
        """(name, estimate, status) for every parameter, in contract order.

        A fitted parameter's estimate is the engine's own ``formatted`` string, so
        the bumps ``45.041(46)`` convention survives. A fixed or linked one is
        rendered as a plain number, because its ``formatted`` string carries a
        ``(fixed)`` / ``(linked)`` suffix that the status column already states.
        """
        rows: list[tuple[str, str, str]] = []
        for name, info in self.parameters.items():
            linked_to = info.get('linked_to')
            is_fixed = info.get('fixed', False)

            if linked_to:
                status = f'linked {arrow} {linked_to}'
                estimate = _format_number(info.get('value'))
            elif is_fixed:
                status = 'fixed'
                estimate = _format_number(info.get('value'))
            else:
                status = 'fitted'
                side = self._bound_side(name)
                if side is not None:
                    status = f'fitted, on bound ({side})'
                estimate = str(info.get('formatted', _format_number(info.get('value'))))
            rows.append((name, estimate, status))
        return rows

    def _quality_rows(self, symbol: str) -> list[tuple[str, str]]:
        rows = [
            (f'{symbol}/dof', _format_number(self.reduced_chisq)),
            (symbol, _format_number(self.chisq)),
            ('Points fitted', str(self.n_points)),
            ('Free parameters', str(self.n_free)),
            ('Degrees of freedom', str(self.dof)),
            ('Converged', _verdict(self.converged)),
        ]
        if self.message:
            rows.append(('Optimizer message', self.message))
        return rows

    def _header(self) -> str:
        return (
            f'{self.model} · {self.engine}/{self.method} · '
            f'resolution: {self.resolution} · weighting: {self.weighting_note}'
        )

    def _show_correlations(self) -> bool:
        return self.cov is not None and len(self.cov_labels) > 1

    # ------------------------------------------------------------------
    # Renderers
    # ------------------------------------------------------------------

    def __str__(self) -> str:
        """Plain-text report, using the console's glyph fallbacks.

        Safe to print on a console that cannot encode χ²: the symbol comes from
        ``console.CHI_SQUARED``, which degrades to ASCII.
        """
        symbol = CHI_SQUARED
        lines = [f'Fit report: {self._header()}', '=' * 72]

        quality = self._quality_rows(symbol)
        width = max(len(label) for label, _ in quality)
        lines.extend(f'{label:<{width}}  {value}' for label, value in quality)

        rows = self._parameter_rows()
        if rows:
            lines.extend(['', 'Parameters:'])
            name_width = max(9, *(len(name) for name, _, _ in rows))
            estimate_width = max(8, *(len(estimate) for _, estimate, _ in rows))
            header = f'{"Parameter":<{name_width}}  {"Estimate":<{estimate_width}}  Status'
            lines.extend([header, '-' * len(header)])
            lines.extend(
                f'{name:<{name_width}}  {estimate:<{estimate_width}}  {status}'
                for name, estimate, status in rows
            )

        if self._show_correlations():
            corr = self.corr
            lines.extend(['', f'Correlations (source: {self.cov_source}):'])
            label_width = max(len(label) for label in self.cov_labels)
            cell_width = max(6, label_width)
            head = ' ' * label_width + ''.join(
                f'  {label:>{cell_width}}' for label in self.cov_labels
            )
            lines.extend([head, '-' * len(head)])
            for i, label in enumerate(self.cov_labels):
                cells = ''.join(
                    f'  {_correlation_text(corr[i, j]):>{cell_width}}'
                    for j in range(len(self.cov_labels))
                )
                lines.append(f'{label:<{label_width}}{cells}')
            strong = self.strongly_correlated()
            if strong:
                lines.append('')
                lines.extend(
                    f'  strongly correlated: {a} / {b} ({rho:+.2f})' for a, b, rho in strong
                )

        if self.on_bounds:
            hits = ', '.join(f'{name} ({side})' for name, side in self.on_bounds)
            lines.extend(['', f'At a bound: {hits}'])

        if self.posterior is not None:
            lines.extend(['', self.posterior.format_summary()])

        return '\n'.join(lines)

    def to_markdown(self) -> str:
        """Markdown report, for a document, an issue comment or a pull request.

        Writes the Unicode χ² directly: the result is a string the caller places,
        not console output, so the ASCII fallback would only make it uglier.
        """
        symbol = 'χ²'
        out = [f'**Fit report:** {escape_markdown_cell(self._header())}', '']
        out.extend(['| Statistic | Value |', '|---|---|'])
        out.extend(
            f'| {escape_markdown_cell(label)} | {escape_markdown_cell(value)} |'
            for label, value in self._quality_rows(symbol)
        )

        rows = self._parameter_rows(arrow='->')
        if rows:
            out.extend(['', '| Parameter | Estimate | Status |', '|---|---|---|'])
            out.extend(
                f'| {escape_markdown_cell(name)} | {escape_markdown_cell(estimate)} '
                f'| {escape_markdown_cell(status)} |'
                for name, estimate, status in rows
            )

        if self._show_correlations():
            corr = self.corr
            out.extend(['', f'Correlations (source: {escape_markdown_cell(self.cov_source)}):', ''])
            header = ' | '.join([''] + [escape_markdown_cell(n) for n in self.cov_labels])
            out.append(f'| {header} |')
            out.append('|' + '---|' * (len(self.cov_labels) + 1))
            threshold = DEFAULT_CORRELATION_THRESHOLD
            for i, label in enumerate(self.cov_labels):
                cells = []
                for j in range(len(self.cov_labels)):
                    text = _correlation_text(corr[i, j])
                    rho = float(corr[i, j])
                    if i != j and math.isfinite(rho) and abs(rho) >= threshold:
                        text = f'**{text}**'
                    cells.append(text)
                out.append(f'| {escape_markdown_cell(label)} | ' + ' | '.join(cells) + ' |')
            strong = self.strongly_correlated()
            if strong:
                out.append('')
                out.extend(
                    f'- Strongly correlated: `{escape_markdown_cell(a)}` / '
                    f'`{escape_markdown_cell(b)}` ({rho:+.2f})'
                    for a, b, rho in strong
                )

        if self.on_bounds:
            hits = ', '.join(f'`{escape_markdown_cell(n)}` ({side})' for n, side in self.on_bounds)
            out.extend(['', f'At a bound: {hits}'])

        if self.posterior is not None:
            out.extend(['', '```', self.posterior.format_summary(), '```'])

        return '\n'.join(out)

    def _repr_html_(self) -> str:
        """HTML report, so a notebook cell shows a table instead of text."""
        symbol = 'χ²'
        style = 'border-collapse:collapse;margin-bottom:0.75em'
        cell = 'padding:2px 10px;border:1px solid #ddd;text-align:left'
        head = 'padding:2px 10px;border:1px solid #ddd;text-align:left;background:#f5f5f5'

        parts = [f'<div><p><strong>Fit report:</strong> {escape_html_cell(self._header())}</p>']

        parts.append(f'<table style="{style}">')
        for label, value in self._quality_rows(symbol):
            parts.append(
                f'<tr><th style="{head}">{escape_html_cell(label)}</th>'
                f'<td style="{cell}">{escape_html_cell(value)}</td></tr>'
            )
        parts.append('</table>')

        rows = self._parameter_rows()
        if rows:
            parts.append(f'<table style="{style}">')
            parts.append(
                f'<tr><th style="{head}">Parameter</th><th style="{head}">Estimate</th>'
                f'<th style="{head}">Status</th></tr>'
            )
            for name, estimate, status in rows:
                parts.append(
                    f'<tr><td style="{cell}">{escape_html_cell(name)}</td>'
                    f'<td style="{cell}">{escape_html_cell(estimate)}</td>'
                    f'<td style="{cell}">{escape_html_cell(status)}</td></tr>'
                )
            parts.append('</table>')

        if self._show_correlations():
            corr = self.corr
            parts.append(
                f'<p>Correlations (source: {escape_html_cell(self.cov_source)}):</p>'
                f'<table style="{style}">'
            )
            heads = ''.join(
                f'<th style="{head}">{escape_html_cell(n)}</th>' for n in self.cov_labels
            )
            parts.append(f'<tr><th style="{head}"></th>{heads}</tr>')
            threshold = DEFAULT_CORRELATION_THRESHOLD
            for i, label in enumerate(self.cov_labels):
                cells = []
                for j in range(len(self.cov_labels)):
                    text = escape_html_cell(_correlation_text(corr[i, j]))
                    rho = float(corr[i, j])
                    if i != j and math.isfinite(rho) and abs(rho) >= threshold:
                        text = f'<strong>{text}</strong>'
                    cells.append(f'<td style="{cell}">{text}</td>')
                parts.append(
                    f'<tr><th style="{head}">{escape_html_cell(label)}</th>'
                    + ''.join(cells)
                    + '</tr>'
                )
            parts.append('</table>')
            strong = self.strongly_correlated()
            if strong:
                items = ''.join(
                    f'<li>{escape_html_cell(a)} / {escape_html_cell(b)} ({rho:+.2f})</li>'
                    for a, b, rho in strong
                )
                parts.append(f'<p>Strongly correlated:</p><ul>{items}</ul>')

        if self.on_bounds:
            hits = ', '.join(f'{escape_html_cell(n)} ({side})' for n, side in self.on_bounds)
            parts.append(f'<p><strong>At a bound:</strong> {hits}</p>')

        if self.posterior is not None:
            parts.append(f'<pre>{escape_html_cell(self.posterior.format_summary())}</pre>')

        parts.append('</div>')
        return ''.join(parts)

    # ------------------------------------------------------------------
    # Serialization
    # ------------------------------------------------------------------

    def to_dict(self) -> dict[str, Any]:
        """The report as strictly JSON-serializable data.

        Every non-finite float becomes None and every array becomes nested lists,
        at any depth, so ``json.dumps(report.to_dict(), allow_nan=False)`` always
        succeeds. This is the payload a future ``save_analysis()`` will store.
        """
        corr = self.corr
        posterior: dict[str, Any] | None = None
        if self.posterior is not None:
            posterior = {
                'labels': list(self.posterior.labels),
                'n_samples': self.posterior.n_samples,
                'n_params': self.posterior.n_params,
                'best': dict(self.posterior.best),
                'mean': dict(self.posterior.mean),
                'median': dict(self.posterior.median),
                'std': dict(self.posterior.std),
                'ci_68': {name: list(bounds) for name, bounds in self.posterior.ci_68.items()},
                'ci_95': {name: list(bounds) for name, bounds in self.posterior.ci_95.items()},
                'diagnostics': self.posterior.diagnostics,
            }

        payload = {
            'model': self.model,
            'engine': self.engine,
            'method': self.method,
            'resolution': self.resolution,
            'weighting_note': self.weighting_note,
            'chisq': self.chisq,
            'reduced_chisq': self.reduced_chisq,
            'n_points': self.n_points,
            'n_free': self.n_free,
            'dof': self.dof,
            'converged': self.converged,
            'message': self.message,
            'parameters': self.parameters,
            'cov_labels': list(self.cov_labels),
            'cov': self.cov,
            'corr': corr,
            'cov_source': self.cov_source,
            'strong_correlations': [
                {'a': a, 'b': b, 'rho': rho} for a, b, rho in self.strongly_correlated()
            ],
            'on_bounds': [{'parameter': name, 'bound': side} for name, side in self.on_bounds],
            'posterior': posterior,
        }
        return json_safe(payload)

    def to_json(self, indent: int | None = 2) -> str:
        """The report as a JSON string, with non-finite values already handled."""
        return json.dumps(self.to_dict(), indent=indent, allow_nan=False)

corr property

Correlation matrix derived from :attr:cov, or None when unavailable.

from_contract(contract, model_name) classmethod

Snapshot contract into a report.

Nothing mutable is shared with the fitter: the parameter block is copied one level deep (each entry becomes a fresh dictionary), the covariance is copied, and the posterior is deep-copied — its samples array and its per-parameter statistic and diagnostic dictionaries are all mutable, and a caller holding the object from get_posterior() would otherwise be able to change a report that was already handed out.

Source code in src/sans_fitter/report.py
@classmethod
def from_contract(cls, contract: FitResultContract, model_name: str) -> 'FitReport':
    """Snapshot *contract* into a report.

    Nothing mutable is shared with the fitter: the parameter block is copied
    one level deep (each entry becomes a fresh dictionary), the covariance is
    copied, and the posterior is deep-copied — its ``samples`` array and its
    per-parameter statistic and diagnostic dictionaries are all mutable, and a
    caller holding the object from ``get_posterior()`` would otherwise be able
    to change a report that was already handed out.
    """
    resolution = 'not recorded'
    if contract.resolution is not None:
        try:
            resolution = ResolutionSetting(**contract.resolution).describe()
        except TypeError:  # a setting shape from another version — show it raw
            resolution = str(contract.resolution)

    return cls(
        model=model_name or 'unknown model',
        engine=contract.engine,
        method=contract.method,
        resolution=resolution,
        weighting_note=contract.weighting_note,
        chisq=contract.chisq,
        reduced_chisq=contract.reduced_chisq,
        n_points=contract.n_points,
        n_free=contract.n_free,
        dof=contract.dof,
        converged=contract.converged,
        message=contract.message,
        parameters={name: dict(info) for name, info in contract.parameters.items()},
        cov_labels=list(contract.cov_labels),
        cov=None if contract.cov is None else np.array(contract.cov, copy=True),
        cov_source=contract.cov_source,
        on_bounds=[tuple(hit) for hit in contract.on_bounds],
        posterior=(
            None
            if contract.artifacts.posterior is None
            else copy.deepcopy(contract.artifacts.posterior)
        ),
    )

strongly_correlated(threshold=DEFAULT_CORRELATION_THRESHOLD)

Parameter pairs whose correlation magnitude reaches threshold.

Walks the strict upper triangle, so each pair appears once, and skips cells that are not finite (a zero-variance dimension). The returned coefficient keeps its sign: an anti-correlation of -0.99 is as informative as +0.99.

Parameters:

Name Type Description Default
threshold float

Magnitude at or above which a pair is reported, in [0, 1].

DEFAULT_CORRELATION_THRESHOLD

Raises:

Type Description
ValueError

If threshold lies outside [0, 1].

Source code in src/sans_fitter/report.py
def strongly_correlated(
    self, threshold: float = DEFAULT_CORRELATION_THRESHOLD
) -> list[tuple[str, str, float]]:
    """Parameter pairs whose correlation magnitude reaches *threshold*.

    Walks the strict upper triangle, so each pair appears once, and skips cells
    that are not finite (a zero-variance dimension). The returned coefficient
    keeps its sign: an anti-correlation of -0.99 is as informative as +0.99.

    Args:
        threshold: Magnitude at or above which a pair is reported, in [0, 1].

    Raises:
        ValueError: If *threshold* lies outside [0, 1].
    """
    if not 0.0 <= threshold <= 1.0:
        raise ValueError(f'threshold must be between 0 and 1, got {threshold}.')
    corr = self.corr
    if corr is None:
        return []

    pairs: list[tuple[str, str, float]] = []
    for i in range(len(self.cov_labels)):
        for j in range(i + 1, len(self.cov_labels)):
            rho = float(corr[i, j])
            if math.isfinite(rho) and abs(rho) >= threshold:
                pairs.append((self.cov_labels[i], self.cov_labels[j], rho))
    return sorted(pairs, key=lambda item: abs(item[2]), reverse=True)

to_markdown()

Markdown report, for a document, an issue comment or a pull request.

Writes the Unicode χ² directly: the result is a string the caller places, not console output, so the ASCII fallback would only make it uglier.

Source code in src/sans_fitter/report.py
def to_markdown(self) -> str:
    """Markdown report, for a document, an issue comment or a pull request.

    Writes the Unicode χ² directly: the result is a string the caller places,
    not console output, so the ASCII fallback would only make it uglier.
    """
    symbol = 'χ²'
    out = [f'**Fit report:** {escape_markdown_cell(self._header())}', '']
    out.extend(['| Statistic | Value |', '|---|---|'])
    out.extend(
        f'| {escape_markdown_cell(label)} | {escape_markdown_cell(value)} |'
        for label, value in self._quality_rows(symbol)
    )

    rows = self._parameter_rows(arrow='->')
    if rows:
        out.extend(['', '| Parameter | Estimate | Status |', '|---|---|---|'])
        out.extend(
            f'| {escape_markdown_cell(name)} | {escape_markdown_cell(estimate)} '
            f'| {escape_markdown_cell(status)} |'
            for name, estimate, status in rows
        )

    if self._show_correlations():
        corr = self.corr
        out.extend(['', f'Correlations (source: {escape_markdown_cell(self.cov_source)}):', ''])
        header = ' | '.join([''] + [escape_markdown_cell(n) for n in self.cov_labels])
        out.append(f'| {header} |')
        out.append('|' + '---|' * (len(self.cov_labels) + 1))
        threshold = DEFAULT_CORRELATION_THRESHOLD
        for i, label in enumerate(self.cov_labels):
            cells = []
            for j in range(len(self.cov_labels)):
                text = _correlation_text(corr[i, j])
                rho = float(corr[i, j])
                if i != j and math.isfinite(rho) and abs(rho) >= threshold:
                    text = f'**{text}**'
                cells.append(text)
            out.append(f'| {escape_markdown_cell(label)} | ' + ' | '.join(cells) + ' |')
        strong = self.strongly_correlated()
        if strong:
            out.append('')
            out.extend(
                f'- Strongly correlated: `{escape_markdown_cell(a)}` / '
                f'`{escape_markdown_cell(b)}` ({rho:+.2f})'
                for a, b, rho in strong
            )

    if self.on_bounds:
        hits = ', '.join(f'`{escape_markdown_cell(n)}` ({side})' for n, side in self.on_bounds)
        out.extend(['', f'At a bound: {hits}'])

    if self.posterior is not None:
        out.extend(['', '```', self.posterior.format_summary(), '```'])

    return '\n'.join(out)

to_dict()

The report as strictly JSON-serializable data.

Every non-finite float becomes None and every array becomes nested lists, at any depth, so json.dumps(report.to_dict(), allow_nan=False) always succeeds. This is the payload a future save_analysis() will store.

Source code in src/sans_fitter/report.py
def to_dict(self) -> dict[str, Any]:
    """The report as strictly JSON-serializable data.

    Every non-finite float becomes None and every array becomes nested lists,
    at any depth, so ``json.dumps(report.to_dict(), allow_nan=False)`` always
    succeeds. This is the payload a future ``save_analysis()`` will store.
    """
    corr = self.corr
    posterior: dict[str, Any] | None = None
    if self.posterior is not None:
        posterior = {
            'labels': list(self.posterior.labels),
            'n_samples': self.posterior.n_samples,
            'n_params': self.posterior.n_params,
            'best': dict(self.posterior.best),
            'mean': dict(self.posterior.mean),
            'median': dict(self.posterior.median),
            'std': dict(self.posterior.std),
            'ci_68': {name: list(bounds) for name, bounds in self.posterior.ci_68.items()},
            'ci_95': {name: list(bounds) for name, bounds in self.posterior.ci_95.items()},
            'diagnostics': self.posterior.diagnostics,
        }

    payload = {
        'model': self.model,
        'engine': self.engine,
        'method': self.method,
        'resolution': self.resolution,
        'weighting_note': self.weighting_note,
        'chisq': self.chisq,
        'reduced_chisq': self.reduced_chisq,
        'n_points': self.n_points,
        'n_free': self.n_free,
        'dof': self.dof,
        'converged': self.converged,
        'message': self.message,
        'parameters': self.parameters,
        'cov_labels': list(self.cov_labels),
        'cov': self.cov,
        'corr': corr,
        'cov_source': self.cov_source,
        'strong_correlations': [
            {'a': a, 'b': b, 'rho': rho} for a, b, rho in self.strongly_correlated()
        ],
        'on_bounds': [{'parameter': name, 'bound': side} for name, side in self.on_bounds],
        'posterior': posterior,
    }
    return json_safe(payload)

to_json(indent=2)

The report as a JSON string, with non-finite values already handled.

Source code in src/sans_fitter/report.py
def to_json(self, indent: int | None = 2) -> str:
    """The report as a JSON string, with non-finite values already handled."""
    return json.dumps(self.to_dict(), indent=indent, allow_nan=False)

Report

The analysis document returned by SANSFitter.report(): the settings block, the FitReport tables and the fit plot, as one HTML page or Markdown document.

sans_fitter.reporting.Report

A rendered analysis report.

Returned by SANSFitter.report(). A plain string could not be this: it has no _repr_html_, so it would not render in a notebook, and the format would have to be chosen before the caller knew what to do with it.

Source code in src/sans_fitter/reporting.py
class Report:
    """A rendered analysis report.

    Returned by ``SANSFitter.report()``. A plain string could not be this: it
    has no ``_repr_html_``, so it would not render in a notebook, and the
    format would have to be chosen before the caller knew what to do with it.
    """

    def __init__(self, html_text: str, markdown_text: str, assets: dict[str, bytes]):
        self._html = html_text
        self._markdown = markdown_text
        #: Files the Markdown form references, keyed by filename.
        self.assets = assets

    def __str__(self) -> str:
        return self._markdown

    def to_markdown(self) -> str:
        """The report as Markdown."""
        return self._markdown

    def to_html(self) -> str:
        """The report as a self-contained HTML document."""
        return self._html

    def _repr_html_(self) -> str:
        return self._html

    def write(self, filename: str, fmt: str | None = None) -> None:
        """Write the report to *filename*, plus any sidecar assets.

        Rendered before anything is replaced, through a temporary sibling and
        an atomic rename, so a failed write cannot destroy a report that is
        already there.
        """
        resolved = fmt or format_for(filename)
        target = os.path.abspath(filename)
        directory = os.path.dirname(target)
        if directory and not os.path.isdir(directory):
            raise ValueError(f'Directory does not exist: {directory}')

        text = self._html if resolved == 'html' else self._markdown
        written: list[str] = []
        try:
            for name, payload in self.assets.items() if resolved == 'markdown' else {}.items():
                asset_path = os.path.join(directory, name)
                with open(asset_path, 'wb') as handle:
                    handle.write(payload)
                written.append(asset_path)
            _atomic_write(target, text)
        except BaseException:
            for path in written:
                try:
                    os.remove(path)
                except OSError:
                    pass
            raise

to_markdown()

The report as Markdown.

Source code in src/sans_fitter/reporting.py
def to_markdown(self) -> str:
    """The report as Markdown."""
    return self._markdown

to_html()

The report as a self-contained HTML document.

Source code in src/sans_fitter/reporting.py
def to_html(self) -> str:
    """The report as a self-contained HTML document."""
    return self._html

write(filename, fmt=None)

Write the report to filename, plus any sidecar assets.

Rendered before anything is replaced, through a temporary sibling and an atomic rename, so a failed write cannot destroy a report that is already there.

Source code in src/sans_fitter/reporting.py
def write(self, filename: str, fmt: str | None = None) -> None:
    """Write the report to *filename*, plus any sidecar assets.

    Rendered before anything is replaced, through a temporary sibling and
    an atomic rename, so a failed write cannot destroy a report that is
    already there.
    """
    resolved = fmt or format_for(filename)
    target = os.path.abspath(filename)
    directory = os.path.dirname(target)
    if directory and not os.path.isdir(directory):
        raise ValueError(f'Directory does not exist: {directory}')

    text = self._html if resolved == 'html' else self._markdown
    written: list[str] = []
    try:
        for name, payload in self.assets.items() if resolved == 'markdown' else {}.items():
            asset_path = os.path.join(directory, name)
            with open(asset_path, 'wb') as handle:
                handle.write(payload)
            written.append(asset_path)
        _atomic_write(target, text)
    except BaseException:
        for path in written:
            try:
                os.remove(path)
            except OSError:
                pass
        raise

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')

PosteriorDigest

Posterior statistics without the sample chain: what an analysis loaded from a file carries. The statistics tables render exactly as they would after a fit, while displays that need the samples themselves raise instead of inventing them.

sans_fitter.results.PosteriorDigest dataclass

Posterior statistics without the sample chain.

What a saved analysis restores. PosteriorSummary cannot serve here: it requires samples and derives its counts from that array, so a summary-only instance would either be impossible or would misreport having a chain. This carries exactly the read surface FitReport uses, and nothing that implies a chain is available.

Sample-dependent displays (trace, pair and posterior-predictive plots) check for the real thing and raise; see SANSFitter.get_posterior.

Source code in src/sans_fitter/results.py
@dataclass(slots=True)
class PosteriorDigest:
    """Posterior statistics without the sample chain.

    What a saved analysis restores. ``PosteriorSummary`` cannot serve here: it
    requires ``samples`` and derives its counts from that array, so a
    summary-only instance would either be impossible or would misreport having
    a chain. This carries exactly the read surface ``FitReport`` uses, and
    nothing that implies a chain is available.

    Sample-dependent displays (trace, pair and posterior-predictive plots)
    check for the real thing and raise; see ``SANSFitter.get_posterior``.
    """

    labels: list[str]
    n_samples: int
    n_params: int
    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

    @classmethod
    def from_summary(cls, posterior: PosteriorSummary) -> 'PosteriorDigest':
        """Drop the chain from a full posterior, keeping every statistic."""
        return cls(
            labels=list(posterior.labels),
            n_samples=posterior.n_samples,
            n_params=posterior.n_params,
            best=dict(posterior.best),
            mean=dict(posterior.mean),
            median=dict(posterior.median),
            std=dict(posterior.std),
            ci_68={name: tuple(bounds) for name, bounds in posterior.ci_68.items()},
            ci_95={name: tuple(bounds) for name, bounds in posterior.ci_95.items()},
            diagnostics=posterior.diagnostics,
        )

    # The statistics table is identical whether or not the chain is present, so
    # the formatting lives on PosteriorSummary and is borrowed here rather than
    # duplicated. Only attributes this class also defines are touched.
    format_summary = PosteriorSummary.format_summary

format_summary = PosteriorSummary.format_summary class-attribute instance-attribute

from_summary(posterior) classmethod

Drop the chain from a full posterior, keeping every statistic.

Source code in src/sans_fitter/results.py
@classmethod
def from_summary(cls, posterior: PosteriorSummary) -> 'PosteriorDigest':
    """Drop the chain from a full posterior, keeping every statistic."""
    return cls(
        labels=list(posterior.labels),
        n_samples=posterior.n_samples,
        n_params=posterior.n_params,
        best=dict(posterior.best),
        mean=dict(posterior.mean),
        median=dict(posterior.median),
        std=dict(posterior.std),
        ci_68={name: tuple(bounds) for name, bounds in posterior.ci_68.items()},
        ci_95={name: tuple(bounds) for name, bounds in posterior.ci_95.items()},
        diagnostics=posterior.diagnostics,
    )

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, dataset=0)

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).

Files may hold several datasets (e.g. CanSAS XML with multiple SASentry blocks). Pass dataset to select one by 0-based index or by name (title, run id or filename); the first dataset is used by default. A warning is emitted whenever the file contains more than one dataset, listing them so a different selection is possible.

Parameters:

Name Type Description Default
filename str

Path to the data file.

required
dataset int | str

Which dataset to load — a 0-based index or a name (title, run id or filename). Defaults to the first dataset.

0

Returns:

Type Description
Data1D

A Data1D object with qmin/qmax/mask set.

Raises:

Type Description
ValueError

If the file cannot be loaded, contains no data, or the requested dataset does not exist or is ambiguous.

Source code in src/sans_fitter/data/ops.py
def load(filename: str, dataset: int | str = 0) -> 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).

    Files may hold several datasets (e.g. CanSAS XML with multiple
    ``SASentry`` blocks). Pass *dataset* to select one by 0-based index or by
    name (title, run id or filename); the first dataset is used by default. A
    warning is emitted whenever the file contains more than one dataset,
    listing them so a different selection is possible.

    Args:
        filename: Path to the data file.
        dataset: Which dataset to load — a 0-based index or a name (title,
            run id or filename). Defaults to the first dataset.

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

    Raises:
        ValueError: If the file cannot be loaded, contains no data, or the
            requested dataset does not exist or is ambiguous.
    """
    return load_sans_data(filename, dataset=dataset)

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 Data1D — qmin/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 pinhole resolution width σ_q/q (Gaussian 1-σ, not FWHM). When given, dx = dq * q is attached and the simulated intensity is smeared accordingly, matching what an instrument would measure. The same quantity as dq_over_q in :meth:~sans_fitter.SANSFitter.set_resolution, so data simulated with dq is fitted correctly under the default 'data' resolution mode.

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 pinhole resolution width σ_q/q (Gaussian 1-σ, not FWHM).
            When given, ``dx = dq * q`` is attached and the *simulated
            intensity is smeared accordingly*, matching what an instrument
            would measure. The same quantity as ``dq_over_q`` in
            :meth:`~sans_fitter.SANSFitter.set_resolution`, so data simulated
            with ``dq`` is fitted correctly under the default ``'data'``
            resolution mode.
        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

Console output

Methods such as load_data, set_model and fit report what they did through the sans_fitter logger rather than with print, so the running commentary can be turned off:

import sans_fitter

sans_fitter.set_verbosity('quiet')   # keep warnings, drop the progress messages
sans_fitter.set_verbosity('info')    # back to the default

Tables you ask for explicitly - get_params(), get_pd_params(), examples.describe() - are printed either way; they are the result of the call rather than a side effect of it.

sans_fitter.console

Console output: the package logger and encoding-safe glyphs.

Status messages that are a side effect of a call — data loaded, model set, fit finished — go through logging.getLogger('sans_fitter') instead of print, so a caller can turn them off::

import sans_fitter
sans_fitter.set_verbosity('quiet')

The logger owns a stdout handler and does not propagate, so by default the output is byte-for-byte what the print calls produced and it does not duplicate into an application's own logging configuration. To hand it over to that configuration instead::

import logging
log = logging.getLogger('sans_fitter')
log.handlers.clear()
log.propagate = True

Tables the user asks for explicitly (display_params, display_pd_params, examples.describe) still print: they are the result of the call rather than a side effect of it, so routing them through the logger would leave those methods doing nothing when the package is silenced.

The glyph constants fall back to ASCII when the active stdout cannot encode them. A legacy Windows console (cp1252) has no ✓, χ, → or ⁻, and printing them there raises UnicodeEncodeError.

set_verbosity(level='info')

Set how much SANS-fitter reports about what it is doing.

Parameters:

Name Type Description Default
level int | str

'quiet' to drop the progress messages and keep only warnings, or any standard logging level as a name ('info', 'debug', 'warning', 'error') or as an integer. Pass logging.CRITICAL to suppress the warnings as well.

'info'

Raises:

Type Description
ValueError

If level is not a recognized level name.

Source code in src/sans_fitter/console.py
def set_verbosity(level: int | str = 'info') -> None:
    """Set how much SANS-fitter reports about what it is doing.

    Args:
        level: ``'quiet'`` to drop the progress messages and keep only
            warnings, or any standard logging level as a name (``'info'``,
            ``'debug'``, ``'warning'``, ``'error'``) or as an integer. Pass
            ``logging.CRITICAL`` to suppress the warnings as well.

    Raises:
        ValueError: If *level* is not a recognized level name.
    """
    if isinstance(level, str):
        resolved = _LEVEL_ALIASES.get(level.lower(), logging.getLevelName(level.upper()))
        if not isinstance(resolved, int):
            raise ValueError(
                f"Unknown verbosity '{level}'. Use 'quiet', 'info', 'debug', "
                "'warning', 'error', or a logging level number."
            )
        level = resolved
    logger.setLevel(level)

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, equality links between parameters, 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
 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
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
class ParameterManager:
    """
    Manages model parameters for SANS fitting.

    Handles parameter initialization, validation, bounds management,
    equality links between parameters, 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). Sources: link_params(), the shared=
        # sugar of set_models(), and radius_effective_mode='link_radius' (which
        # owns its 'radius_effective' entry — see update_for_product_model).
        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 canonical_overrides(self, overrides: dict[str, float]) -> dict[str, float]:
        """Translate user-facing parameter overrides to canonical names.

        Accepts everything ``set_param``/``set_pd_param`` accept: aliases,
        canonical names, shared names (which expand to every canonical name
        they drive) and polydispersity widths (``radius_pd``). Used for
        what-if evaluation (``SANSFitter.compare``) without touching state.

        Raises:
            KeyError: If a name is not a known parameter, or a ``_pd`` name
                does not belong to a polydisperse parameter.
            ValueError: If the parameter is a link follower, whose value is
                dictated by its target.
        """
        canonical: dict[str, float] = {}
        for name, value in overrides.items():
            if name.endswith('_pd'):
                base = self._resolve_canonical(name.removesuffix('_pd'))
                if base not in self._pd_manager.get_parameters():
                    available = ', '.join(self._pd_manager.get_parameters())
                    raise KeyError(
                        f"'{name}' is not a polydispersity width. "
                        f'Polydisperse parameters: {available}'
                    )
                canonical[f'{base}_pd'] = value
                continue

            resolved = self.resolve_name(name)
            # In link_radius mode 'radius_effective' is an ordinary follower,
            # so the structure-factor link needs no special case here.
            if resolved in self._links:
                raise ValueError(
                    f"Parameter '{name}' is linked to '{self._links[resolved]}' and cannot "
                    'be set directly. Override the target, or unlink_params() first.'
                )
            for target in self._shared_to_canonicals.get(
                resolved, [self._resolve_canonical(resolved)]
            ):
                canonical[target] = value
        return canonical

    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
            # Propagate to equality-link followers of this parameter. In
            # link_radius mode 'radius_effective' is one of them, so the
            # structure-factor link needs no special case here.
            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 = YES if info['vary'] else NO
            if name == 'radius_effective' and self._radius_effective_mode == 'link_radius':
                vary_str = f'{ARROW}radius'
            if name in self._links:
                vary_str = f'{ARROW}{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 = YES if info['vary'] else NO
            # Show linked indicator for radius_effective in link_radius mode
            if name == 'radius_effective' and self._radius_effective_mode == 'link_radius':
                vary_str = f'{ARROW}radius'
            if name in self._links:
                vary_str = f'{ARROW}{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()
        previous_mode = self._radius_effective_mode
        self.params = self._sf_manager.apply(
            kernel=kernel,
            sf_name=structure_factor_name,
            re_mode=radius_effective_mode,
            current_params=self.params,
        )
        # radius_effective_mode owns the 'radius_effective' -> 'radius' link:
        # drop the outgoing mode's link before the incoming mode re-establishes
        # it, so switching modes cannot leave a phantom behind. Dropping it here
        # rather than in _prune_stale_links() keeps it silent — it is an
        # expected consequence of the mode change, not a stale link.
        if 'link_radius' in (previous_mode, self._radius_effective_mode):
            self._links.pop('radius_effective', None)
        self._prune_stale_links()
        # _sf_manager.apply() downgrades the mode to 'unconstrained' when either
        # parameter is missing, so both are present whenever it still reads
        # 'link_radius'.
        if self._radius_effective_mode == 'link_radius':
            self.link_params('radius_effective', 'radius')

    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
        """
        was_linked = self._radius_effective_mode == 'link_radius'
        sf_name, restored_params = self._sf_manager.remove()
        self.params = restored_params
        # The mode-owned link retires with the structure factor that created
        # it; a link the user made themselves is left to _prune_stale_links(),
        # which reports it.
        if was_linked:
            self._links.pop('radius_effective', None)
        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()

    # =========================================================================
    # Persistence seam (see MD/79_PLAN.md §4.1)
    # =========================================================================

    def export_config(self) -> dict[str, Any]:
        """Return everything this manager holds, as plain nested data.

        The persistence seam. Replaying the public setters instead does not
        work for a bulk restore, for three separate reasons:

        - ``get_components()`` returns ``(prefix, moniker, part)`` triples while
          ``register_aliases()`` takes ``(moniker, model)`` pairs;
        - ``radius_effective_mode='link_radius'`` creates its link when the
          structure factor is applied, so a later ``set_param()`` on
          ``radius_effective`` raises before any value can be restored;
        - ``get_pd_param()`` returns ``pd`` plus a derived ``active`` flag,
          while ``set_pd_param()`` expects ``pd_width`` and has no ``active``.

        Parameter names are the user-facing ones (aliases on the ``set_models``
        path). ``description`` is not exported: it is documentation re-derived
        from the kernel, and pinning it would make files stale against a
        sasmodels upgrade.

        Numeric fields are normalized to float (``pd_n`` to int). sasmodels
        defaults arrive as ints for some parameters, and a reloaded value is
        always a float, so without this the same configuration would hash
        differently before and after a round trip and every restored fit result
        would look stale.
        """
        return {
            'model_name': self.model_name,
            'components': [list(entry) for entry in self._components],
            'shared': list(self._shared_to_canonicals.keys()),
            'structure_factor': {
                'name': self._structure_factor_name,
                'radius_effective_mode': self._radius_effective_mode,
                'form_factor_backup': {
                    name: _normalized_param(info)
                    for name, info in self._sf_manager.backed_up_params.items()
                },
            },
            'params': {name: _normalized_param(info) for name, info in self.params.items()},
            'polydispersity': {
                'enabled': self._pd_manager.is_enabled(),
                'params': {
                    name: _normalized_pd(info) for name, info in self._pd_manager.params.items()
                },
                'backup': self._export_pd_backup(),
            },
            'links': dict(self._links),
        }

    def _export_pd_backup(self) -> dict[str, Any] | None:
        """The pre-structure-factor polydispersity backup, as plain data."""
        backup = self._pd_manager.backup_state
        if backup is None:
            return None
        return {
            'polydisperse_param_names': list(backup['polydisperse_param_names']),
            'polydisperse_params': {
                name: _normalized_pd(info) for name, info in backup['polydisperse_params'].items()
            },
            'pd_enabled': bool(backup['pd_enabled']),
        }

    def import_config(self, config: dict[str, Any]) -> None:
        """Restore a state produced by :meth:`export_config`.

        Assumes the parameter skeleton already matches: the caller has loaded
        the model, registered any aliases, and applied any structure factor, so
        that ``self.params`` has the right keys. Values, bounds, vary flags,
        polydispersity, links and the manager backups are what this restores.

        Validates everything against the live skeleton first and commits only
        once every check passes, so a rejected configuration leaves the manager
        exactly as the model setup left it.

        Raises:
            ValueError: If the configuration does not describe this model, a
                parameter or link name is unknown, a bound or flag is invalid,
                or the link set contradicts ``radius_effective_mode``.
        """
        params_cfg = _require_mapping(config, 'params')
        expected = set(self.params)
        got = set(params_cfg)
        if got != expected:
            missing = ', '.join(sorted(expected - got)) or 'none'
            unknown = ', '.join(sorted(got - expected)) or 'none'
            raise ValueError(
                'Saved parameters do not match the model that was loaded '
                f"('{self.model_name}'). Missing: {missing}. Unknown: {unknown}."
            )

        new_params: dict[str, dict[str, Any]] = {}
        for name, saved in params_cfg.items():
            new_params[name] = dict(self.params[name]) | _validated_param(name, saved)

        pd_cfg = _require_mapping(config, 'polydispersity', default={})
        pd_params_cfg = _require_mapping(pd_cfg, 'params', default={})
        known_pd = set(self._pd_manager.get_parameters())
        unknown_pd = set(pd_params_cfg) - known_pd
        if unknown_pd:
            raise ValueError(
                f'Saved polydispersity names not supported by this model: '
                f'{", ".join(sorted(unknown_pd))}. '
                f'Available: {", ".join(sorted(known_pd)) or "none"}.'
            )
        new_pd = {name: dict(info) for name, info in self._pd_manager.params.items()}
        for name, saved in pd_params_cfg.items():
            new_pd[name] = dict(new_pd.get(name, {})) | _validated_pd(name, saved)

        new_links = _validated_links(config.get('links') or {}, new_params)
        mode = self._radius_effective_mode
        if mode == 'link_radius' and new_links.get('radius_effective') != 'radius':
            raise ValueError(
                "radius_effective_mode is 'link_radius' but the saved links do not "
                "contain 'radius_effective' -> 'radius'. The two would disagree "
                'about what the fit varies.'
            )
        if mode != 'link_radius' and new_links.get('radius_effective') == 'radius':
            raise ValueError(
                "The saved links contain 'radius_effective' -> 'radius' but "
                f"radius_effective_mode is '{mode}'. Use 'link_radius' for that link."
            )

        sf_cfg = _require_mapping(config, 'structure_factor', default={})
        backup_cfg = _require_mapping(sf_cfg, 'form_factor_backup', default={})
        new_backup = {name: _validated_param(name, saved) for name, saved in backup_cfg.items()}

        # Followers always carry their target's value, whatever the file says.
        for follower, target in new_links.items():
            new_params[follower]['value'] = new_params[target]['value']
            new_params[follower]['vary'] = False

        # Commit.
        self.params = new_params
        self._links = new_links
        self._pd_manager.params = new_pd
        self._pd_manager.set_enabled(bool(pd_cfg.get('enabled', False)))
        self._pd_manager.backup_state = _validated_pd_backup(pd_cfg.get('backup'))
        self._sf_manager.backed_up_params = new_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 = YES if pd_config.get('vary', False) else NO
            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 = YES if pd_config.get('vary', False) else NO
        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]]:
        validate_radius_effective_mode(re_mode)

        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',
                }

        # The link itself is an ordinary equality link, established by
        # ParameterManager after this rebuild; all that is decided here is
        # whether the requested mode is achievable at all.
        if re_mode == 'link_radius' and not (
            'radius' in new_params and 'radius_effective' in new_params
        ):
            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]]:
    validate_radius_effective_mode(re_mode)

    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',
            }

    # The link itself is an ordinary equality link, established by
    # ParameterManager after this rebuild; all that is decided here is
    # whether the requested mode is achievable at all.
    if re_mode == 'link_radius' and not (
        'radius' in new_params and 'radius_effective' in new_params
    ):
        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