Skip to content

API Reference

Module-level helpers

iquana.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/iquana/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())

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

IQuana

The main class for SANS data fitting.

iquana.fitter.IQuana

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:iquana.inversion — it operates directly on datasets (fitter.data or data_ops results) and needs no model setup.

Example

fitter = IQuana() 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/iquana/fitter.py
 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
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
class IQuana:
    """
    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:`iquana.inversion` — it operates directly on datasets
    (``fitter.data`` or ``data_ops`` results) and needs no model setup.

    Example:
        >>> fitter = IQuana()
        >>> 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 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()
        # Weighting is the same kind of choice: which sigma divides each
        # residual. The default 'dI' means "the loaded dataset's own column".
        self._weighting = WeightingSetting()

        # 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()}\n'
            f'  Weighting mode: {self._weighting.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:`iquana.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()}\n'
            f'  Weighting mode: {self._weighting.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:`iquana.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 set_weighting(self, mode: str = 'dI') -> None:
        """
        Choose which uncertainty divides each residual in the fit.

        Every engine minimises χ² = Σ((I − I_model) / σ)² over the fitted
        points. The four modes choose σ, and mirror SasView's Fit Page
        (*No Weighting* / *Use dI Data* / *Use |sqrt(I Data)|* /
        *Use |I Data|*), with the same formulas::

            fitter.set_weighting('dI')     # default: the dataset's own dI column
            fitter.set_weighting('sqrt')   # σ = √|I|  (counting statistics)
            fitter.set_weighting('abs')    # σ = |I|   (constant relative error)
            fitter.set_weighting('none')   # σ = 1     (unweighted least squares)

        Which one to use:

        ========================================================  ==========
        Your data                                                 Mode
        ========================================================  ==========
        has a real dI column from the reduction                   ``'dI'``
        counts without dI                                         ``'sqrt'``
        no dI, and every decade of I(q) should matter equally     ``'abs'``
        noise-free simulation, or a plain least-squares fit       ``'none'``
        ========================================================  ==========

        The setting is applied to a **copy** of the dataset made for
        evaluation; ``fitter.data`` is never modified, so plots keep drawing
        the file's dI as error bars and ``save_results()`` keeps exporting it.
        It reaches ``fit(engine='bumps')``, ``fit(engine='lmfit')``,
        ``fit_bayesian()`` and the χ² of ``plot_model()``/``calculate()``.

        Like the resolution mode, the choice is fitter state: it **persists**
        across ``load_data()`` / ``set_data()``, and the load summary reports
        it.

        Note:
            Reduced χ² is a goodness-of-fit statistic only under ``'dI'`` with
            correct uncertainties. Under the other modes it compares fits of
            the same data under the same mode, and the reported ``stderr``,
            covariance and credible intervals inherit that caveat: under
            ``'none'`` they scale with the intensity units and are not
            uncertainties. Do not choose the mode that minimises χ².

        Args:
            mode: One of ``'dI'``, ``'sqrt'``, ``'abs'``, ``'none'``. Matching
                is exact and case-sensitive. Calling with no argument resets
                to ``'dI'``.

        Raises:
            ValueError: If the mode is not one of the four. A rejected call
                leaves the fitter unchanged.
        """
        self._weighting = validate_weighting(mode)
        logger.info(f'{OK} Weighting: {self._weighting.describe()}')

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

        Works before any data is loaded.

        Returns:
            A fresh dict, e.g. ``{'mode': 'sqrt'}``. Mutating it does not
            affect the fitter.
        """
        return self._weighting.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: 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.
        """
        evaluation_data = apply_resolution(
            self.data if data is None else data, self._resolution, warn=warn
        )
        # apply_resolution returned a fresh deep copy nobody else holds, so the
        # weighting is written onto it rather than copying a second time.
        return apply_weighting(evaluation_data, self._weighting, copy=False)

    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()
        # Likewise the weighting: χ² and the stderr values are only comparable
        # between fits that divided by the same σ.
        self._fit_contract.weighting = self._weighting.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),
            weighting=self._weighting.as_dict(),
        )

        # 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:`~iquana.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, canonical_values: dict[str, float] | None = None
    ) -> 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.

        Args:
            canonical_values: Parameter values keyed by canonical sasmodels
                name, overriding this fitter's own. A simultaneous fit resolves
                its values through the constraint graph rather than from each
                child's parameter table, and must be able to say so; the default
                reads this fitter's values and is what the single-fit path uses.
        """
        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

        if canonical_values is 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', self._weighting.note(ascii_only=True)
                ),
                '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': self._weighting.note(ascii_only=True),
            '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(),
                weighting=self._weighting.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(),
            weighting=self._weighting.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()
        # Built once: the guard inspects exactly the σ the engine will divide by.
        evaluation_data = self._evaluation_data()
        self._check_fit_uncertainties(engine, evaluation_data)
        self._log_active_resolution()

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

    def _log_active_resolution(self) -> None:
        """Report the resolution and weighting a fit is about to use.

        Before, not after: the engine's own "Initial χ²" line is already a
        smeared, weighted number, so the reader needs to know what produced it
        first.
        """
        logger.info(
            f'Resolution: {self._resolution.describe()}\nWeighting: {self._weighting.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, evaluation_data: Any) -> None:
        """Validate the per-point σ a fit is about to divide by.

        Inspects the **evaluation copy** — the σ the engine will actually see
        under the active weighting mode — rather than ``self.data``. Zero (or
        absent) σ makes the bumps χ² infinite for every parameter set, so the
        bumps engine and the DREAM sampler cannot proceed; the scipy/lmfit
        engine falls back to unit weights for the affected points (with a
        warning from the engine itself).

        Args:
            engine: ``'bumps'``, ``'lmfit'`` or ``'dream'`` (``fit_bayesian``).
                ``'dream'`` raises like ``'bumps'``, but its message does not
                offer ``engine='lmfit'``, which is no exit from sampling.
            evaluation_data: The copy built by :meth:`_evaluation_data` that
                will be handed to the engine.

        Raises:
            ValueError: On zero σ under bumps or DREAM, and on SESANS data
                under any mode but ``'dI'``.
        """
        mode = self._weighting.mode
        if mode != 'dI' and getattr(evaluation_data, 'isSesans', False):
            raise ValueError(
                f"Weighting mode '{mode}' applies to I(q) data; SESANS data is "
                "always weighted by its own uncertainties. Use set_weighting('dI')."
            )

        index = get_fit_index(evaluation_data)
        dy = getattr(evaluation_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())
        if mode == 'dI':
            detail = (
                'Data has no intensity uncertainties (dI)'
                if n_zero == n_fit
                else f'Data has {n_zero} of {n_fit} fitted points with zero intensity '
                'uncertainty (dI)'
            )
            exits = (
                'Provide dI values, exclude the points (mask or set_q_range), '
                "choose another weighting (set_weighting('sqrt') or "
                "set_weighting('none'))"
            )
        else:
            note = self._weighting.note()
            detail = (
                f"Weighting mode '{mode}' ({SIGMA} = {note}) gives {n_zero} of {n_fit} "
                f'fitted points a zero {SIGMA}, because their intensity is exactly zero'
            )
            exits = "Exclude the points (mask or set_q_range), choose set_weighting('none')"

        if engine in ('bumps', 'dream'):
            who = 'The bumps engine' if engine == 'bumps' else 'The DREAM sampler'
            lmfit_exit = (
                ", or use engine='lmfit', which treats them as unweighted"
                if engine == 'bumps'
                else ''
            )
            raise ValueError(
                f'{detail}. {who} cannot weight such points ({CHI_SQUARED} becomes infinite). '
                f'{exits}{lmfit_exit}.'
            )
        warnings.warn(
            f'{detail}. Affected residuals will be unweighted ({SIGMA} treated as 1.0), '
            f'so these points may dominate {CHI_SQUARED} relative to points with small errors.',
            stacklevel=2,
        )

    def _fit_bumps(
        self, data: Any, method: str = DEFAULT_BUMPS_METHOD, **kwargs: Any
    ) -> dict[str, Any]:
        """Fit *data* (the evaluation copy) using the BUMPS engine."""
        engine_output = fit_bumps(
            data=data,
            kernel=self.kernel,
            fit_state=self._param_manager.snapshot_fit_state(),
            method=method,
            weighting_note=self._weighting.note(ascii_only=True),
            **kwargs,
        )
        return self._finalize_fit(engine_output)

    def _fit_lmfit(self, data: Any, method: str = 'leastsq', **kwargs: Any) -> dict[str, Any]:
        """Fit *data* (the evaluation copy) using the scipy.optimize engine."""
        engine_output = fit_scipy(
            data=data,
            kernel=self.kernel,
            fit_state=self._param_manager.snapshot_fit_state(),
            method=method,
            weighting_note=self._weighting.note(ascii_only=True),
            **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()
        # Built once and checked before any sampling starts: without the guard,
        # data with no dI failed deep inside bumps with a message naming
        # nothing the user had set.
        evaluation_data = self._evaluation_data()
        self._check_fit_uncertainties('dream', evaluation_data)
        self._log_active_resolution()
        if self._weighting.mode == 'none':
            warnings.warn(
                "Weighting mode 'none' divides every residual by 1, so the "
                'posterior widths and credible intervals scale with the intensity '
                'units and are not uncertainties. Use them to compare fits under '
                'the same mode only.',
                stacklevel=2,
            )

        engine_output = fit_bumps_dream(
            data=evaluation_data,
            kernel=self.kernel,
            fit_state=self._param_manager.snapshot_fit_state(),
            method=method,
            samples=samples,
            burn=burn,
            thin=thin,
            pop=pop,
            weighting_note=self._weighting.note(ascii_only=True),
            # The predictive plot shows the file's dI, not the weighting σ
            # the evaluation copy carries.
            plot_data=self.data,
            **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=self._weighting.note(ascii_only=True),
            parameters={
                self._param_manager.to_display_name(name): {'value': info['value']}
                for name, info in fit_state.params.items()
            },
            resolution=self._resolution.as_dict(),
            weighting=self._weighting.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,
            evaluation_data=evaluation_data,
            weighting_mode=self._weighting.mode,
        )

    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
        """
        contract = self._get_active_fit_contract()
        # The residual axis describes the σ the plotted fit divided by, which
        # is the fit's recorded weighting even if the mode changed since.
        weighting_mode = self._weighting.mode
        if contract is not None and contract.weighting:
            weighting_mode = contract.weighting.get('mode', weighting_mode)
        # Error bars stay on the file's dI; the evaluation copy only supplies
        # the σ for residuals a contract does not carry itself.
        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,
            evaluation_data=None if self.data is None else self._evaluation_data(warn=False),
            weighting_mode=weighting_mode,
        )

    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,
    ) -> 'IQuana':
        """
        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/iquana/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()}\n'
        f'  Weighting mode: {self._weighting.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:iquana.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/iquana/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:`iquana.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()}\n'
        f'  Weighting mode: {self._weighting.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/iquana/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/iquana/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/iquana/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:iquana.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/iquana/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:`iquana.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/iquana/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_weighting(mode='dI')

Choose which uncertainty divides each residual in the fit.

Every engine minimises χ² = Σ((I − I_model) / σ)² over the fitted points. The four modes choose σ, and mirror SasView's Fit Page (No Weighting / Use dI Data / Use |sqrt(I Data)| / Use |I Data|), with the same formulas::

fitter.set_weighting('dI')     # default: the dataset's own dI column
fitter.set_weighting('sqrt')   # σ = √|I|  (counting statistics)
fitter.set_weighting('abs')    # σ = |I|   (constant relative error)
fitter.set_weighting('none')   # σ = 1     (unweighted least squares)

Which one to use:

======================================================== ========== Your data Mode ======================================================== ========== has a real dI column from the reduction 'dI' counts without dI 'sqrt' no dI, and every decade of I(q) should matter equally 'abs' noise-free simulation, or a plain least-squares fit 'none' ======================================================== ==========

The setting is applied to a copy of the dataset made for evaluation; fitter.data is never modified, so plots keep drawing the file's dI as error bars and save_results() keeps exporting it. It reaches fit(engine='bumps'), fit(engine='lmfit'), fit_bayesian() and the χ² of plot_model()/calculate().

Like the resolution mode, the choice is fitter state: it persists across load_data() / set_data(), and the load summary reports it.

Note

Reduced χ² is a goodness-of-fit statistic only under 'dI' with correct uncertainties. Under the other modes it compares fits of the same data under the same mode, and the reported stderr, covariance and credible intervals inherit that caveat: under 'none' they scale with the intensity units and are not uncertainties. Do not choose the mode that minimises χ².

Parameters:

Name Type Description Default
mode str

One of 'dI', 'sqrt', 'abs', 'none'. Matching is exact and case-sensitive. Calling with no argument resets to 'dI'.

'dI'

Raises:

Type Description
ValueError

If the mode is not one of the four. A rejected call leaves the fitter unchanged.

Source code in src/iquana/fitter.py
def set_weighting(self, mode: str = 'dI') -> None:
    """
    Choose which uncertainty divides each residual in the fit.

    Every engine minimises χ² = Σ((I − I_model) / σ)² over the fitted
    points. The four modes choose σ, and mirror SasView's Fit Page
    (*No Weighting* / *Use dI Data* / *Use |sqrt(I Data)|* /
    *Use |I Data|*), with the same formulas::

        fitter.set_weighting('dI')     # default: the dataset's own dI column
        fitter.set_weighting('sqrt')   # σ = √|I|  (counting statistics)
        fitter.set_weighting('abs')    # σ = |I|   (constant relative error)
        fitter.set_weighting('none')   # σ = 1     (unweighted least squares)

    Which one to use:

    ========================================================  ==========
    Your data                                                 Mode
    ========================================================  ==========
    has a real dI column from the reduction                   ``'dI'``
    counts without dI                                         ``'sqrt'``
    no dI, and every decade of I(q) should matter equally     ``'abs'``
    noise-free simulation, or a plain least-squares fit       ``'none'``
    ========================================================  ==========

    The setting is applied to a **copy** of the dataset made for
    evaluation; ``fitter.data`` is never modified, so plots keep drawing
    the file's dI as error bars and ``save_results()`` keeps exporting it.
    It reaches ``fit(engine='bumps')``, ``fit(engine='lmfit')``,
    ``fit_bayesian()`` and the χ² of ``plot_model()``/``calculate()``.

    Like the resolution mode, the choice is fitter state: it **persists**
    across ``load_data()`` / ``set_data()``, and the load summary reports
    it.

    Note:
        Reduced χ² is a goodness-of-fit statistic only under ``'dI'`` with
        correct uncertainties. Under the other modes it compares fits of
        the same data under the same mode, and the reported ``stderr``,
        covariance and credible intervals inherit that caveat: under
        ``'none'`` they scale with the intensity units and are not
        uncertainties. Do not choose the mode that minimises χ².

    Args:
        mode: One of ``'dI'``, ``'sqrt'``, ``'abs'``, ``'none'``. Matching
            is exact and case-sensitive. Calling with no argument resets
            to ``'dI'``.

    Raises:
        ValueError: If the mode is not one of the four. A rejected call
            leaves the fitter unchanged.
    """
    self._weighting = validate_weighting(mode)
    logger.info(f'{OK} Weighting: {self._weighting.describe()}')

get_weighting()

Return the active weighting setting.

Works before any data is loaded.

Returns:

Type Description
dict[str, Any]

A fresh dict, e.g. {'mode': 'sqrt'}. Mutating it does not

dict[str, Any]

affect the fitter.

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

    Works before any data is loaded.

    Returns:
        A fresh dict, e.g. ``{'mode': 'sqrt'}``. Mutating it does not
        affect the fitter.
    """
    return self._weighting.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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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=self._weighting.note(ascii_only=True),
        parameters={
            self._param_manager.to_display_name(name): {'value': info['value']}
            for name, info in fit_state.params.items()
        },
        resolution=self._resolution.as_dict(),
        weighting=self._weighting.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,
        evaluation_data=evaluation_data,
        weighting_mode=self._weighting.mode,
    )

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/iquana/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/iquana/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()
    # Built once: the guard inspects exactly the σ the engine will divide by.
    evaluation_data = self._evaluation_data()
    self._check_fit_uncertainties(engine, evaluation_data)
    self._log_active_resolution()

    if engine == 'bumps':
        return self._fit_bumps(evaluation_data, method or DEFAULT_BUMPS_METHOD, **kwargs)
    if not LMFIT_AVAILABLE:
        raise ValueError("scipy is not installed. Use 'bumps' engine or install scipy.")
    return self._fit_lmfit(evaluation_data, 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/iquana/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()
    # Built once and checked before any sampling starts: without the guard,
    # data with no dI failed deep inside bumps with a message naming
    # nothing the user had set.
    evaluation_data = self._evaluation_data()
    self._check_fit_uncertainties('dream', evaluation_data)
    self._log_active_resolution()
    if self._weighting.mode == 'none':
        warnings.warn(
            "Weighting mode 'none' divides every residual by 1, so the "
            'posterior widths and credible intervals scale with the intensity '
            'units and are not uncertainties. Use them to compare fits under '
            'the same mode only.',
            stacklevel=2,
        )

    engine_output = fit_bumps_dream(
        data=evaluation_data,
        kernel=self.kernel,
        fit_state=self._param_manager.snapshot_fit_state(),
        method=method,
        samples=samples,
        burn=burn,
        thin=thin,
        pop=pop,
        weighting_note=self._weighting.note(ascii_only=True),
        # The predictive plot shows the file's dI, not the weighting σ
        # the evaluation copy carries.
        plot_data=self.data,
        **kwargs,
    )
    return self._finalize_fit(engine_output)

get_fit_report()

Return a :class:~iquana.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/iquana/fitter.py
def get_fit_report(self) -> FitReport:
    """
    Return a :class:`~iquana.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/iquana/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/iquana/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
    """
    contract = self._get_active_fit_contract()
    # The residual axis describes the σ the plotted fit divided by, which
    # is the fit's recorded weighting even if the mode changed since.
    weighting_mode = self._weighting.mode
    if contract is not None and contract.weighting:
        weighting_mode = contract.weighting.get('mode', weighting_mode)
    # Error bars stay on the file's dI; the evaluation copy only supplies
    # the σ for residuals a contract does not carry itself.
    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,
        evaluation_data=None if self.data is None else self._evaluation_data(warn=False),
        weighting_mode=weighting_mode,
    )

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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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
IQuana

A configured fitter. When the saved result still describes the

IQuana

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

IQuana

plot_results(), save_results() and get_fit_report()

IQuana

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/iquana/fitter.py
@classmethod
def load_analysis(
    cls,
    filename: str,
    data: Any = None,
    allow_custom_models: bool = False,
) -> 'IQuana':
    """
    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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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.

MultiFitter

Simultaneous, constrained fitting of several datasets through one bumps problem. See the Simultaneous Fitting guide for the workflow and for what sharing, linking and constraining each mean.

iquana.multifit.MultiFitter

Fit several datasets at once, with parameters shared or related between them.

See the module docstring for the workflow. The public surface divides into configuration (:meth:add, :meth:share, :meth:link_params, :meth:constrain, :meth:set_dataset_weight), inspection (:meth:describe, :meth:get_constraints, :meth:get_sharing, :meth:calculate, :meth:plot_model) and results (:meth:fit, :meth:get_fit_report, :meth:plot_results, :meth:save_results).

Only the bumps engine is supported. The parameter graph is kept free of bumps so the scipy backend and joint DREAM sampling can be added without changing this API.

Source code in src/iquana/multifit.py
 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
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
class MultiFitter:
    """Fit several datasets at once, with parameters shared or related between them.

    See the module docstring for the workflow. The public surface divides into
    configuration (:meth:`add`, :meth:`share`, :meth:`link_params`,
    :meth:`constrain`, :meth:`set_dataset_weight`), inspection
    (:meth:`describe`, :meth:`get_constraints`, :meth:`get_sharing`,
    :meth:`calculate`, :meth:`plot_model`) and results (:meth:`fit`,
    :meth:`get_fit_report`, :meth:`plot_results`, :meth:`save_results`).

    Only the bumps engine is supported. The parameter graph is kept free of
    bumps so the scipy backend and joint DREAM sampling can be added without
    changing this API.
    """

    def __init__(self) -> None:
        self._entries: dict[str, _Entry] = {}
        self._share_groups: list[ShareGroup] = []
        self._directed: dict[ParameterRef, ParameterRef] = {}
        self._constraints: dict[ParameterRef, ConstraintSpec] = {}
        self._result: MultiFitResult | None = None
        self._graph: CompiledGraph | None = None

    # =====================================================================
    # Dataset registry
    # =====================================================================

    def add(
        self,
        name: str,
        data: Any,
        *,
        model: str | None = None,
        dataset: int | str = 0,
        weight: float = 1.0,
        platform: str = 'cpu',
    ) -> DatasetHandle:
        """Add a named dataset to the analysis.

        Args:
            name: Identifier for this dataset. Must match ``[A-Za-z][A-Za-z0-9_]*``
                and not be a Python keyword, because it appears unquoted on the
                left of the dot in every reference to one of its parameters.
            data: A file path, or an in-memory ``Data1D``. The dataset is copied,
                so later edits to the object passed here do not reach the fit.
            model: Model expression, e.g. ``'sphere'`` or ``'sphere@hardsphere'``.
                May be set later through the handle's ``set_model``.
            dataset: Which entry to take from a file holding several.
            weight: Objective coefficient for this dataset. Leave at 1 unless you
                mean to change how much it counts; see :meth:`set_dataset_weight`.
            platform: Computation platform passed to sasmodels.

        Returns:
            A :class:`DatasetHandle` for configuring this dataset.

        Raises:
            ValueError: If the name is invalid or already used, or the weight is
                not positive and finite.
        """
        self._validate_name(name)
        weight = self._validate_weight(weight)

        fitter = IQuana()
        if isinstance(data, (str, os.PathLike)):
            fitter.load_data(os.fspath(data), dataset=dataset)
        else:
            # Copied at ingestion: a joint analysis holding a reference to a
            # caller's array would silently change meaning when that array did.
            fitter.set_data(copy.deepcopy(data))
        if model is not None:
            fitter.set_model(model, platform=platform)

        entry = _Entry(name, fitter, weight)
        entry.handle = DatasetHandle(self, name)
        self._entries[name] = entry
        self._invalidate()
        logger.info(
            f"{OK} Added dataset '{name}'"
            + (f" with model '{model}'" if model else '')
            + f' ({len(fitter.data.x)} points, weight {weight:g})'
        )
        return entry.handle

    def add_fitter(self, name: str, fitter: IQuana, *, weight: float = 1.0) -> DatasetHandle:
        """Add a dataset from an already configured :class:`~iquana.IQuana`.

        The data, model, parameters, polydispersity, links, structure factor, Q
        range and resolution are copied into a new owned entry. The source
        fitter's fit result and engine handles are deliberately not: they belong
        to a different fit of a different problem.

        A joint fit always weights each dataset by its own ``dI``. A fitter
        whose weighting is another mode (``set_weighting('sqrt')``, ``'abs'``
        or ``'none'``) is refused rather than silently refitted under a
        different objective; weighting modes for joint fits are tracked in
        issue #87.

        Args:
            name: Identifier for this dataset.
            fitter: The fitter to copy. It is left untouched.
            weight: Objective coefficient for this dataset.

        Raises:
            ValueError: If the name is invalid or taken, the weight is invalid,
                the source fitter has no data or no model, or its weighting
                mode is not ``'dI'``.
        """
        self._validate_name(name)
        weight = self._validate_weight(weight)
        if fitter.data is None:
            raise ValueError(f"Cannot add '{name}': the fitter has no data loaded.")
        if fitter.kernel is None:
            raise ValueError(f"Cannot add '{name}': the fitter has no model set.")
        mode = fitter.get_weighting()['mode']
        if mode != 'dI':
            raise ValueError(
                f"Cannot add '{name}': the fitter weights by {mode!r}, and a joint fit "
                'weights every dataset by its own dI (other weighting modes are not '
                'yet supported for MultiFitter, see issue #87). Call '
                "fitter.set_weighting('dI') first; if the data has no dI, fit it "
                'on its own with IQuana.'
            )

        owned = IQuana()
        owned.set_data(copy.deepcopy(fitter.data))
        q_range = fitter.get_q_range()
        if q_range is not None:
            owned.data.qmin, owned.data.qmax = q_range
        owned._resolution = ResolutionSetting(**fitter.get_resolution())
        # The kernel is shared with the source fitter, deliberately. A sasmodels
        # KernelModel is a compiled model definition, not evaluation state:
        # DirectModel and the bumps Experiment each build their own per-call
        # kernel from it, so two holders cannot interfere. Rebuilding it would
        # recompile for nothing, and _Entry.snapshot() already holds it by
        # reference for the same reason. Everything mutable is copied above.
        owned.kernel = fitter.kernel
        owned._param_manager = copy.deepcopy(fitter._param_manager)
        owned._data_source = copy.deepcopy(fitter._data_source)

        entry = _Entry(name, owned, weight)
        entry.handle = DatasetHandle(self, name)
        self._entries[name] = entry
        self._invalidate()
        logger.info(f"{OK} Added dataset '{name}' from an existing fitter (weight {weight:g})")
        return entry.handle

    def remove(self, name: str) -> None:
        """Remove a dataset.

        Raises:
            KeyError: If the dataset is unknown.
            ValueError: If sharing or a constraint still refers to it. The
                message names the references to remove first, rather than
                silently dropping relationships the user set up.
        """
        self._entry(name)
        references = sorted(
            ref.qualified for ref in self._declared_references() if ref.dataset == name
        )
        if references:
            raise ValueError(
                f"Cannot remove '{name}': it is still referenced by {', '.join(references)}. "
                'Remove those with unshare(), unlink_params() or unconstrain() first.'
            )
        del self._entries[name]
        self._invalidate()
        logger.info(f"{OK} Removed dataset '{name}'")

    def set_dataset_weight(self, name: str, weight: float) -> None:
        """Set a dataset's objective coefficient ``a_d``.

        The objective becomes ``Σ a_d·χ²_d``. The default of 1 means every valid
        observation counts according to its own uncertainty, which is what makes
        the total a χ². A coefficient other than 1 states a **fitting priority**:
        it changes which compromise the optimizer prefers, while the supplied
        ``dI`` stays the error model. Uncertainties then come from the
        known-error sandwich covariance rather than the curvature of the
        reweighted objective — the reported ``chisq`` and the reported
        ``objective`` are different numbers, and the report labels both.

        Raises:
            ValueError: If the weight is not positive and finite. Zero is
                rejected: to leave a dataset out, remove it.
        """
        entry = self._entry(name)
        previous = entry.weight
        entry.weight = self._validate_weight(weight)
        self._invalidate()
        logger.info(f"{OK} Dataset '{name}' weight: {previous:g} -> {entry.weight:g}")

    # -- access -------------------------------------------------------------

    def __getitem__(self, name: str) -> DatasetHandle:
        handle = self._entry(name).handle
        assert handle is not None
        return handle

    def __contains__(self, name: object) -> bool:
        return name in self._entries

    def __len__(self) -> int:
        return len(self._entries)

    def __iter__(self):
        return iter(self._entries)

    def __repr__(self) -> str:
        return f'<MultiFitter {len(self._entries)} dataset(s): {", ".join(self._entries)}>'

    @property
    def names(self) -> list[str]:
        """The dataset names, in the order they were added."""
        return list(self._entries)

    @property
    def result(self) -> MultiFitResult | None:
        """The last fit result, or None."""
        return self._result

    # =====================================================================
    # Relationships
    # =====================================================================

    def share(
        self,
        *parameters: str,
        datasets: Sequence[str] | None = None,
        source: str | None = None,
    ) -> None:
        """Make one or more parameters a single quantity across datasets.

        Sharing is symmetric: the members must already agree on their starting
        value and vary flag, or *source* must name the dataset whose settings
        win. Insertion order deliberately does not decide — which dataset's
        starting radius the fit uses is a scientific choice, not a consequence
        of the order two files were loaded in. The shared bounds are the
        intersection of the members', so a limit set on any member still holds.

        Args:
            *parameters: Unqualified names, e.g. ``'radius'``, ``'radius_pd'``.
            datasets: Which datasets take part. Defaults to all of them.
                Membership is fixed here: a dataset added later is not enrolled.
            source: Dataset whose value and vary flag the group adopts.

        Raises:
            KeyError: If a dataset or parameter is unknown.
            ConstraintError: If members disagree with no *source*, their bounds
                do not overlap, or the result contradicts another relationship.
        """
        if not parameters:
            raise ValueError('share() needs at least one parameter name.')
        chosen = list(self._entries) if datasets is None else list(datasets)
        for name in chosen:
            self._entry(name)
        if len(chosen) < 2:
            raise ValueError(
                f'share() needs at least two datasets, got {len(chosen)}. '
                'Add another dataset, or use set_param() for a single one.'
            )
        if source is not None and source not in chosen:
            raise ValueError(
                f"source='{source}' is not among the datasets being shared ({', '.join(chosen)})."
            )

        groups = []
        for parameter in parameters:
            members = tuple(self._reference(f'{name}.{parameter}') for name in chosen)
            picked = self._reference(f'{source}.{parameter}') if source else None
            groups.append(ShareGroup(members=members, source=picked, label=parameter))

        self._transaction(lambda: self._share_groups.extend(groups))
        logger.info(
            f'{OK} Sharing {", ".join(parameters)} across {", ".join(chosen)}'
            + (f' (source: {source})' if source else '')
        )

    def unshare(self, parameter: str, *, dataset: str) -> None:
        """Detach one dataset's parameter from a shared group.

        It keeps the value the group currently holds — that is the value the
        rest of the analysis was built around — along with its own bounds and
        vary flag. A group left with one member is dropped.

        Raises:
            KeyError: If the dataset or parameter is unknown.
            ValueError: If the parameter is not shared.
        """
        ref = self._reference(f'{dataset}.{parameter}')
        remaining: list[ShareGroup] = []
        found = False
        for group in self._share_groups:
            if ref not in group.members:
                remaining.append(group)
                continue
            found = True
            members = tuple(member for member in group.members if member != ref)
            if len(members) < 2:
                continue
            source = group.source if group.source in members else None
            remaining.append(ShareGroup(members=members, source=source, label=group.label))
        if not found:
            raise ValueError(f'{ref.qualified} is not part of a shared group.')

        members = self._class_members(ref)
        touched = {member.dataset for member in members}

        def apply() -> None:
            # Every member first, not just the one leaving: the group's value
            # may never have been written down anywhere, and the ones staying
            # behind must not move either.
            self._materialize(members)
            self._replace_shares(remaining)

        self._transaction(apply, entries=tuple(touched))
        logger.info(f'{OK} Detached {ref.qualified} from its shared group')

    def link_params(self, target: str, *, to: str) -> None:
        """Make one parameter follow another, in either dataset.

        Directed, unlike :meth:`share`: *target* adopts *to*'s value, vary flag
        **and bounds**, and its own settings are discarded. That is what the
        single-dataset :meth:`IQuana.link_params` has always meant. Use it
        to relate parameters with different names (``'warm.length'`` following
        ``'cold.length'``); use :meth:`share` when the parameters are the same
        measurement seen from several datasets, and
        ``constrain(target, 'other.parameter')`` when the target's own limits
        must continue to hold.

        Raises:
            KeyError: If either reference is unknown.
            ValueError: If the two references are the same parameter, or the
                follower is already defined by a constraint.
            ConstraintError: If the link contradicts another relationship.
        """
        follower = self._reference(target)
        leader = self._reference(to)
        if follower == leader:
            raise ValueError(f'Cannot link {follower.qualified} to itself.')
        spec = self._constraints.get(follower)
        if spec is not None:
            raise ValueError(
                f"{follower.qualified} is already defined by the constraint '{spec.text}'. "
                'Two definitions of one parameter would both have to hold; remove that '
                'one with unconstrain() first if the link is what you want.'
            )
        self._transaction(lambda: self._directed.__setitem__(follower, leader))
        logger.info(f'{OK} Linked {follower.qualified} -> {leader.qualified}')

    def unlink_params(self, target: str) -> None:
        """Remove a link created by :meth:`link_params`.

        Raises:
            ValueError: If the reference does not follow anything.
        """
        follower = self._reference(target)
        if follower not in self._directed:
            raise ValueError(f'{follower.qualified} does not follow another parameter.')

        def apply() -> None:
            # Same reason as unshare(): the follower's value lives in the graph
            # until the link is gone, so write it down before removing it.
            self._materialize([follower])
            self._directed.pop(follower)

        self._transaction(apply, entries=(follower.dataset,))
        logger.info(f'{OK} Unlinked {follower.qualified}')

    def constrain(self, target: str, expression: float | str) -> None:
        """Define a parameter's value, removing it from the fitted coordinates.

        Three forms are accepted:

        - a **number**, or a string holding one: ``constrain('d2o.sld_solvent', 6.34)``
          pins the parameter;
        - a **reference**: ``constrain('d2o.background', 'h2o.background')`` is
          equality, the same relationship :meth:`link_params` creates;
        - an **expression**: ``constrain('d2o.scale', '0.8 * h2o.scale')`` or
          ``constrain('warm.length', '2 * cold.radius + 10')``.

        The expression grammar is small on purpose — numbers, qualified
        references, ``+ - * /`` and integer powers — and is interpreted, never
        executed. Binding an expression replaces the target's parameter object
        inside bumps, which discards the target's own limits, so those limits are
        proved here instead: a constraint is accepted only if it cannot leave
        them over the ranges its inputs are allowed to explore. Where the
        relationship is linear in one parameter that proof is constructive and
        the input's range is narrowed to guarantee it; where the arithmetic
        cannot certify it, the constraint is refused and the message names the
        parameters to tighten. That is conservative — some feasible nonlinear
        relationships need tighter bounds than they strictly must — and it is
        preferred to a limit that quietly stops applying.

        **The target keeps its own limits under every form**, including a bare
        reference. That is the difference from :meth:`link_params`, where the
        follower deliberately adopts its target's configuration: the two
        spellings of one relationship would otherwise permit different values.

        Constraining the same parameter again replaces the definition. A
        parameter that already follows another through :meth:`link_params`
        cannot also be constrained — unlink it first — because two definitions
        of one value would both have to hold.

        Raises:
            KeyError: If a reference is unknown.
            ExpressionError: If the expression is outside the grammar.
            ValueError: If the target already follows another parameter.
            ConstraintError: If the value or range cannot be kept inside the
                target's bounds, or the definition conflicts with another.
        """
        ref = self._reference(target)
        if ref in self._directed:
            raise ValueError(
                f'{ref.qualified} already follows {self._directed[ref].qualified} through '
                'link_params(). Remove that link with unlink_params() before constraining '
                'it, so the parameter has one definition rather than two.'
            )
        spec = self._build_constraint(ref, expression)
        self._transaction(lambda: self._constraints.__setitem__(ref, spec))
        logger.info(f'{OK} Constrained {ref.qualified} = {spec.text}')

    def unconstrain(self, target: str) -> None:
        """Remove a constraint, restoring an independent parameter.

        The parameter keeps the value the constraint last resolved to — that is
        the state the rest of the analysis was built around — and its current
        bounds, including any set while it was constrained, since the constraint
        honoured those. It gets back the vary flag it had before being
        constrained, which is the one setting a constraint overrides.

        Raises:
            ValueError: If the parameter is not constrained.
        """
        ref = self._reference(target)
        spec = self._constraints.get(ref)
        if spec is None:
            raise ValueError(f'{ref.qualified} is not constrained.')

        resolved = self._resolved_values().get(ref)
        # Only the vary flag comes back. The value stays where the constraint put
        # it — the rest of the analysis was configured around that — and the
        # bounds stay as they are now: set_param() may have changed them while
        # the constraint held, and the constraint's bound proof respected that
        # change, so reverting it here would silently undo a user's edit.
        previous = {'vary': spec.previous.get('vary')}

        def apply() -> None:
            del self._constraints[ref]
            if resolved is not None:
                self._write_value(ref, resolved)
            self._write_configuration(ref, previous)

        self._transaction(apply, entries=(ref.dataset,))
        logger.info(f'{OK} Removed the constraint on {ref.qualified}')

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

        *reference* is normally qualified (``'h2o.radius'``). An unqualified name
        is accepted when exactly one shared group carries it, which is the common
        case after ``share('radius')``.

        A value or vary flag set on a member of a shared group is applied to
        every member, so the group keeps agreeing. A bound is applied to the
        named member only; the group's effective range is the intersection, and
        is recomputed here.

        A link follower's value and vary flag are its leader's, so they are set
        on the leader. Writing them through the follower is refused, as
        :meth:`IQuana.set_param` refuses it, rather than quietly moving the
        parameter the follower is attached to.

        Raises:
            KeyError: If the reference is unknown.
            ValueError: If the parameter is defined by a constraint or follows
                another through a link — change the relationship instead — or
                if bounds are given for a polydispersity width, which is always
                fitted on ``[0, 1]``.
        """
        ref = self._reference(reference)
        if ref.pd and (min is not None or max is not None):
            low, high = PD_WIDTH_RANGE
            raise ValueError(
                f'{ref.qualified} is a polydispersity width, which is always fitted on '
                f'[{low:g}, {high:g}]; its bounds cannot be set. Use '
                f"fit['{ref.dataset}'].set_pd_param() for the width and vary flag."
            )
        if value is not None or vary is not None:
            self._refuse_defined(ref, 'value')

        # Value and vary belong to the quantity, bounds to the individual
        # parameter, so the two travel different distances through the graph.
        siblings = self._class_members(ref) if (value is not None or vary is not None) else (ref,)
        touched = {member.dataset for member in siblings} | {ref.dataset}

        def apply() -> None:
            for member in siblings:
                self._write_configuration(member, {'value': value, 'vary': vary})
            self._write_configuration(ref, {'min': min, 'max': max})

        self._transaction(apply, entries=tuple(touched))

    # -- inspection ---------------------------------------------------------

    def get_constraints(self) -> list[dict[str, Any]]:
        """Every explicit constraint, as inspectable data."""
        return [
            {
                'target': ref.qualified,
                'kind': spec.kind,
                'text': spec.text,
                'depends_on': sorted(dep.qualified for dep in spec.dependencies),
            }
            for ref, spec in sorted(self._constraints.items(), key=lambda item: item[0].qualified)
        ] + [
            {
                'target': follower.qualified,
                'kind': 'equality',
                'text': leader.qualified,
                'depends_on': [leader.qualified],
            }
            for follower, leader in sorted(
                self._directed.items(), key=lambda item: item[0].qualified
            )
        ]

    def get_sharing(self) -> list[dict[str, Any]]:
        """Every quantity held by more than one parameter, after compilation."""
        graph = self._compiled()
        return [
            {
                'root': entry.label,
                'members': [ref.qualified for ref in entry.members],
                'status': entry.kind,
            }
            for entry in graph.classes
            if entry.shared
        ]

    def get_parameter_table(self) -> list[dict[str, Any]]:
        """Every parameter of every dataset, with its status in the graph.

        ``min``/``max`` are the configured bounds of the quantity (intersected
        across a shared group). ``fit_min``/``fit_max`` are the range the fit
        actually uses: a constraint whose target has limits narrows the range of
        the parameter that drives it, so ``h2o.scale`` can be held below its own
        ``max`` by ``constrain('d2o.scale', '2 * h2o.scale')``. That narrowed
        range is also what "on a bound" is judged against after a fit. For a
        fixed or constant quantity both equal its value.
        """
        graph = self._compiled()
        values = graph.class_values()
        rows: list[dict[str, Any]] = []
        for entry_class in graph.classes:
            fit_min, fit_max = graph.ranges.get(
                entry_class.index, (entry_class.minimum, entry_class.maximum)
            )
            for ref in entry_class.members:
                rows.append(
                    {
                        'parameter': ref.qualified,
                        'value': values[entry_class.index],
                        'status': self._status(entry_class, ref),
                        'root': entry_class.label,
                        'expression': entry_class.constraint_text,
                        'min': entry_class.minimum,
                        'max': entry_class.maximum,
                        'fit_min': fit_min,
                        'fit_max': fit_max,
                    }
                )
        return sorted(rows, key=lambda row: row['parameter'])

    def describe(self) -> None:
        """Print the datasets, relationships and free-parameter count.

        The summary to read before fitting: it states how many independent
        coordinates the graph leaves, which is the number that shared parameters
        are supposed to reduce and the easiest thing to get wrong.
        """
        graph = self._compiled()
        print(f'\n{"=" * 78}')
        print(f'MultiFitter: {len(self._entries)} dataset(s), {graph.n_free} free parameter(s)')
        print('=' * 78)

        for entry in self._entries.values():
            q_range = entry.fitter.get_q_range()
            selected = (
                int(get_fit_index(entry.fitter.data).sum()) if entry.fitter.data is not None else 0
            )
            print(
                f'  {entry.name:<14} {entry.fitter.model_name or "(no model)":<24} '
                f'{selected:>5} pts  weight {entry.weight:<6g} '
                f'Q [{q_range[0]:.4g}, {q_range[1]:.4g}]  '
                f'{entry.fitter._resolution.describe()}'
            )

        rows = [row for row in self.get_parameter_table() if row['status'] != 'fixed']
        if rows:
            print('\nParameters taking part in the fit:')
            width = max(len(row['parameter']) for row in rows)
            for row in rows:
                detail = f' = {row["expression"]}' if row['expression'] else ''
                root = '' if row['parameter'] == row['root'] else f'  [= {row["root"]}]'
                narrowed = ''
                if row['status'] in ('free', 'shared') and (
                    row['fit_min'] > row['min'] or row['fit_max'] < row['max']
                ):
                    narrowed = (
                        f'  fitted on [{row["fit_min"]:.6g}, {row["fit_max"]:.6g}] '
                        f'by a constraint (configured [{row["min"]:.6g}, {row["max"]:.6g}])'
                    )
                print(
                    f'  {row["parameter"]:<{width}}  {row["value"]:>12.6g}  '
                    f'{row["status"]}{detail}{root}{narrowed}'
                )

        constraints = self.get_constraints()
        if constraints:
            print('\nConstraints:')
            for item in constraints:
                print(f'  {item["target"]} = {item["text"]}  ({item["kind"]})')

        print(f'\nFree parameters: {", ".join(graph.labels) or "none"}')
        print(f'{"=" * 78}\n')

    # =====================================================================
    # Evaluation
    # =====================================================================

    def calculate(
        self, q: Mapping[str, np.ndarray] | None = None, dq: Mapping[str, float] | None = None
    ) -> dict[str, np.ndarray]:
        """Evaluate every model at the current, constraint-resolved parameters.

        Shared values and expressions hold here exactly as they do during a fit,
        so a preview is a preview of the constrained model rather than of
        whatever the individual parameter tables happen to say.

        Args:
            q: Optional per-dataset Q grids, ``{'h2o': array, ...}``. Datasets
                left out use their own data grid and resolution.
            dq: Optional per-dataset relative resolution widths for those grids.

        Returns:
            Dataset name -> intensities. On a data grid the array is full length,
            with NaN where a point is excluded from the fit.
        """
        self._require_ready()
        graph = self._compiled()
        resolved = graph.resolve()

        curves: dict[str, np.ndarray] = {}
        for entry in self._entries.values():
            grid = None if q is None else q.get(entry.name)
            width = None if dq is None else dq.get(entry.name)
            target = (
                entry.fitter._evaluation_data(warn=False)
                if grid is None
                else theory_data(grid, width)
            )
            curve, fit_index = self._evaluate_entry(entry, target, resolved)
            full = np.full(len(fit_index), np.nan)
            full[fit_index] = curve
            curves[entry.name] = full
        return curves

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

        One data-and-model panel per dataset, each with its own axes because the
        Q ranges need not match. Constraints are resolved first, so this shows
        the model the fit will start from. Unlike :meth:`plot_results`, this
        reads the datasets as they are now — that is the point of a preview.

        Args:
            show_components: For a '+' mixture model, overlay one dashed curve
                per component. A documented no-op elsewhere.
        """
        self._require_ready()
        graph = self._compiled()
        resolved = graph.resolve()

        panels = []
        for entry in self._entries.values():
            evaluation_data = entry.fitter._evaluation_data(warn=False)
            curve, fit_index = self._evaluate_entry(entry, evaluation_data, resolved)
            components = None
            if show_components:
                components = entry.fitter._compute_component_curves(
                    self._canonical_values(entry, resolved)
                )
            panels.append(
                multi_plotting.DatasetPanel.from_dataset(
                    name=entry.name,
                    model=entry.fitter.model_name or '',
                    data=entry.fitter.data,
                    fitted_curve=curve,
                    fit_index=fit_index,
                    residuals=_preview_residuals(evaluation_data, curve, fit_index),
                    weight=entry.weight,
                    component_curves=components,
                )
            )
        return multi_plotting.plot_multi(
            panels,
            title=f'Model preview: {len(panels)} datasets, {graph.n_free} free parameters',
            show_residuals=show_residuals,
            log_scale=log_scale,
            show=show,
            preview=True,
            show_components=show_components,
        )

    def plot_results(
        self,
        show_residuals: bool = True,
        log_scale: bool = True,
        show: bool | None = None,
        objective_residuals: bool = False,
        show_components: bool = False,
    ) -> Figure:
        """Plot the fitted model for every dataset.

        Drawn entirely from the result's own snapshot — the observations, the
        selection and the model curves it was built with — so the figure shows
        one coherent fit even if a dataset has since been reconfigured or
        removed. Use :meth:`plot_model` to see the datasets as they are now.

        Args:
            objective_residuals: Show ``sqrt(weight)·residual`` — what the
                optimizer minimised — instead of the residual in the dataset's
                own sigma units. Only differs when a dataset factor is set, and
                is labelled differently so the two cannot be confused.
            show_components: For a '+' mixture model, overlay one dashed curve
                per component. A documented no-op elsewhere.
        """
        result = self._require_result()
        self._warn_if_stale()
        panels = [
            multi_plotting.DatasetPanel(
                name=entry.name,
                model=entry.model,
                q=entry.observed_q,
                intensity=entry.observed_intensity,
                uncertainty=entry.observed_uncertainty,
                dq=entry.observed_dq,
                fitted_curve=entry.fitted_curve,
                fit_index=entry.fit_index,
                residuals=(entry.objective_residuals if objective_residuals else entry.residuals),
                weight=entry.weight,
                component_curves=entry.component_curves,
            )
            for entry in result.datasets.values()
        ]
        symbol = 'χ²'
        return multi_plotting.plot_multi(
            panels,
            title=(
                f'Simultaneous fit: {result.n_datasets} datasets, '
                f'{symbol}/dof = {result.reduced_chisq:.4f}'
            ),
            show_residuals=show_residuals,
            log_scale=log_scale,
            show=show,
            preview=False,
            residual_label=('Residuals (√w·σ)' if objective_residuals else 'Residuals (σ)'),
            show_components=show_components,
        )

    # =====================================================================
    # Fitting
    # =====================================================================

    def fit(
        self,
        engine: Literal['bumps'] = 'bumps',
        method: str | None = None,
        **kwargs: Any,
    ) -> MultiFitResult:
        """Fit every dataset together, through one bumps problem.

        Args:
            engine: Only ``'bumps'`` is supported. The scipy backend and joint
                DREAM sampling are planned and will keep this signature.
            method: bumps optimizer — ``'amoeba'`` (default), ``'lm'``,
                ``'newton'``, ``'de'``.
            **kwargs: Passed to the bumps fitter.

        Returns:
            A :class:`~iquana.multi_results.MultiFitResult`.

        Raises:
            ValueError: If the analysis is not ready to fit, an engine other
                than bumps is requested, or a dataset fails the data preflight.
            ConstraintError: If the parameter graph cannot be compiled.
        """
        if engine != 'bumps':
            raise ValueError(
                f"Unknown engine '{engine}'. Simultaneous fitting currently supports "
                "'bumps' only; the scipy path is not implemented, and reporting one "
                'as if it were would give results no test has checked.'
            )
        self._require_ready()
        graph = self._compiled()
        self._preflight(graph)

        specs = [self._entry_spec(entry) for entry in self._entries.values()]
        method = method or DEFAULT_BUMPS_METHOD
        outcome = run_multi_bumps(specs, graph, method=method, **kwargs)

        result = self._build_result(graph, outcome, method)
        # Commit last: a failure anywhere above leaves every dataset exactly as
        # it was, which is what makes a failed fit safe to retry. The context is
        # taken afterwards, because it digests the parameter values and would
        # otherwise describe the configuration the fit started from — making
        # every result look stale the moment it was produced.
        self._commit(graph, outcome.root_values)
        self._attach_component_curves(result)
        result.fit_context = self._context()
        self._result = result

        logger.info(f'\n{OK} Simultaneous fit completed!\n{MultiFitReport(result)}')
        self._warn_about_bounds(result)
        return result

    def get_fit_report(self) -> MultiFitReport:
        """A self-rendering report for the last fit.

        Prints as a table in a terminal, renders as HTML in a notebook, and
        converts to Markdown with ``to_markdown()``.

        Raises:
            ValueError: If no fit has been run.
        """
        return MultiFitReport(self._require_result())

    def save_results(self, directory: str) -> None:
        """Write the fit to a directory of CSV files.

        Produces ``parameters.csv`` (every parameter with its status, root and
        uncertainty), ``datasets.csv`` (per-dataset diagnostics),
        ``covariance.csv`` (over the free roots, when available), one
        ``<dataset>_curve.csv`` per dataset, and ``manifest.txt`` tying them to
        one fit. Curve files carry Q, I, dI, the fitted intensity, the residual
        in sigma units and the objective residual, for the fitted points only.

        Raises:
            ValueError: If no fit has been run.
        """
        result = self._require_result()
        self._warn_if_stale()

        # Everything is rendered before anything is written, so a failure
        # half-way cannot leave a directory holding some files from this fit and
        # some from the last one.
        files: dict[str, str] = {
            'parameters.csv': _csv_text(
                ['parameter', 'value', 'stderr', 'formatted', 'status', 'root', 'expression'],
                [
                    [
                        entry.qualified,
                        _csv_number(entry.value),
                        '' if entry.stderr is None else _csv_number(entry.stderr),
                        entry.formatted,
                        entry.status,
                        entry.root,
                        entry.expression,
                    ]
                    for entry in result.parameters.values()
                ],
            ),
            'datasets.csv': _csv_text(
                [
                    'dataset',
                    'model',
                    'n_points',
                    'chisq',
                    'objective_contribution',
                    'rms_residual',
                    'mean_squared_residual',
                    'weight',
                    'qmin',
                    'qmax',
                    'resolution',
                ],
                [
                    [
                        entry.name,
                        entry.model,
                        str(entry.n_points),
                        _csv_number(entry.chisq),
                        _csv_number(entry.objective_contribution),
                        _csv_number(entry.rms_residual),
                        _csv_number(entry.mean_squared_residual),
                        _csv_number(entry.weight),
                        '' if entry.q_range is None else _csv_number(entry.q_range[0]),
                        '' if entry.q_range is None else _csv_number(entry.q_range[1]),
                        entry.resolution,
                    ]
                    for entry in result.datasets.values()
                ],
            ),
        }

        if result.cov is not None:
            files['covariance.csv'] = _csv_text(
                ['parameter', *result.cov_labels],
                [
                    [label, *[_csv_number(value) for value in row]]
                    for label, row in zip(result.cov_labels, np.asarray(result.cov), strict=True)
                ],
            )

        for entry in result.datasets.values():
            files[f'{entry.name}_curve.csv'] = self._curve_text(entry, result)

        files[MANIFEST_NAME] = self._manifest_text(result, sorted(files))

        os.makedirs(directory, exist_ok=True)
        obsolete = _previously_exported(directory) - set(files)
        for name, text in files.items():
            atomic_write(os.path.join(directory, name), text)
        # Only files a previous export of ours listed are removed, and only once
        # the new ones are safely in place. A stale covariance.csv beside a fit
        # that has none would otherwise read as this fit's covariance.
        for name in sorted(obsolete):
            try:
                os.remove(os.path.join(directory, name))
            except OSError:
                logger.debug(f'Could not remove the superseded export file {name}.')

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

    # =====================================================================
    # Internals: references and state
    # =====================================================================

    def _entry(self, name: str) -> _Entry:
        try:
            return self._entries[name]
        except KeyError:
            available = ', '.join(self._entries) or 'none'
            raise KeyError(f"Unknown dataset '{name}'. Available: {available}") from None

    def _validate_name(self, name: str) -> None:
        if name in self._entries:
            raise ValueError(
                f"A dataset called '{name}' has already been added. Names identify "
                'parameters, so reusing one would make every reference to it ambiguous.'
            )
        if not isinstance(name, str) or not _NAME_PATTERN.fullmatch(name):
            raise ValueError(
                f'Invalid dataset name {name!r}. Use a letter followed by letters, '
                "digits or underscores, e.g. 'h2o' — the name appears unquoted in "
                "every reference such as 'h2o.radius'."
            )
        if keyword.iskeyword(name):
            raise ValueError(f"Dataset name '{name}' is a Python keyword.")

    def _validate_weight(self, weight: float) -> float:
        value = float(weight)
        if not math.isfinite(value) or value <= 0:
            raise ValueError(
                f'Dataset weight must be positive and finite, got {weight!r}. '
                'To leave a dataset out of the fit, remove it rather than '
                'weighting it to zero.'
            )
        return value

    def _reference(self, text: str) -> ParameterRef:
        """Turn ``'h2o.radius'`` into a resolved reference.

        An unqualified name is accepted when exactly one shared group carries
        it, which is what makes ``set_param('radius', value=45)`` work after
        ``share('radius')`` without naming a dataset arbitrarily.
        """
        if isinstance(text, str) and '.' not in text:
            return self._unqualified(text)
        dataset, local = parse_reference(text)
        entry = self._entry(dataset)
        return self._resolve_local(entry, local)

    def _unqualified(self, name: str) -> ParameterRef:
        matches = [group for group in self._share_groups if group.label == name]
        if len(matches) == 1:
            return matches[0].members[0]
        if not matches:
            raise KeyError(
                f"'{name}' is not a shared parameter, so it does not identify one "
                f"quantity. Qualify it with a dataset, for example 'h2o.{name}'."
            )
        raise KeyError(f"'{name}' names {len(matches)} shared groups. Qualify it with a dataset.")

    def _resolve_local(self, entry: _Entry, local: str) -> ParameterRef:
        """Resolve one dataset-local parameter name, including PD widths."""
        manager = entry.fitter._param_manager
        if not manager.params:
            raise KeyError(
                f"Dataset '{entry.name}' has no model, so it has no parameters yet. "
                'Call set_model() on its handle first.'
            )

        if local.endswith('_pd'):
            base = local[:-3]
            try:
                key = manager.resolve_name(base)
            except KeyError:
                key = None
            if key is not None and manager._resolve_canonical(key) in (
                manager.get_polydisperse_parameters()
            ):
                return ParameterRef(entry.name, key, pd=True)

        try:
            key = manager.resolve_name(local)
        except KeyError as error:
            raise KeyError(f"Dataset '{entry.name}': {error.args[0]}") from None
        if key not in manager.params:
            raise KeyError(
                f"Dataset '{entry.name}': parameter '{local}' is not independently "
                'settable under this model.'
            )
        return ParameterRef(entry.name, key, pd=False)

    def _declared_references(self) -> set[ParameterRef]:
        """Every reference named by a share, link or constraint."""
        references: set[ParameterRef] = set()
        for group in self._share_groups:
            references.update(group.members)
        for follower, leader in self._directed.items():
            references.update((follower, leader))
        for ref, spec in self._constraints.items():
            references.add(ref)
            references.update(spec.dependencies)
        return references

    def _build_constraint(self, ref: ParameterRef, expression: float | str) -> ConstraintSpec:
        """Classify a constraint and remember what to restore on unconstrain()."""
        previous = self._configuration(ref)

        if isinstance(expression, (int, float)) and not isinstance(expression, bool):
            value = float(expression)
            if not math.isfinite(value):
                raise ConstraintError(f'Cannot constrain {ref.qualified} to {expression}.')
            return ConstraintSpec(
                target=ref, kind='constant', text=f'{value:g}', value=value, previous=previous
            )

        # Collapsed to one line: the text is echoed into reports and CSV exports,
        # and a constraint may legally span lines inside parentheses. The same
        # reasoning as fitting.base.normalize_message.
        text = ' '.join(str(expression).split())
        try:
            value = float(text)
        except ValueError:
            pass
        else:
            if not math.isfinite(value):
                raise ConstraintError(f"Cannot constrain {ref.qualified} to '{text}'.")
            return ConstraintSpec(
                target=ref, kind='constant', text=text, value=value, previous=previous
            )

        parsed = parse_expression(
            text, lambda dataset, local: self._reference(f'{dataset}.{local}')
        )
        if ref in parsed.refs:
            raise ConstraintError(
                f"Constraint '{ref.qualified} = {text}' defines the parameter in terms of itself."
            )

        if not parsed.refs:
            # Arithmetic over literals alone is a number, and is folded into one
            # here so that every layer agrees it is one: a derived node with no
            # inputs would translate to a bare float, which the optimizer backend
            # cannot bind, and would be reported as 'derived' with a gradient of
            # nothing. Evaluating also enforces the domain, so '1 / 0' is
            # rejected here rather than surviving as a definition.
            value = parsed.evaluate({})
            if not math.isfinite(value):
                raise ConstraintError(
                    f"Constraint '{ref.qualified} = {text}' does not evaluate to a finite number."
                )
            return ConstraintSpec(
                target=ref, kind='constant', text=text, value=float(value), previous=previous
            )

        # A lone reference is equality, which the graph merges into one class
        # rather than carrying as an expression — same relationship, one fewer
        # moving part, and an uncertainty that is copied rather than propagated.
        # The target keeps its own bounds either way; see compile_graph.
        if isinstance(parsed.root, Reference):
            return ConstraintSpec(
                target=ref,
                kind='equality',
                text=text,
                source=parsed.root.ref,
                previous=previous,
            )
        return ConstraintSpec(
            target=ref, kind='expression', text=text, expression=parsed, previous=previous
        )

    def _configuration(self, ref: ParameterRef) -> dict[str, Any]:
        """The independent value/vary/bounds of one reference."""
        fitter = self._entry(ref.dataset).fitter
        if ref.pd:
            config = fitter.get_pd_param(ref.name)
            low, high = PD_WIDTH_RANGE
            return {
                'value': float(config['pd']),
                'vary': bool(config.get('vary', False)),
                'min': low,
                'max': high,
            }
        info = fitter.params[ref.name]
        return {
            'value': float(info['value']),
            'vary': bool(info['vary']),
            'min': float(info['min']),
            'max': float(info['max']),
        }

    def _write_configuration(self, ref: ParameterRef, changes: Mapping[str, Any]) -> None:
        """Apply value/vary/min/max to one reference, bypassing link guards.

        The graph, not the per-dataset manager, decides what follows what here,
        so a write to a local link follower is legitimate and the manager's own
        refusal to set one would be wrong.
        """
        fitter = self._entry(ref.dataset).fitter
        if ref.pd:
            fitter.set_pd_param(
                ref.name,
                pd_width=changes.get('value'),
                vary=changes.get('vary'),
            )
            return
        info = fitter.params[ref.name]
        for field in ('value', 'min', 'max', 'vary'):
            if changes.get(field) is not None:
                info[field] = changes[field]

    def _write_value(self, ref: ParameterRef, value: float) -> None:
        self._write_configuration(ref, {'value': float(value)})

    def _class_members(self, ref: ParameterRef) -> tuple[ParameterRef, ...]:
        """Every reference that is the same quantity as *ref*."""
        graph = self._compiled()
        index = graph.class_of.get(ref)
        return graph.classes[index].members if index is not None else (ref,)

    def _replace_shares(self, groups: list[ShareGroup]) -> None:
        self._share_groups[:] = groups

    # -- transactions -------------------------------------------------------

    def _invalidate(self) -> None:
        self._graph = None

    def _declarations(self) -> tuple[Any, ...]:
        return (
            list(self._share_groups),
            dict(self._directed),
            dict(self._constraints),
        )

    def _restore_declarations(self, state: tuple[Any, ...]) -> None:
        shares, directed, constraints = state
        self._share_groups[:] = shares
        self._directed.clear()
        self._directed.update(directed)
        self._constraints.clear()
        self._constraints.update(constraints)

    def _transaction(self, action, entries: Sequence[str] = ()) -> None:
        """Apply a configuration change, or leave nothing behind if it fails.

        Recompiling after every change is what lets this class promise that a
        configured analysis is always fittable: the error arrives at the call
        that caused it, with both sides of the contradiction in scope, instead
        of at ``fit()`` where the cause is several steps away.
        """
        saved_declarations = self._declarations()
        saved_entries = {name: self._entry(name).snapshot() for name in entries}
        try:
            action()
            self._graph = None
            # Keep what this compile produced: it already describes the state
            # the change left behind, and discarding it only means the next read
            # compiles the same graph a second time.
            self._compiled()
        except Exception:
            self._restore_declarations(saved_declarations)
            for name, state in saved_entries.items():
                self._entry(name).restore(state)
            self._graph = None
            raise

    def _mutate(self, name: str, action) -> None:
        """A handle-level change to one dataset, revalidated and rolled back."""
        self._transaction(action, entries=(name,))

    # -- graph --------------------------------------------------------------

    def _descriptors(self) -> dict[ParameterRef, ParameterDescriptor]:
        """Every parameter the graph can address.

        Polydispersity widths appear only when they matter: the entry has PD
        enabled and the width is either active already or named by a
        relationship. Listing every width of every model would bury the
        parameter table under quantities nobody is fitting.
        """
        declared = self._declared_references()
        descriptors: dict[ParameterRef, ParameterDescriptor] = {}

        for entry in self._entries.values():
            manager = entry.fitter._param_manager
            units = _kernel_units(entry.fitter.kernel)
            for name, info in manager.params.items():
                ref = ParameterRef(entry.name, name)
                descriptors[ref] = ParameterDescriptor(
                    ref=ref,
                    value=float(info['value']),
                    vary=bool(info['vary']),
                    minimum=float(info['min']),
                    maximum=float(info['max']),
                    units=units.get(manager._resolve_canonical(name), ''),
                )

            if not manager.is_pd_enabled():
                continue
            for canonical in manager.get_polydisperse_parameters():
                display = manager.to_display_name(canonical)
                try:
                    key = manager.resolve_name(display)
                except KeyError:
                    continue
                ref = ParameterRef(entry.name, key, pd=True)
                config = manager.get_pd_param(canonical)
                active = config['pd'] > 0 or config.get('vary', False)
                if not (active or ref in declared):
                    continue
                low, high = PD_WIDTH_RANGE
                descriptors[ref] = ParameterDescriptor(
                    ref=ref,
                    value=float(config['pd']),
                    vary=bool(config.get('vary', False)),
                    minimum=low,
                    maximum=high,
                    pd_type=str(config.get('pd_type', '')),
                    pd_enabled=True,
                )
        return descriptors

    def _local_links(self) -> dict[ParameterRef, ParameterRef]:
        """Per-dataset equality links, promoted into the global graph.

        Mandatory edges: a link a dataset already carried (``link_params``,
        ``shared=`` on ``set_models``, ``radius_effective_mode='link_radius'``)
        still holds inside a joint fit, and compiling it here rather than
        letting each engine reapply it is what keeps one binding map for
        everything.
        """
        links: dict[ParameterRef, ParameterRef] = {}
        for entry in self._entries.values():
            for follower, target in entry.fitter.get_links().items():
                links[ParameterRef(entry.name, follower)] = ParameterRef(entry.name, target)
        return links

    def _compiled(self) -> CompiledGraph:
        if self._graph is None:
            descriptors = self._descriptors()
            self._check_references(descriptors)
            self._graph = compile_graph(
                descriptors,
                local_links=self._local_links(),
                directed_links=self._directed,
                share_groups=self._share_groups,
                constraints=self._constraints,
            )
        return self._graph

    def _check_references(self, descriptors: Mapping[ParameterRef, ParameterDescriptor]) -> None:
        """Fail with names, not a KeyError, when a relationship lost its parameter."""
        missing = sorted(
            (ref for ref in self._declared_references() if ref not in descriptors),
            key=lambda ref: ref.qualified,
        )
        if not missing:
            return

        disabled = [
            ref
            for ref in missing
            if ref.pd and not self._entries[ref.dataset].fitter.is_polydispersity_enabled()
        ]
        if disabled:
            names = ', '.join(sorted({ref.dataset for ref in disabled}))
            raise ConstraintError(
                f'{disabled[0].qualified} is part of a relationship, but polydispersity '
                f'is switched off for {names}, so the width takes no part in the fit. '
                f"Call fit['{disabled[0].dataset}'].enable_polydispersity(True), or drop "
                'the relationship with unshare()/unconstrain().'
            )
        raise ConstraintError(
            f'These references no longer exist: '
            f'{", ".join(ref.qualified for ref in missing)}. A model changed underneath '
            'a relationship. Remove the relationship with unshare(), unlink_params() or '
            'unconstrain(), or restore the parameter.'
        )

    def _resolved_values(self) -> dict[ParameterRef, float]:
        """Graph-resolved values, or the raw ones if the graph does not compile.

        The single read path for numeric configuration. A shared, linked or
        constrained parameter's value lives in the graph, not in the dataset
        that happens to hold a copy of it, so every public getter that reports a
        number goes through here — otherwise two getters answer the same
        question differently depending on which layer they consulted.

        Returns an empty mapping rather than raising when the graph does not
        compile: read-only views must stay usable mid-edit.
        """
        try:
            return self._compiled().resolve()
        except ConstraintError:
            return {}

    def _resolved_value(self, ref: ParameterRef) -> float | None:
        """One graph-resolved value, or None when the graph cannot supply it."""
        return self._resolved_values().get(ref)

    def _cross_dataset_equalities(self) -> dict[ParameterRef, ParameterRef]:
        """Equality relationships declared on the parent, follower -> target.

        Both spellings that create one: :meth:`link_params` and a
        ``constrain(target, 'other.parameter')`` whose expression is a bare
        reference.
        """
        edges = dict(self._directed)
        for ref, spec in self._constraints.items():
            if spec.kind == 'equality' and spec.source is not None:
                edges[ref] = spec.source
        return edges

    def _materialize(self, refs: Sequence[ParameterRef]) -> None:
        """Write each reference's currently resolved value into its dataset.

        Called before a relationship is removed. Until that point a member's
        value lives in the graph and the dataset may still hold whatever it was
        configured with; detaching without materializing first would make the
        parameter jump back to a value the analysis stopped using when the
        relationship was created.
        """
        resolved = self._resolved_values()
        for ref in refs:
            value = resolved.get(ref)
            if value is not None:
                self._write_value(ref, value)

    def _leader_of(self, ref: ParameterRef) -> ParameterRef | None:
        """The parameter *ref* follows through a link, or None.

        Covers both the cross-dataset :meth:`link_params` and a link the dataset
        carries itself; an equality *constraint* is reported by the caller from
        the constraint instead, since its message names the constraint text.
        """
        if ref in self._directed:
            return self._directed[ref]
        if ref.pd:
            return None
        target = self._entry(ref.dataset).fitter.get_links().get(ref.name)
        return None if target is None else ParameterRef(ref.dataset, target)

    def _refuse_defined(self, ref: ParameterRef, what: str) -> None:
        """Refuse a value/vary write to a parameter another relationship defines."""
        spec = self._constraints.get(ref)
        if spec is not None:
            raise ValueError(
                f"{ref.qualified} is defined by the constraint '{spec.text}', so its {what} "
                'is not free to set. Use unconstrain() first, or change the constraint.'
            )
        leader = self._leader_of(ref)
        if leader is not None:
            raise ValueError(
                f'{ref.qualified} follows {leader.qualified} through a link, so its {what} '
                f'and vary flag are those of {leader.qualified}. Set them there, or unlink '
                'first.'
            )

    def _configure_pd(self, dataset: str, param_name: str, **changes: Any) -> None:
        """Apply one polydispersity setter call as a single transaction."""
        ref = self._reference(f'{dataset}.{param_name}_pd')
        width, vary = changes.pop('pd_width', None), changes.pop('vary', None)
        if width is not None or vary is not None:
            self._refuse_defined(ref, 'width')

        # The width is the quantity and travels to every member; the
        # distribution and quadrature settings describe how this dataset
        # evaluates it and stay here.
        settings = {key: value for key, value in changes.items() if value is not None}
        siblings = self._class_members(ref) if (width is not None or vary is not None) else ()
        touched = {dataset} | {member.dataset for member in siblings}

        def apply() -> None:
            if settings:
                self._entry(dataset).fitter.set_pd_param(param_name, **settings)
            for member in siblings:
                self._write_configuration(member, {'value': width, 'vary': vary})

        self._transaction(apply, entries=tuple(touched))

    @staticmethod
    def _status(entry_class: Any, ref: ParameterRef) -> str:
        if entry_class.kind == 'derived':
            return 'derived'
        if entry_class.kind == 'constant':
            return 'fixed'
        if entry_class.kind == 'fixed':
            return 'fixed'
        return 'shared' if entry_class.shared else 'free'

    # -- evaluation helpers -------------------------------------------------

    def _overrides(self, entry: _Entry, resolved: Mapping[ParameterRef, float]) -> dict[str, float]:
        """Graph values as canonical sasmodels keyword arguments for one entry."""
        manager = entry.fitter._param_manager
        overrides: dict[str, float] = {}
        for ref, value in resolved.items():
            if ref.dataset != entry.name:
                continue
            if ref.pd:
                overrides[f'{manager._resolve_canonical(ref.name)}_pd'] = value
            else:
                for canonical in _canonical_names(manager, ref.name):
                    overrides[canonical] = value
        return overrides

    def _canonical_values(
        self, entry: _Entry, resolved: Mapping[ParameterRef, float]
    ) -> dict[str, float]:
        """The entry's canonical parameter table with graph-resolved values laid over it.

        What :meth:`IQuana._compute_component_curves` needs to draw a preview:
        before a fit, a shared or constrained value lives in the graph rather than
        in the entry's own table, so reading the table alone would draw the
        components at values the total curve is not using.
        """
        return {
            **entry.fitter._param_manager.get_canonical_param_values(),
            **self._overrides(entry, resolved),
        }

    def _evaluate_entry(
        self, entry: _Entry, target: Any, resolved: Mapping[ParameterRef, float]
    ) -> tuple[np.ndarray, np.ndarray]:
        fit_state = entry.fitter._param_manager.snapshot_fit_state()
        pars = build_model_parameters(fit_state, self._overrides(entry, resolved))
        return evaluate_theory(target, entry.fitter.kernel, pars)

    def _entry_spec(self, entry: _Entry) -> MultiEntrySpec:
        manager = entry.fitter._param_manager
        graph = self._compiled()
        forced = tuple(
            manager._resolve_canonical(ref.name)
            for ref in graph.class_of
            if ref.dataset == entry.name and ref.pd
        )
        return MultiEntrySpec(
            name=entry.name,
            data=entry.fitter._evaluation_data(),
            kernel=entry.fitter.kernel,
            fit_state=manager.snapshot_fit_state(),
            weight=entry.weight,
            forced_pd=forced,
            canonical={name: tuple(_canonical_names(manager, name)) for name in manager.params},
            canonical_pd={name: manager._resolve_canonical(name) for name in manager.params},
        )

    # -- preflight ----------------------------------------------------------

    def _require_ready(self) -> None:
        if not self._entries:
            raise ValueError('No datasets added. Use add() first.')
        for entry in self._entries.values():
            if entry.fitter.data is None:
                raise ValueError(f"Dataset '{entry.name}' has no data.")
            if entry.fitter.kernel is None:
                raise ValueError(
                    f"Dataset '{entry.name}' has no model. "
                    f"Call fit['{entry.name}'].set_model(...) first."
                )

    def _preflight(self, graph: CompiledGraph) -> None:
        """Check the data and the graph before an optimizer is built.

        Every failure here is one that would otherwise surface as an infinite
        chi-squared, a silent reweighting, or a fit of fewer points than the
        user believes, so each is reported with the dataset that caused it.
        """
        fingerprints: dict[str, list[str]] = {}
        for entry in self._entries.values():
            data = entry.fitter.data
            index = get_fit_index(data)
            selected = int(index.sum())
            if selected == 0:
                raise ValueError(
                    f"Dataset '{entry.name}' has no points inside its Q range and mask."
                )
            dy = getattr(data, 'dy', None)
            if not has_real_data(dy):
                raise ValueError(
                    f"Dataset '{entry.name}' has no intensity uncertainties (dI). The "
                    'bumps engine cannot weight such points. Supply dI or remove the '
                    'dataset.'
                )
            values = np.asarray(dy, dtype=float)[index]
            bad = int(np.sum(~np.isfinite(values) | (values <= 0)))
            if bad:
                raise ValueError(
                    f"Dataset '{entry.name}' has {bad} of {selected} fitted points with a "
                    'non-positive or non-finite dI. Exclude them with set_q_range() or a '
                    'mask, or supply valid uncertainties.'
                )
            fingerprints.setdefault(fingerprint_arrays(data), []).append(entry.name)

        for names in fingerprints.values():
            if len(names) > 1:
                warnings.warn(
                    f'Datasets {", ".join(names)} hold identical data. Fitting the same '
                    'measurement twice does not make it two independent measurements: '
                    'the joint chi-squared counts it twice and the uncertainties will be '
                    'optimistic. This is intentional when comparing two models on one '
                    'dataset — but that comparison is better done as separate fits.',
                    stacklevel=3,
                )

        if graph.n_free == 0:
            warnings.warn(
                'No parameters are free to vary, so the fit evaluates the model once at '
                'the configured values. Set vary=True on something, or remove a '
                'constraint.',
                stacklevel=3,
            )
        self._check_degeneracies(graph)

    def _check_degeneracies(self, graph: CompiledGraph) -> None:
        """Warn about coordinates the data cannot separate.

        Extended to the graph's roots rather than each dataset's parameter list:
        sharing can hide a degeneracy (two datasets whose scales are shared with
        one another *and* with an SLD) that neither dataset shows on its own.
        """
        free_labels = set(graph.labels)
        for entry in self._entries.values():
            manager = entry.fitter._param_manager
            if not manager.get_components():
                continue
            scale = ParameterRef(entry.name, 'scale')
            if scale not in graph.class_of:
                continue
            if graph.classes[graph.class_of[scale]].label not in free_labels:
                continue
            component_scales = [
                name
                for name in manager.params
                if name.endswith('_scale')
                and name != 'scale'
                and graph.classes[graph.class_of[ParameterRef(entry.name, name)]].label
                in free_labels
            ]
            if component_scales:
                warnings.warn(
                    f"In dataset '{entry.name}' both the global 'scale' and the component "
                    f'scale(s) {", ".join(component_scales)} vary. Only their product is '
                    'fitted, so the split between them is degenerate. Fix one of them.',
                    stacklevel=4,
                )

    # -- results ------------------------------------------------------------

    def _build_result(self, graph: CompiledGraph, outcome: Any, method: str) -> MultiFitResult:
        values = graph.class_values(outcome.root_values)
        gradients = graph.gradients(outcome.root_values)
        cov = outcome.cov

        def error_of(index: int) -> tuple[float | None, str]:
            entry_class = graph.classes[index]
            if entry_class.kind in ('fixed', 'constant'):
                return 0.0, 'fixed'
            if cov is None:
                return None, outcome.cov_note or 'no covariance was available'
            gradient = gradients[index]
            variance = float(gradient @ np.asarray(cov) @ gradient)
            if not math.isfinite(variance) or variance < 0:
                return None, 'the propagated variance was not a positive number'
            source = (
                'root covariance'
                if entry_class.kind == 'free'
                else (
                    'propagated through the constraint graph'
                    if entry_class.kind == 'derived'
                    else 'shared with its root'
                )
            )
            return math.sqrt(variance), source

        parameters: dict[str, MultiParameter] = {}
        by_dataset: dict[str, dict[str, MultiParameter]] = {name: {} for name in self._entries}
        for entry_class in graph.classes:
            stderr, source = error_of(entry_class.index)
            value = values[entry_class.index]
            for ref in entry_class.members:
                parameter = MultiParameter(
                    qualified=ref.qualified,
                    dataset=ref.dataset,
                    name=f'{ref.name}_pd' if ref.pd else ref.name,
                    value=value,
                    stderr=stderr,
                    formatted=format_estimate(value, stderr),
                    status=self._status(entry_class, ref),
                    root=entry_class.label,
                    members=tuple(member.qualified for member in entry_class.members),
                    expression=entry_class.constraint_text,
                    uncertainty_source=source,
                )
                parameters[ref.qualified] = parameter
                by_dataset[ref.dataset][parameter.name] = parameter

        datasets: dict[str, DatasetResult] = {}
        for dataset_outcome in outcome.datasets:
            entry = self._entries[dataset_outcome.name]
            root = math.sqrt(dataset_outcome.weight)
            data = entry.fitter.data
            datasets[entry.name] = DatasetResult(
                name=entry.name,
                model=entry.fitter.model_name or '',
                n_points=dataset_outcome.n_points,
                chisq=dataset_outcome.chisq,
                objective_contribution=dataset_outcome.objective_contribution,
                weight=dataset_outcome.weight,
                q_range=entry.fitter.get_q_range(),
                resolution=entry.fitter._resolution.describe(),
                parameters=by_dataset[entry.name],
                # Copied, not referenced: this result must keep describing one
                # coherent fit even after the dataset is reconfigured, replaced
                # or removed. See DatasetResult.
                observed_q=np.array(data.x, dtype=float, copy=True),
                observed_intensity=np.array(data.y, dtype=float, copy=True),
                observed_uncertainty=(
                    None if data.dy is None else np.array(data.dy, dtype=float, copy=True)
                ),
                observed_dq=(
                    None
                    if getattr(data, 'dx', None) is None
                    else np.array(data.dx, dtype=float, copy=True)
                ),
                fitted_curve=dataset_outcome.fitted_curve,
                fit_index=dataset_outcome.fit_index,
                residuals=dataset_outcome.residuals,
                objective_residuals=root * dataset_outcome.residuals,
            )

        dof = outcome.n_points - graph.n_free
        on_bounds: list[tuple[str, str]] = []
        for index in graph.free_roots:
            entry_class = graph.classes[index]
            low, high = graph.ranges[index]
            value = values[index]
            if at_bound(value, low):
                on_bounds.append((entry_class.label, 'min'))
            elif at_bound(value, high):
                on_bounds.append((entry_class.label, 'max'))

        return MultiFitResult(
            engine='bumps',
            method=method,
            # bumps hard-codes success on every fitter, so there is no verdict to
            # report and inventing one would be worse than saying nothing.
            converged=None,
            message=outcome.message,
            n_datasets=len(datasets),
            n_points=outcome.n_points,
            n_free=graph.n_free,
            dof=dof,
            chisq=outcome.chisq,
            reduced_chisq=reduced_chisq(outcome.chisq, dof),
            objective=outcome.objective,
            reduced_objective=reduced_chisq(outcome.objective, dof),
            weighting={name: entry.weight for name, entry in self._entries.items()},
            constraints=self.get_constraints(),
            sharing=[
                {
                    'root': entry_class.label,
                    'members': [ref.qualified for ref in entry_class.members],
                    'status': entry_class.kind,
                }
                for entry_class in graph.classes
                if entry_class.shared
            ],
            parameters=parameters,
            cov=outcome.cov,
            cov_labels=list(outcome.cov_labels),
            cov_source=outcome.cov_source,
            cov_note=outcome.cov_note,
            on_bounds=on_bounds,
            datasets=datasets,
            fit_context=None,
        )

    def _commit(self, graph: CompiledGraph, root_values: np.ndarray) -> None:
        """Write the fitted values back into every dataset, all at once.

        The compiled graph is dropped afterwards: it carries the configured
        starting values, and leaving it cached would make every read-only view
        report the state the fit began from rather than where it ended.
        """
        for ref, value in graph.resolve(root_values).items():
            self._write_value(ref, value)
        self._invalidate()

    def _attach_component_curves(self, result: MultiFitResult) -> None:
        """Add per-component curves to a '+' mixture dataset's result.

        Run after the fitted values are committed, so the existing single-fit
        evaluator sees the parameters the joint fit landed on. A mismatched
        length is dropped rather than plotted: it would mean the component was
        evaluated on a different selection than the total, and a component curve
        that does not stack onto its total is worse than none.
        """
        for name, entry in result.datasets.items():
            fitter = self._entries[name].fitter
            curves = fitter._compute_component_curves()
            if not curves:
                continue
            expected = len(entry.fitted_curve)
            usable = {
                label: curve
                for label, curve in curves.items()
                if len(np.asarray(curve)) == expected
            }
            if len(usable) != len(curves):
                warnings.warn(
                    f"Dataset '{name}': some component curves did not match the "
                    f'{expected} fitted points and were dropped.',
                    RuntimeWarning,
                    stacklevel=4,
                )
            entry.component_curves = usable or None

    def _context(self) -> dict[str, Any]:
        """A digest of everything a result depends on, for stale detection."""
        entries = {
            entry.name: {
                'model': entry.fitter.model_name,
                'weight': entry.weight,
                'q_range': list(entry.fitter.get_q_range() or ()),
                'resolution': entry.fitter.get_resolution(),
                'data': fingerprint_arrays(entry.fitter.data),
                'config': config_digest(entry.fitter._param_manager.export_config()),
            }
            for entry in self._entries.values()
        }
        graph = {
            'sharing': [[ref.qualified for ref in group.members] for group in self._share_groups],
            'links': {
                follower.qualified: leader.qualified for follower, leader in self._directed.items()
            },
            'constraints': {ref.qualified: spec.text for ref, spec in self._constraints.items()},
        }
        payload = {'entries': entries, 'graph': graph}
        return {'digest': config_digest(payload), 'entries': entries, 'graph': graph}

    def _require_result(self) -> MultiFitResult:
        if self._result is None:
            raise ValueError('No fit result available. Run fit() first.')
        return self._result

    def _warn_if_stale(self) -> None:
        result = self._result
        if result is None or result.fit_context is None:
            return
        if result.fit_context.get('digest') != self._context()['digest']:
            warnings.warn(
                'The configuration has changed since this fit ran, so the result no '
                'longer describes the current analysis. It is still available on '
                '.result; refit to bring the two back together.',
                stacklevel=3,
            )

    @staticmethod
    def _warn_about_bounds(result: MultiFitResult) -> None:
        if not result.on_bounds:
            return
        hits = ', '.join(f'{name} ({side})' for name, side in result.on_bounds)
        warnings.warn(
            f'These parameters stopped on a bound: {hits}. The optimizer wanted to go '
            'further, so the value is a limit rather than a measurement, and its '
            'uncertainty understates the truth.',
            stacklevel=3,
        )

    # -- export helpers -----------------------------------------------------

    def _curve_text(self, entry: DatasetResult, result: MultiFitResult) -> str:
        """One dataset's measured and fitted curve, from the result's snapshot.

        Reads the observations the fit was run against rather than whatever the
        dataset holds now, so every exported row is internally consistent:
        ``Residual`` always equals ``(I_fit - I_exp) / dI_exp``.
        """
        index = entry.selection()
        q = entry.observed_q[index]
        intensity = entry.observed_intensity[index]
        uncertainty = (
            np.full(len(q), np.nan)
            if entry.observed_uncertainty is None
            else entry.observed_uncertainty[index]
        )

        header = [
            'SANS simultaneous fit',
            f'Dataset: {entry.name}',
            f'Model: {entry.model}',
            f'Points: {entry.n_points} of {len(index)}',
            f'Chi-squared (this dataset): {entry.chisq:.6f}',
            f'Objective contribution: {entry.objective_contribution:.6f}',
            f'Weight: {entry.weight:g}',
            f'Resolution: {entry.resolution}',
            'Residual convention: (model - I) / dI',
            f'Joint fit: {result.n_datasets} datasets, {result.n_free} free parameters, '
            f'dof {result.dof}',
        ]
        rows = [
            [_csv_number(value) for value in row]
            for row in zip(
                q,
                intensity,
                uncertainty,
                entry.fitted_curve,
                entry.residuals,
                entry.objective_residuals,
                strict=True,
            )
        ]
        return _csv_text(
            ['Q', 'I_exp', 'dI_exp', 'I_fit', 'Residual', 'Objective_residual'],
            rows,
            header=header,
        )

    def _manifest_text(self, result: MultiFitResult, filenames: Sequence[str]) -> str:
        """The manifest, which also records what this export owns.

        The file list is read back by the next export so it can retire artifacts
        that no longer belong — a covariance file beside a fit that has no
        covariance would otherwise be read as this fit's.
        """
        lines = [
            'IQuana simultaneous fit',
            f'Engine: {result.engine}/{result.method}',
            f'Datasets: {result.n_datasets}',
            f'Points: {result.n_points}   Free parameters: {result.n_free}   dof: {result.dof}',
            f'Chi-squared: {result.chisq:.6f}   Reduced: {result.reduced_chisq:.6f}',
        ]
        if result.weighted:
            lines.append(
                f'Weighted objective: {result.objective:.6f}   '
                f'Per dof: {result.reduced_objective:.6f}'
            )
            lines.append('Dataset factors are fitting priorities; dI remains the error model.')
        lines.append(f'Covariance: {result.cov_source or "unavailable"}')
        if result.cov_note:
            lines.append(f'  {result.cov_note}')
        lines.append(f'Context digest: {(result.fit_context or {}).get("digest", "n/a")}')
        lines.append('')
        lines.append(MANIFEST_FILE_MARKER)
        lines.extend(f'  {name}' for name in filenames)
        lines.append(f'  {MANIFEST_NAME}')
        return '\n'.join(lines) + '\n'

add(name, data, *, model=None, dataset=0, weight=1.0, platform='cpu')

Add a named dataset to the analysis.

Parameters:

Name Type Description Default
name str

Identifier for this dataset. Must match [A-Za-z][A-Za-z0-9_]* and not be a Python keyword, because it appears unquoted on the left of the dot in every reference to one of its parameters.

required
data Any

A file path, or an in-memory Data1D. The dataset is copied, so later edits to the object passed here do not reach the fit.

required
model str | None

Model expression, e.g. 'sphere' or 'sphere@hardsphere'. May be set later through the handle's set_model.

None
dataset int | str

Which entry to take from a file holding several.

0
weight float

Objective coefficient for this dataset. Leave at 1 unless you mean to change how much it counts; see :meth:set_dataset_weight.

1.0
platform str

Computation platform passed to sasmodels.

'cpu'

Returns:

Name Type Description
A DatasetHandle

class:DatasetHandle for configuring this dataset.

Raises:

Type Description
ValueError

If the name is invalid or already used, or the weight is not positive and finite.

Source code in src/iquana/multifit.py
def add(
    self,
    name: str,
    data: Any,
    *,
    model: str | None = None,
    dataset: int | str = 0,
    weight: float = 1.0,
    platform: str = 'cpu',
) -> DatasetHandle:
    """Add a named dataset to the analysis.

    Args:
        name: Identifier for this dataset. Must match ``[A-Za-z][A-Za-z0-9_]*``
            and not be a Python keyword, because it appears unquoted on the
            left of the dot in every reference to one of its parameters.
        data: A file path, or an in-memory ``Data1D``. The dataset is copied,
            so later edits to the object passed here do not reach the fit.
        model: Model expression, e.g. ``'sphere'`` or ``'sphere@hardsphere'``.
            May be set later through the handle's ``set_model``.
        dataset: Which entry to take from a file holding several.
        weight: Objective coefficient for this dataset. Leave at 1 unless you
            mean to change how much it counts; see :meth:`set_dataset_weight`.
        platform: Computation platform passed to sasmodels.

    Returns:
        A :class:`DatasetHandle` for configuring this dataset.

    Raises:
        ValueError: If the name is invalid or already used, or the weight is
            not positive and finite.
    """
    self._validate_name(name)
    weight = self._validate_weight(weight)

    fitter = IQuana()
    if isinstance(data, (str, os.PathLike)):
        fitter.load_data(os.fspath(data), dataset=dataset)
    else:
        # Copied at ingestion: a joint analysis holding a reference to a
        # caller's array would silently change meaning when that array did.
        fitter.set_data(copy.deepcopy(data))
    if model is not None:
        fitter.set_model(model, platform=platform)

    entry = _Entry(name, fitter, weight)
    entry.handle = DatasetHandle(self, name)
    self._entries[name] = entry
    self._invalidate()
    logger.info(
        f"{OK} Added dataset '{name}'"
        + (f" with model '{model}'" if model else '')
        + f' ({len(fitter.data.x)} points, weight {weight:g})'
    )
    return entry.handle

add_fitter(name, fitter, *, weight=1.0)

Add a dataset from an already configured :class:~iquana.IQuana.

The data, model, parameters, polydispersity, links, structure factor, Q range and resolution are copied into a new owned entry. The source fitter's fit result and engine handles are deliberately not: they belong to a different fit of a different problem.

A joint fit always weights each dataset by its own dI. A fitter whose weighting is another mode (set_weighting('sqrt'), 'abs' or 'none') is refused rather than silently refitted under a different objective; weighting modes for joint fits are tracked in issue #87.

Parameters:

Name Type Description Default
name str

Identifier for this dataset.

required
fitter IQuana

The fitter to copy. It is left untouched.

required
weight float

Objective coefficient for this dataset.

1.0

Raises:

Type Description
ValueError

If the name is invalid or taken, the weight is invalid, the source fitter has no data or no model, or its weighting mode is not 'dI'.

Source code in src/iquana/multifit.py
def add_fitter(self, name: str, fitter: IQuana, *, weight: float = 1.0) -> DatasetHandle:
    """Add a dataset from an already configured :class:`~iquana.IQuana`.

    The data, model, parameters, polydispersity, links, structure factor, Q
    range and resolution are copied into a new owned entry. The source
    fitter's fit result and engine handles are deliberately not: they belong
    to a different fit of a different problem.

    A joint fit always weights each dataset by its own ``dI``. A fitter
    whose weighting is another mode (``set_weighting('sqrt')``, ``'abs'``
    or ``'none'``) is refused rather than silently refitted under a
    different objective; weighting modes for joint fits are tracked in
    issue #87.

    Args:
        name: Identifier for this dataset.
        fitter: The fitter to copy. It is left untouched.
        weight: Objective coefficient for this dataset.

    Raises:
        ValueError: If the name is invalid or taken, the weight is invalid,
            the source fitter has no data or no model, or its weighting
            mode is not ``'dI'``.
    """
    self._validate_name(name)
    weight = self._validate_weight(weight)
    if fitter.data is None:
        raise ValueError(f"Cannot add '{name}': the fitter has no data loaded.")
    if fitter.kernel is None:
        raise ValueError(f"Cannot add '{name}': the fitter has no model set.")
    mode = fitter.get_weighting()['mode']
    if mode != 'dI':
        raise ValueError(
            f"Cannot add '{name}': the fitter weights by {mode!r}, and a joint fit "
            'weights every dataset by its own dI (other weighting modes are not '
            'yet supported for MultiFitter, see issue #87). Call '
            "fitter.set_weighting('dI') first; if the data has no dI, fit it "
            'on its own with IQuana.'
        )

    owned = IQuana()
    owned.set_data(copy.deepcopy(fitter.data))
    q_range = fitter.get_q_range()
    if q_range is not None:
        owned.data.qmin, owned.data.qmax = q_range
    owned._resolution = ResolutionSetting(**fitter.get_resolution())
    # The kernel is shared with the source fitter, deliberately. A sasmodels
    # KernelModel is a compiled model definition, not evaluation state:
    # DirectModel and the bumps Experiment each build their own per-call
    # kernel from it, so two holders cannot interfere. Rebuilding it would
    # recompile for nothing, and _Entry.snapshot() already holds it by
    # reference for the same reason. Everything mutable is copied above.
    owned.kernel = fitter.kernel
    owned._param_manager = copy.deepcopy(fitter._param_manager)
    owned._data_source = copy.deepcopy(fitter._data_source)

    entry = _Entry(name, owned, weight)
    entry.handle = DatasetHandle(self, name)
    self._entries[name] = entry
    self._invalidate()
    logger.info(f"{OK} Added dataset '{name}' from an existing fitter (weight {weight:g})")
    return entry.handle

remove(name)

Remove a dataset.

Raises:

Type Description
KeyError

If the dataset is unknown.

ValueError

If sharing or a constraint still refers to it. The message names the references to remove first, rather than silently dropping relationships the user set up.

Source code in src/iquana/multifit.py
def remove(self, name: str) -> None:
    """Remove a dataset.

    Raises:
        KeyError: If the dataset is unknown.
        ValueError: If sharing or a constraint still refers to it. The
            message names the references to remove first, rather than
            silently dropping relationships the user set up.
    """
    self._entry(name)
    references = sorted(
        ref.qualified for ref in self._declared_references() if ref.dataset == name
    )
    if references:
        raise ValueError(
            f"Cannot remove '{name}': it is still referenced by {', '.join(references)}. "
            'Remove those with unshare(), unlink_params() or unconstrain() first.'
        )
    del self._entries[name]
    self._invalidate()
    logger.info(f"{OK} Removed dataset '{name}'")

share(*parameters, datasets=None, source=None)

Make one or more parameters a single quantity across datasets.

Sharing is symmetric: the members must already agree on their starting value and vary flag, or source must name the dataset whose settings win. Insertion order deliberately does not decide — which dataset's starting radius the fit uses is a scientific choice, not a consequence of the order two files were loaded in. The shared bounds are the intersection of the members', so a limit set on any member still holds.

Parameters:

Name Type Description Default
*parameters str

Unqualified names, e.g. 'radius', 'radius_pd'.

()
datasets Sequence[str] | None

Which datasets take part. Defaults to all of them. Membership is fixed here: a dataset added later is not enrolled.

None
source str | None

Dataset whose value and vary flag the group adopts.

None

Raises:

Type Description
KeyError

If a dataset or parameter is unknown.

ConstraintError

If members disagree with no source, their bounds do not overlap, or the result contradicts another relationship.

Source code in src/iquana/multifit.py
def share(
    self,
    *parameters: str,
    datasets: Sequence[str] | None = None,
    source: str | None = None,
) -> None:
    """Make one or more parameters a single quantity across datasets.

    Sharing is symmetric: the members must already agree on their starting
    value and vary flag, or *source* must name the dataset whose settings
    win. Insertion order deliberately does not decide — which dataset's
    starting radius the fit uses is a scientific choice, not a consequence
    of the order two files were loaded in. The shared bounds are the
    intersection of the members', so a limit set on any member still holds.

    Args:
        *parameters: Unqualified names, e.g. ``'radius'``, ``'radius_pd'``.
        datasets: Which datasets take part. Defaults to all of them.
            Membership is fixed here: a dataset added later is not enrolled.
        source: Dataset whose value and vary flag the group adopts.

    Raises:
        KeyError: If a dataset or parameter is unknown.
        ConstraintError: If members disagree with no *source*, their bounds
            do not overlap, or the result contradicts another relationship.
    """
    if not parameters:
        raise ValueError('share() needs at least one parameter name.')
    chosen = list(self._entries) if datasets is None else list(datasets)
    for name in chosen:
        self._entry(name)
    if len(chosen) < 2:
        raise ValueError(
            f'share() needs at least two datasets, got {len(chosen)}. '
            'Add another dataset, or use set_param() for a single one.'
        )
    if source is not None and source not in chosen:
        raise ValueError(
            f"source='{source}' is not among the datasets being shared ({', '.join(chosen)})."
        )

    groups = []
    for parameter in parameters:
        members = tuple(self._reference(f'{name}.{parameter}') for name in chosen)
        picked = self._reference(f'{source}.{parameter}') if source else None
        groups.append(ShareGroup(members=members, source=picked, label=parameter))

    self._transaction(lambda: self._share_groups.extend(groups))
    logger.info(
        f'{OK} Sharing {", ".join(parameters)} across {", ".join(chosen)}'
        + (f' (source: {source})' if source else '')
    )

unshare(parameter, *, dataset)

Detach one dataset's parameter from a shared group.

It keeps the value the group currently holds — that is the value the rest of the analysis was built around — along with its own bounds and vary flag. A group left with one member is dropped.

Raises:

Type Description
KeyError

If the dataset or parameter is unknown.

ValueError

If the parameter is not shared.

Source code in src/iquana/multifit.py
def unshare(self, parameter: str, *, dataset: str) -> None:
    """Detach one dataset's parameter from a shared group.

    It keeps the value the group currently holds — that is the value the
    rest of the analysis was built around — along with its own bounds and
    vary flag. A group left with one member is dropped.

    Raises:
        KeyError: If the dataset or parameter is unknown.
        ValueError: If the parameter is not shared.
    """
    ref = self._reference(f'{dataset}.{parameter}')
    remaining: list[ShareGroup] = []
    found = False
    for group in self._share_groups:
        if ref not in group.members:
            remaining.append(group)
            continue
        found = True
        members = tuple(member for member in group.members if member != ref)
        if len(members) < 2:
            continue
        source = group.source if group.source in members else None
        remaining.append(ShareGroup(members=members, source=source, label=group.label))
    if not found:
        raise ValueError(f'{ref.qualified} is not part of a shared group.')

    members = self._class_members(ref)
    touched = {member.dataset for member in members}

    def apply() -> None:
        # Every member first, not just the one leaving: the group's value
        # may never have been written down anywhere, and the ones staying
        # behind must not move either.
        self._materialize(members)
        self._replace_shares(remaining)

    self._transaction(apply, entries=tuple(touched))
    logger.info(f'{OK} Detached {ref.qualified} from its shared group')

Make one parameter follow another, in either dataset.

Directed, unlike :meth:share: target adopts to's value, vary flag and bounds, and its own settings are discarded. That is what the single-dataset :meth:IQuana.link_params has always meant. Use it to relate parameters with different names ('warm.length' following 'cold.length'); use :meth:share when the parameters are the same measurement seen from several datasets, and constrain(target, 'other.parameter') when the target's own limits must continue to hold.

Raises:

Type Description
KeyError

If either reference is unknown.

ValueError

If the two references are the same parameter, or the follower is already defined by a constraint.

ConstraintError

If the link contradicts another relationship.

Source code in src/iquana/multifit.py
def link_params(self, target: str, *, to: str) -> None:
    """Make one parameter follow another, in either dataset.

    Directed, unlike :meth:`share`: *target* adopts *to*'s value, vary flag
    **and bounds**, and its own settings are discarded. That is what the
    single-dataset :meth:`IQuana.link_params` has always meant. Use it
    to relate parameters with different names (``'warm.length'`` following
    ``'cold.length'``); use :meth:`share` when the parameters are the same
    measurement seen from several datasets, and
    ``constrain(target, 'other.parameter')`` when the target's own limits
    must continue to hold.

    Raises:
        KeyError: If either reference is unknown.
        ValueError: If the two references are the same parameter, or the
            follower is already defined by a constraint.
        ConstraintError: If the link contradicts another relationship.
    """
    follower = self._reference(target)
    leader = self._reference(to)
    if follower == leader:
        raise ValueError(f'Cannot link {follower.qualified} to itself.')
    spec = self._constraints.get(follower)
    if spec is not None:
        raise ValueError(
            f"{follower.qualified} is already defined by the constraint '{spec.text}'. "
            'Two definitions of one parameter would both have to hold; remove that '
            'one with unconstrain() first if the link is what you want.'
        )
    self._transaction(lambda: self._directed.__setitem__(follower, leader))
    logger.info(f'{OK} Linked {follower.qualified} -> {leader.qualified}')

Remove a link created by :meth:link_params.

Raises:

Type Description
ValueError

If the reference does not follow anything.

Source code in src/iquana/multifit.py
def unlink_params(self, target: str) -> None:
    """Remove a link created by :meth:`link_params`.

    Raises:
        ValueError: If the reference does not follow anything.
    """
    follower = self._reference(target)
    if follower not in self._directed:
        raise ValueError(f'{follower.qualified} does not follow another parameter.')

    def apply() -> None:
        # Same reason as unshare(): the follower's value lives in the graph
        # until the link is gone, so write it down before removing it.
        self._materialize([follower])
        self._directed.pop(follower)

    self._transaction(apply, entries=(follower.dataset,))
    logger.info(f'{OK} Unlinked {follower.qualified}')

constrain(target, expression)

Define a parameter's value, removing it from the fitted coordinates.

Three forms are accepted:

  • a number, or a string holding one: constrain('d2o.sld_solvent', 6.34) pins the parameter;
  • a reference: constrain('d2o.background', 'h2o.background') is equality, the same relationship :meth:link_params creates;
  • an expression: constrain('d2o.scale', '0.8 * h2o.scale') or constrain('warm.length', '2 * cold.radius + 10').

The expression grammar is small on purpose — numbers, qualified references, + - * / and integer powers — and is interpreted, never executed. Binding an expression replaces the target's parameter object inside bumps, which discards the target's own limits, so those limits are proved here instead: a constraint is accepted only if it cannot leave them over the ranges its inputs are allowed to explore. Where the relationship is linear in one parameter that proof is constructive and the input's range is narrowed to guarantee it; where the arithmetic cannot certify it, the constraint is refused and the message names the parameters to tighten. That is conservative — some feasible nonlinear relationships need tighter bounds than they strictly must — and it is preferred to a limit that quietly stops applying.

The target keeps its own limits under every form, including a bare reference. That is the difference from :meth:link_params, where the follower deliberately adopts its target's configuration: the two spellings of one relationship would otherwise permit different values.

Constraining the same parameter again replaces the definition. A parameter that already follows another through :meth:link_params cannot also be constrained — unlink it first — because two definitions of one value would both have to hold.

Raises:

Type Description
KeyError

If a reference is unknown.

ExpressionError

If the expression is outside the grammar.

ValueError

If the target already follows another parameter.

ConstraintError

If the value or range cannot be kept inside the target's bounds, or the definition conflicts with another.

Source code in src/iquana/multifit.py
def constrain(self, target: str, expression: float | str) -> None:
    """Define a parameter's value, removing it from the fitted coordinates.

    Three forms are accepted:

    - a **number**, or a string holding one: ``constrain('d2o.sld_solvent', 6.34)``
      pins the parameter;
    - a **reference**: ``constrain('d2o.background', 'h2o.background')`` is
      equality, the same relationship :meth:`link_params` creates;
    - an **expression**: ``constrain('d2o.scale', '0.8 * h2o.scale')`` or
      ``constrain('warm.length', '2 * cold.radius + 10')``.

    The expression grammar is small on purpose — numbers, qualified
    references, ``+ - * /`` and integer powers — and is interpreted, never
    executed. Binding an expression replaces the target's parameter object
    inside bumps, which discards the target's own limits, so those limits are
    proved here instead: a constraint is accepted only if it cannot leave
    them over the ranges its inputs are allowed to explore. Where the
    relationship is linear in one parameter that proof is constructive and
    the input's range is narrowed to guarantee it; where the arithmetic
    cannot certify it, the constraint is refused and the message names the
    parameters to tighten. That is conservative — some feasible nonlinear
    relationships need tighter bounds than they strictly must — and it is
    preferred to a limit that quietly stops applying.

    **The target keeps its own limits under every form**, including a bare
    reference. That is the difference from :meth:`link_params`, where the
    follower deliberately adopts its target's configuration: the two
    spellings of one relationship would otherwise permit different values.

    Constraining the same parameter again replaces the definition. A
    parameter that already follows another through :meth:`link_params`
    cannot also be constrained — unlink it first — because two definitions
    of one value would both have to hold.

    Raises:
        KeyError: If a reference is unknown.
        ExpressionError: If the expression is outside the grammar.
        ValueError: If the target already follows another parameter.
        ConstraintError: If the value or range cannot be kept inside the
            target's bounds, or the definition conflicts with another.
    """
    ref = self._reference(target)
    if ref in self._directed:
        raise ValueError(
            f'{ref.qualified} already follows {self._directed[ref].qualified} through '
            'link_params(). Remove that link with unlink_params() before constraining '
            'it, so the parameter has one definition rather than two.'
        )
    spec = self._build_constraint(ref, expression)
    self._transaction(lambda: self._constraints.__setitem__(ref, spec))
    logger.info(f'{OK} Constrained {ref.qualified} = {spec.text}')

unconstrain(target)

Remove a constraint, restoring an independent parameter.

The parameter keeps the value the constraint last resolved to — that is the state the rest of the analysis was built around — and its current bounds, including any set while it was constrained, since the constraint honoured those. It gets back the vary flag it had before being constrained, which is the one setting a constraint overrides.

Raises:

Type Description
ValueError

If the parameter is not constrained.

Source code in src/iquana/multifit.py
def unconstrain(self, target: str) -> None:
    """Remove a constraint, restoring an independent parameter.

    The parameter keeps the value the constraint last resolved to — that is
    the state the rest of the analysis was built around — and its current
    bounds, including any set while it was constrained, since the constraint
    honoured those. It gets back the vary flag it had before being
    constrained, which is the one setting a constraint overrides.

    Raises:
        ValueError: If the parameter is not constrained.
    """
    ref = self._reference(target)
    spec = self._constraints.get(ref)
    if spec is None:
        raise ValueError(f'{ref.qualified} is not constrained.')

    resolved = self._resolved_values().get(ref)
    # Only the vary flag comes back. The value stays where the constraint put
    # it — the rest of the analysis was configured around that — and the
    # bounds stay as they are now: set_param() may have changed them while
    # the constraint held, and the constraint's bound proof respected that
    # change, so reverting it here would silently undo a user's edit.
    previous = {'vary': spec.previous.get('vary')}

    def apply() -> None:
        del self._constraints[ref]
        if resolved is not None:
            self._write_value(ref, resolved)
        self._write_configuration(ref, previous)

    self._transaction(apply, entries=(ref.dataset,))
    logger.info(f'{OK} Removed the constraint on {ref.qualified}')

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

Configure a parameter through the graph.

reference is normally qualified ('h2o.radius'). An unqualified name is accepted when exactly one shared group carries it, which is the common case after share('radius').

A value or vary flag set on a member of a shared group is applied to every member, so the group keeps agreeing. A bound is applied to the named member only; the group's effective range is the intersection, and is recomputed here.

A link follower's value and vary flag are its leader's, so they are set on the leader. Writing them through the follower is refused, as :meth:IQuana.set_param refuses it, rather than quietly moving the parameter the follower is attached to.

Raises:

Type Description
KeyError

If the reference is unknown.

ValueError

If the parameter is defined by a constraint or follows another through a link — change the relationship instead — or if bounds are given for a polydispersity width, which is always fitted on [0, 1].

Source code in src/iquana/multifit.py
def set_param(
    self,
    reference: str,
    value: float | None = None,
    min: float | None = None,
    max: float | None = None,
    vary: bool | None = None,
) -> None:
    """Configure a parameter through the graph.

    *reference* is normally qualified (``'h2o.radius'``). An unqualified name
    is accepted when exactly one shared group carries it, which is the common
    case after ``share('radius')``.

    A value or vary flag set on a member of a shared group is applied to
    every member, so the group keeps agreeing. A bound is applied to the
    named member only; the group's effective range is the intersection, and
    is recomputed here.

    A link follower's value and vary flag are its leader's, so they are set
    on the leader. Writing them through the follower is refused, as
    :meth:`IQuana.set_param` refuses it, rather than quietly moving the
    parameter the follower is attached to.

    Raises:
        KeyError: If the reference is unknown.
        ValueError: If the parameter is defined by a constraint or follows
            another through a link — change the relationship instead — or
            if bounds are given for a polydispersity width, which is always
            fitted on ``[0, 1]``.
    """
    ref = self._reference(reference)
    if ref.pd and (min is not None or max is not None):
        low, high = PD_WIDTH_RANGE
        raise ValueError(
            f'{ref.qualified} is a polydispersity width, which is always fitted on '
            f'[{low:g}, {high:g}]; its bounds cannot be set. Use '
            f"fit['{ref.dataset}'].set_pd_param() for the width and vary flag."
        )
    if value is not None or vary is not None:
        self._refuse_defined(ref, 'value')

    # Value and vary belong to the quantity, bounds to the individual
    # parameter, so the two travel different distances through the graph.
    siblings = self._class_members(ref) if (value is not None or vary is not None) else (ref,)
    touched = {member.dataset for member in siblings} | {ref.dataset}

    def apply() -> None:
        for member in siblings:
            self._write_configuration(member, {'value': value, 'vary': vary})
        self._write_configuration(ref, {'min': min, 'max': max})

    self._transaction(apply, entries=tuple(touched))

set_dataset_weight(name, weight)

Set a dataset's objective coefficient a_d.

The objective becomes Σ a_d·χ²_d. The default of 1 means every valid observation counts according to its own uncertainty, which is what makes the total a χ². A coefficient other than 1 states a fitting priority: it changes which compromise the optimizer prefers, while the supplied dI stays the error model. Uncertainties then come from the known-error sandwich covariance rather than the curvature of the reweighted objective — the reported chisq and the reported objective are different numbers, and the report labels both.

Raises:

Type Description
ValueError

If the weight is not positive and finite. Zero is rejected: to leave a dataset out, remove it.

Source code in src/iquana/multifit.py
def set_dataset_weight(self, name: str, weight: float) -> None:
    """Set a dataset's objective coefficient ``a_d``.

    The objective becomes ``Σ a_d·χ²_d``. The default of 1 means every valid
    observation counts according to its own uncertainty, which is what makes
    the total a χ². A coefficient other than 1 states a **fitting priority**:
    it changes which compromise the optimizer prefers, while the supplied
    ``dI`` stays the error model. Uncertainties then come from the
    known-error sandwich covariance rather than the curvature of the
    reweighted objective — the reported ``chisq`` and the reported
    ``objective`` are different numbers, and the report labels both.

    Raises:
        ValueError: If the weight is not positive and finite. Zero is
            rejected: to leave a dataset out, remove it.
    """
    entry = self._entry(name)
    previous = entry.weight
    entry.weight = self._validate_weight(weight)
    self._invalidate()
    logger.info(f"{OK} Dataset '{name}' weight: {previous:g} -> {entry.weight:g}")

get_constraints()

Every explicit constraint, as inspectable data.

Source code in src/iquana/multifit.py
def get_constraints(self) -> list[dict[str, Any]]:
    """Every explicit constraint, as inspectable data."""
    return [
        {
            'target': ref.qualified,
            'kind': spec.kind,
            'text': spec.text,
            'depends_on': sorted(dep.qualified for dep in spec.dependencies),
        }
        for ref, spec in sorted(self._constraints.items(), key=lambda item: item[0].qualified)
    ] + [
        {
            'target': follower.qualified,
            'kind': 'equality',
            'text': leader.qualified,
            'depends_on': [leader.qualified],
        }
        for follower, leader in sorted(
            self._directed.items(), key=lambda item: item[0].qualified
        )
    ]

get_sharing()

Every quantity held by more than one parameter, after compilation.

Source code in src/iquana/multifit.py
def get_sharing(self) -> list[dict[str, Any]]:
    """Every quantity held by more than one parameter, after compilation."""
    graph = self._compiled()
    return [
        {
            'root': entry.label,
            'members': [ref.qualified for ref in entry.members],
            'status': entry.kind,
        }
        for entry in graph.classes
        if entry.shared
    ]

get_parameter_table()

Every parameter of every dataset, with its status in the graph.

min/max are the configured bounds of the quantity (intersected across a shared group). fit_min/fit_max are the range the fit actually uses: a constraint whose target has limits narrows the range of the parameter that drives it, so h2o.scale can be held below its own max by constrain('d2o.scale', '2 * h2o.scale'). That narrowed range is also what "on a bound" is judged against after a fit. For a fixed or constant quantity both equal its value.

Source code in src/iquana/multifit.py
def get_parameter_table(self) -> list[dict[str, Any]]:
    """Every parameter of every dataset, with its status in the graph.

    ``min``/``max`` are the configured bounds of the quantity (intersected
    across a shared group). ``fit_min``/``fit_max`` are the range the fit
    actually uses: a constraint whose target has limits narrows the range of
    the parameter that drives it, so ``h2o.scale`` can be held below its own
    ``max`` by ``constrain('d2o.scale', '2 * h2o.scale')``. That narrowed
    range is also what "on a bound" is judged against after a fit. For a
    fixed or constant quantity both equal its value.
    """
    graph = self._compiled()
    values = graph.class_values()
    rows: list[dict[str, Any]] = []
    for entry_class in graph.classes:
        fit_min, fit_max = graph.ranges.get(
            entry_class.index, (entry_class.minimum, entry_class.maximum)
        )
        for ref in entry_class.members:
            rows.append(
                {
                    'parameter': ref.qualified,
                    'value': values[entry_class.index],
                    'status': self._status(entry_class, ref),
                    'root': entry_class.label,
                    'expression': entry_class.constraint_text,
                    'min': entry_class.minimum,
                    'max': entry_class.maximum,
                    'fit_min': fit_min,
                    'fit_max': fit_max,
                }
            )
    return sorted(rows, key=lambda row: row['parameter'])

describe()

Print the datasets, relationships and free-parameter count.

The summary to read before fitting: it states how many independent coordinates the graph leaves, which is the number that shared parameters are supposed to reduce and the easiest thing to get wrong.

Source code in src/iquana/multifit.py
def describe(self) -> None:
    """Print the datasets, relationships and free-parameter count.

    The summary to read before fitting: it states how many independent
    coordinates the graph leaves, which is the number that shared parameters
    are supposed to reduce and the easiest thing to get wrong.
    """
    graph = self._compiled()
    print(f'\n{"=" * 78}')
    print(f'MultiFitter: {len(self._entries)} dataset(s), {graph.n_free} free parameter(s)')
    print('=' * 78)

    for entry in self._entries.values():
        q_range = entry.fitter.get_q_range()
        selected = (
            int(get_fit_index(entry.fitter.data).sum()) if entry.fitter.data is not None else 0
        )
        print(
            f'  {entry.name:<14} {entry.fitter.model_name or "(no model)":<24} '
            f'{selected:>5} pts  weight {entry.weight:<6g} '
            f'Q [{q_range[0]:.4g}, {q_range[1]:.4g}]  '
            f'{entry.fitter._resolution.describe()}'
        )

    rows = [row for row in self.get_parameter_table() if row['status'] != 'fixed']
    if rows:
        print('\nParameters taking part in the fit:')
        width = max(len(row['parameter']) for row in rows)
        for row in rows:
            detail = f' = {row["expression"]}' if row['expression'] else ''
            root = '' if row['parameter'] == row['root'] else f'  [= {row["root"]}]'
            narrowed = ''
            if row['status'] in ('free', 'shared') and (
                row['fit_min'] > row['min'] or row['fit_max'] < row['max']
            ):
                narrowed = (
                    f'  fitted on [{row["fit_min"]:.6g}, {row["fit_max"]:.6g}] '
                    f'by a constraint (configured [{row["min"]:.6g}, {row["max"]:.6g}])'
                )
            print(
                f'  {row["parameter"]:<{width}}  {row["value"]:>12.6g}  '
                f'{row["status"]}{detail}{root}{narrowed}'
            )

    constraints = self.get_constraints()
    if constraints:
        print('\nConstraints:')
        for item in constraints:
            print(f'  {item["target"]} = {item["text"]}  ({item["kind"]})')

    print(f'\nFree parameters: {", ".join(graph.labels) or "none"}')
    print(f'{"=" * 78}\n')

calculate(q=None, dq=None)

Evaluate every model at the current, constraint-resolved parameters.

Shared values and expressions hold here exactly as they do during a fit, so a preview is a preview of the constrained model rather than of whatever the individual parameter tables happen to say.

Parameters:

Name Type Description Default
q Mapping[str, ndarray] | None

Optional per-dataset Q grids, {'h2o': array, ...}. Datasets left out use their own data grid and resolution.

None
dq Mapping[str, float] | None

Optional per-dataset relative resolution widths for those grids.

None

Returns:

Type Description
dict[str, ndarray]

Dataset name -> intensities. On a data grid the array is full length,

dict[str, ndarray]

with NaN where a point is excluded from the fit.

Source code in src/iquana/multifit.py
def calculate(
    self, q: Mapping[str, np.ndarray] | None = None, dq: Mapping[str, float] | None = None
) -> dict[str, np.ndarray]:
    """Evaluate every model at the current, constraint-resolved parameters.

    Shared values and expressions hold here exactly as they do during a fit,
    so a preview is a preview of the constrained model rather than of
    whatever the individual parameter tables happen to say.

    Args:
        q: Optional per-dataset Q grids, ``{'h2o': array, ...}``. Datasets
            left out use their own data grid and resolution.
        dq: Optional per-dataset relative resolution widths for those grids.

    Returns:
        Dataset name -> intensities. On a data grid the array is full length,
        with NaN where a point is excluded from the fit.
    """
    self._require_ready()
    graph = self._compiled()
    resolved = graph.resolve()

    curves: dict[str, np.ndarray] = {}
    for entry in self._entries.values():
        grid = None if q is None else q.get(entry.name)
        width = None if dq is None else dq.get(entry.name)
        target = (
            entry.fitter._evaluation_data(warn=False)
            if grid is None
            else theory_data(grid, width)
        )
        curve, fit_index = self._evaluate_entry(entry, target, resolved)
        full = np.full(len(fit_index), np.nan)
        full[fit_index] = curve
        curves[entry.name] = full
    return curves

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

Plot every dataset with its model at the current parameters, without fitting.

One data-and-model panel per dataset, each with its own axes because the Q ranges need not match. Constraints are resolved first, so this shows the model the fit will start from. Unlike :meth:plot_results, this reads the datasets as they are now — that is the point of a preview.

Parameters:

Name Type Description Default
show_components bool

For a '+' mixture model, overlay one dashed curve per component. A documented no-op elsewhere.

False
Source code in src/iquana/multifit.py
def plot_model(
    self,
    show_residuals: bool = True,
    log_scale: bool = True,
    show: bool | None = None,
    show_components: bool = False,
) -> Figure:
    """Plot every dataset with its model at the current parameters, without fitting.

    One data-and-model panel per dataset, each with its own axes because the
    Q ranges need not match. Constraints are resolved first, so this shows
    the model the fit will start from. Unlike :meth:`plot_results`, this
    reads the datasets as they are now — that is the point of a preview.

    Args:
        show_components: For a '+' mixture model, overlay one dashed curve
            per component. A documented no-op elsewhere.
    """
    self._require_ready()
    graph = self._compiled()
    resolved = graph.resolve()

    panels = []
    for entry in self._entries.values():
        evaluation_data = entry.fitter._evaluation_data(warn=False)
        curve, fit_index = self._evaluate_entry(entry, evaluation_data, resolved)
        components = None
        if show_components:
            components = entry.fitter._compute_component_curves(
                self._canonical_values(entry, resolved)
            )
        panels.append(
            multi_plotting.DatasetPanel.from_dataset(
                name=entry.name,
                model=entry.fitter.model_name or '',
                data=entry.fitter.data,
                fitted_curve=curve,
                fit_index=fit_index,
                residuals=_preview_residuals(evaluation_data, curve, fit_index),
                weight=entry.weight,
                component_curves=components,
            )
        )
    return multi_plotting.plot_multi(
        panels,
        title=f'Model preview: {len(panels)} datasets, {graph.n_free} free parameters',
        show_residuals=show_residuals,
        log_scale=log_scale,
        show=show,
        preview=True,
        show_components=show_components,
    )

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

Fit every dataset together, through one bumps problem.

Parameters:

Name Type Description Default
engine Literal['bumps']

Only 'bumps' is supported. The scipy backend and joint DREAM sampling are planned and will keep this signature.

'bumps'
method str | None

bumps optimizer — 'amoeba' (default), 'lm', 'newton', 'de'.

None
**kwargs Any

Passed to the bumps fitter.

{}

Returns:

Name Type Description
A MultiFitResult

class:~iquana.multi_results.MultiFitResult.

Raises:

Type Description
ValueError

If the analysis is not ready to fit, an engine other than bumps is requested, or a dataset fails the data preflight.

ConstraintError

If the parameter graph cannot be compiled.

Source code in src/iquana/multifit.py
def fit(
    self,
    engine: Literal['bumps'] = 'bumps',
    method: str | None = None,
    **kwargs: Any,
) -> MultiFitResult:
    """Fit every dataset together, through one bumps problem.

    Args:
        engine: Only ``'bumps'`` is supported. The scipy backend and joint
            DREAM sampling are planned and will keep this signature.
        method: bumps optimizer — ``'amoeba'`` (default), ``'lm'``,
            ``'newton'``, ``'de'``.
        **kwargs: Passed to the bumps fitter.

    Returns:
        A :class:`~iquana.multi_results.MultiFitResult`.

    Raises:
        ValueError: If the analysis is not ready to fit, an engine other
            than bumps is requested, or a dataset fails the data preflight.
        ConstraintError: If the parameter graph cannot be compiled.
    """
    if engine != 'bumps':
        raise ValueError(
            f"Unknown engine '{engine}'. Simultaneous fitting currently supports "
            "'bumps' only; the scipy path is not implemented, and reporting one "
            'as if it were would give results no test has checked.'
        )
    self._require_ready()
    graph = self._compiled()
    self._preflight(graph)

    specs = [self._entry_spec(entry) for entry in self._entries.values()]
    method = method or DEFAULT_BUMPS_METHOD
    outcome = run_multi_bumps(specs, graph, method=method, **kwargs)

    result = self._build_result(graph, outcome, method)
    # Commit last: a failure anywhere above leaves every dataset exactly as
    # it was, which is what makes a failed fit safe to retry. The context is
    # taken afterwards, because it digests the parameter values and would
    # otherwise describe the configuration the fit started from — making
    # every result look stale the moment it was produced.
    self._commit(graph, outcome.root_values)
    self._attach_component_curves(result)
    result.fit_context = self._context()
    self._result = result

    logger.info(f'\n{OK} Simultaneous fit completed!\n{MultiFitReport(result)}')
    self._warn_about_bounds(result)
    return result

get_fit_report()

A self-rendering report for the last fit.

Prints as a table in a terminal, renders as HTML in a notebook, and converts to Markdown with to_markdown().

Raises:

Type Description
ValueError

If no fit has been run.

Source code in src/iquana/multifit.py
def get_fit_report(self) -> MultiFitReport:
    """A self-rendering report for the last fit.

    Prints as a table in a terminal, renders as HTML in a notebook, and
    converts to Markdown with ``to_markdown()``.

    Raises:
        ValueError: If no fit has been run.
    """
    return MultiFitReport(self._require_result())

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

Plot the fitted model for every dataset.

Drawn entirely from the result's own snapshot — the observations, the selection and the model curves it was built with — so the figure shows one coherent fit even if a dataset has since been reconfigured or removed. Use :meth:plot_model to see the datasets as they are now.

Parameters:

Name Type Description Default
objective_residuals bool

Show sqrt(weight)·residual — what the optimizer minimised — instead of the residual in the dataset's own sigma units. Only differs when a dataset factor is set, and is labelled differently so the two cannot be confused.

False
show_components bool

For a '+' mixture model, overlay one dashed curve per component. A documented no-op elsewhere.

False
Source code in src/iquana/multifit.py
def plot_results(
    self,
    show_residuals: bool = True,
    log_scale: bool = True,
    show: bool | None = None,
    objective_residuals: bool = False,
    show_components: bool = False,
) -> Figure:
    """Plot the fitted model for every dataset.

    Drawn entirely from the result's own snapshot — the observations, the
    selection and the model curves it was built with — so the figure shows
    one coherent fit even if a dataset has since been reconfigured or
    removed. Use :meth:`plot_model` to see the datasets as they are now.

    Args:
        objective_residuals: Show ``sqrt(weight)·residual`` — what the
            optimizer minimised — instead of the residual in the dataset's
            own sigma units. Only differs when a dataset factor is set, and
            is labelled differently so the two cannot be confused.
        show_components: For a '+' mixture model, overlay one dashed curve
            per component. A documented no-op elsewhere.
    """
    result = self._require_result()
    self._warn_if_stale()
    panels = [
        multi_plotting.DatasetPanel(
            name=entry.name,
            model=entry.model,
            q=entry.observed_q,
            intensity=entry.observed_intensity,
            uncertainty=entry.observed_uncertainty,
            dq=entry.observed_dq,
            fitted_curve=entry.fitted_curve,
            fit_index=entry.fit_index,
            residuals=(entry.objective_residuals if objective_residuals else entry.residuals),
            weight=entry.weight,
            component_curves=entry.component_curves,
        )
        for entry in result.datasets.values()
    ]
    symbol = 'χ²'
    return multi_plotting.plot_multi(
        panels,
        title=(
            f'Simultaneous fit: {result.n_datasets} datasets, '
            f'{symbol}/dof = {result.reduced_chisq:.4f}'
        ),
        show_residuals=show_residuals,
        log_scale=log_scale,
        show=show,
        preview=False,
        residual_label=('Residuals (√w·σ)' if objective_residuals else 'Residuals (σ)'),
        show_components=show_components,
    )

save_results(directory)

Write the fit to a directory of CSV files.

Produces parameters.csv (every parameter with its status, root and uncertainty), datasets.csv (per-dataset diagnostics), covariance.csv (over the free roots, when available), one <dataset>_curve.csv per dataset, and manifest.txt tying them to one fit. Curve files carry Q, I, dI, the fitted intensity, the residual in sigma units and the objective residual, for the fitted points only.

Raises:

Type Description
ValueError

If no fit has been run.

Source code in src/iquana/multifit.py
def save_results(self, directory: str) -> None:
    """Write the fit to a directory of CSV files.

    Produces ``parameters.csv`` (every parameter with its status, root and
    uncertainty), ``datasets.csv`` (per-dataset diagnostics),
    ``covariance.csv`` (over the free roots, when available), one
    ``<dataset>_curve.csv`` per dataset, and ``manifest.txt`` tying them to
    one fit. Curve files carry Q, I, dI, the fitted intensity, the residual
    in sigma units and the objective residual, for the fitted points only.

    Raises:
        ValueError: If no fit has been run.
    """
    result = self._require_result()
    self._warn_if_stale()

    # Everything is rendered before anything is written, so a failure
    # half-way cannot leave a directory holding some files from this fit and
    # some from the last one.
    files: dict[str, str] = {
        'parameters.csv': _csv_text(
            ['parameter', 'value', 'stderr', 'formatted', 'status', 'root', 'expression'],
            [
                [
                    entry.qualified,
                    _csv_number(entry.value),
                    '' if entry.stderr is None else _csv_number(entry.stderr),
                    entry.formatted,
                    entry.status,
                    entry.root,
                    entry.expression,
                ]
                for entry in result.parameters.values()
            ],
        ),
        'datasets.csv': _csv_text(
            [
                'dataset',
                'model',
                'n_points',
                'chisq',
                'objective_contribution',
                'rms_residual',
                'mean_squared_residual',
                'weight',
                'qmin',
                'qmax',
                'resolution',
            ],
            [
                [
                    entry.name,
                    entry.model,
                    str(entry.n_points),
                    _csv_number(entry.chisq),
                    _csv_number(entry.objective_contribution),
                    _csv_number(entry.rms_residual),
                    _csv_number(entry.mean_squared_residual),
                    _csv_number(entry.weight),
                    '' if entry.q_range is None else _csv_number(entry.q_range[0]),
                    '' if entry.q_range is None else _csv_number(entry.q_range[1]),
                    entry.resolution,
                ]
                for entry in result.datasets.values()
            ],
        ),
    }

    if result.cov is not None:
        files['covariance.csv'] = _csv_text(
            ['parameter', *result.cov_labels],
            [
                [label, *[_csv_number(value) for value in row]]
                for label, row in zip(result.cov_labels, np.asarray(result.cov), strict=True)
            ],
        )

    for entry in result.datasets.values():
        files[f'{entry.name}_curve.csv'] = self._curve_text(entry, result)

    files[MANIFEST_NAME] = self._manifest_text(result, sorted(files))

    os.makedirs(directory, exist_ok=True)
    obsolete = _previously_exported(directory) - set(files)
    for name, text in files.items():
        atomic_write(os.path.join(directory, name), text)
    # Only files a previous export of ours listed are removed, and only once
    # the new ones are safely in place. A stale covariance.csv beside a fit
    # that has none would otherwise read as this fit's covariance.
    for name in sorted(obsolete):
        try:
            os.remove(os.path.join(directory, name))
        except OSError:
            logger.debug(f'Could not remove the superseded export file {name}.')

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

DatasetHandle

Returned by MultiFitter.add() and by fit['name']. Configures one dataset of a joint analysis. Deliberately a facade rather than the underlying IQuana: fitting, persistence and reporting belong to the analysis as a whole, and a child that could fit itself while enrolled in one would produce a result nobody asked for.

iquana.multifit.DatasetHandle

Configure one dataset of a :class:MultiFitter.

A deliberate facade over the private fitter, not the fitter itself. The methods below are the ones that describe this dataset; everything that describes the analysis as a whole — fitting, sharing, constraints, plots, export — belongs to the parent, because a child that could fit itself while enrolled in a joint analysis would produce a result nobody asked for and nothing would notice.

Every mutation is routed back through the parent so the parameter graph is revalidated and, if the change is inconsistent, rolled back.

Source code in src/iquana/multifit.py
class DatasetHandle:
    """Configure one dataset of a :class:`MultiFitter`.

    A deliberate facade over the private fitter, not the fitter itself. The
    methods below are the ones that describe *this* dataset; everything that
    describes the analysis as a whole — fitting, sharing, constraints, plots,
    export — belongs to the parent, because a child that could fit itself while
    enrolled in a joint analysis would produce a result nobody asked for and
    nothing would notice.

    Every mutation is routed back through the parent so the parameter graph is
    revalidated and, if the change is inconsistent, rolled back.
    """

    __slots__ = ('_parent', '_name')

    def __init__(self, parent: 'MultiFitter', name: str) -> None:
        self._parent = parent
        self._name = name

    def __repr__(self) -> str:
        return f'<DatasetHandle {self._name!r} model={self.model_name!r}>'

    @property
    def _fitter(self) -> IQuana:
        return self._parent._entry(self._name).fitter

    @property
    def name(self) -> str:
        """The dataset's identifier within the analysis."""
        return self._name

    @property
    def model_name(self) -> str | None:
        """The model expression this dataset is fitted with."""
        return self._fitter.model_name

    @property
    def weight(self) -> float:
        """This dataset's objective coefficient."""
        return self._parent._entry(self._name).weight

    @property
    def data(self) -> Any:
        """A copy of the dataset, so edits cannot reach the configured fit."""
        return copy.deepcopy(self._fitter.data)

    @property
    def params(self) -> dict[str, dict[str, Any]]:
        """A snapshot of the parameter table, with values resolved by the graph."""
        resolved = self._parent._resolved_values()
        table: dict[str, dict[str, Any]] = {}
        for name, info in self._fitter.params.items():
            entry = dict(info)
            ref = ParameterRef(self._name, name)
            if ref in resolved:
                entry['value'] = resolved[ref]
            table[name] = entry
        return table

    # -- model configuration ------------------------------------------------

    def set_model(self, model_name: str, platform: str = 'cpu') -> None:
        """Replace this dataset's model. Rejected while a constraint needs a
        parameter the new model does not have."""
        self._parent._mutate(self._name, lambda: self._fitter.set_model(model_name, platform))

    def set_models(self, *model_names: str, **kwargs: Any) -> None:
        """Combine several models for this dataset (see :meth:`IQuana.set_models`)."""
        self._parent._mutate(self._name, lambda: self._fitter.set_models(*model_names, **kwargs))

    def set_structure_factor(
        self, structure_factor_name: str, radius_effective_mode: str = 'unconstrained'
    ) -> None:
        """Apply a structure factor to this dataset's model."""
        self._parent._mutate(
            self._name,
            lambda: self._fitter.set_structure_factor(structure_factor_name, radius_effective_mode),
        )

    def remove_structure_factor(self) -> None:
        """Revert to the form factor alone."""
        self._parent._mutate(self._name, self._fitter.remove_structure_factor)

    # -- parameters ---------------------------------------------------------

    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 parameter of this dataset.

        Routed through the parent, so setting a value or vary flag on a member
        of a shared group updates the whole group and keeps it consistent.
        """
        self._parent.set_param(f'{self._name}.{name}', value=value, min=min, max=max, vary=vary)

    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 of this dataset.

        One call is one transaction. The width and vary flag belong to the
        quantity and reach every dataset sharing it; the distribution type,
        quadrature count and truncation are this dataset's own configuration.
        If any part of the call is rejected, none of it is applied.
        """
        self._parent._configure_pd(
            self._name,
            param_name,
            pd_width=pd_width,
            pd_n=pd_n,
            pd_nsigma=pd_nsigma,
            pd_type=pd_type,
            vary=vary,
        )

    def enable_polydispersity(self, enabled: bool = True) -> None:
        """Enable or disable polydispersity for this dataset."""
        self._parent._mutate(self._name, lambda: self._fitter.enable_polydispersity(enabled))

    def link_params(self, name: str, to: str) -> None:
        """Link two parameters *within* this dataset.

        Applied to the dataset's own parameter manager, exactly as
        :meth:`IQuana.link_params` would, so this handle's ``get_links()``
        and the graph agree about it afterwards. The graph picks it up as a
        mandatory local edge. The cross-dataset form is
        :meth:`MultiFitter.link_params`, which takes qualified names.
        """
        self._parent._mutate(self._name, lambda: self._fitter.link_params(name, to))

    def unlink_params(self, name: str) -> None:
        """Remove a link created by :meth:`link_params`."""
        self._parent._mutate(self._name, lambda: self._fitter.unlink_params(name))

    # -- selection and resolution -------------------------------------------

    def set_q_range(self, qmin: float | None = None, qmax: float | None = None) -> None:
        """Restrict the Q range used for this dataset."""
        self._parent._mutate(self._name, lambda: self._fitter.set_q_range(qmin, qmax))

    def reset_q_range(self) -> None:
        """Use this dataset's full Q range again."""
        self._parent._mutate(self._name, self._fitter.reset_q_range)

    def set_resolution(self, mode: str = 'data', **kwargs: Any) -> None:
        """Choose how instrument resolution is applied to this dataset."""
        self._parent._mutate(self._name, lambda: self._fitter.set_resolution(mode, **kwargs))

    # -- inspection ---------------------------------------------------------

    def get_params(self) -> None:
        """Print this dataset's parameter table."""
        self._fitter.get_params()

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

        ``pd`` is the width the analysis will actually use, resolved through the
        constraint graph, so a shared or constrained width reads the same here
        as it does in :meth:`MultiFitter.get_parameter_table`. The distribution
        type, quadrature count and truncation are per-dataset configuration and
        are reported as this dataset holds them.
        """
        config = dict(self._fitter.get_pd_param(param_name))
        resolved = self._parent._resolved_value(
            ParameterRef(self._name, self._fitter._param_manager.resolve_name(param_name), pd=True)
        )
        if resolved is not None:
            config['pd'] = resolved
            config['active'] = resolved > 0
        return config

    def get_pd_params(self) -> None:
        """Print this dataset's polydispersity table."""
        self._fitter.get_pd_params()

    def get_polydisperse_parameters(self) -> list[str]:
        """Parameters of this model that support polydispersity."""
        return self._fitter.get_polydisperse_parameters()

    def get_varying_pd_params(self) -> list[str]:
        """Polydispersity widths set to vary for this dataset."""
        return self._fitter.get_varying_pd_params()

    def supports_polydispersity(self) -> bool:
        return self._fitter.supports_polydispersity()

    def is_polydispersity_enabled(self) -> bool:
        return self._fitter.is_polydispersity_enabled()

    def get_q_range(self) -> tuple[float, float] | None:
        return self._fitter.get_q_range()

    def get_resolution(self) -> dict[str, Any]:
        return self._fitter.get_resolution()

    def get_structure_factor(self) -> str | None:
        return self._fitter.get_structure_factor()

    def get_components(self) -> list[tuple[str, str, str]]:
        return self._fitter.get_components()

    def get_links(self) -> dict[str, str]:
        """Every equality relationship whose follower is a parameter of this dataset.

        Covers this dataset's own links *and* the cross-dataset ones declared on
        the parent, because a reader asking what constrains this dataset needs
        both. A target in another dataset is reported qualified
        (``{'radius': 'h2o.radius'}``); a target in this one is reported bare
        (``{'radius_effective': 'radius'}``), matching
        :meth:`IQuana.get_links`.
        """
        links = dict(self._fitter.get_links())
        for follower, target in self._parent._cross_dataset_equalities().items():
            if follower.dataset != self._name:
                continue
            local = f'{follower.name}_pd' if follower.pd else follower.name
            links[local] = (
                (f'{target.name}_pd' if target.pd else target.name)
                if target.dataset == self._name
                else target.qualified
            )
        return links

set_model(model_name, platform='cpu')

Replace this dataset's model. Rejected while a constraint needs a parameter the new model does not have.

Source code in src/iquana/multifit.py
def set_model(self, model_name: str, platform: str = 'cpu') -> None:
    """Replace this dataset's model. Rejected while a constraint needs a
    parameter the new model does not have."""
    self._parent._mutate(self._name, lambda: self._fitter.set_model(model_name, platform))

set_models(*model_names, **kwargs)

Combine several models for this dataset (see :meth:IQuana.set_models).

Source code in src/iquana/multifit.py
def set_models(self, *model_names: str, **kwargs: Any) -> None:
    """Combine several models for this dataset (see :meth:`IQuana.set_models`)."""
    self._parent._mutate(self._name, lambda: self._fitter.set_models(*model_names, **kwargs))

set_structure_factor(structure_factor_name, radius_effective_mode='unconstrained')

Apply a structure factor to this dataset's model.

Source code in src/iquana/multifit.py
def set_structure_factor(
    self, structure_factor_name: str, radius_effective_mode: str = 'unconstrained'
) -> None:
    """Apply a structure factor to this dataset's model."""
    self._parent._mutate(
        self._name,
        lambda: self._fitter.set_structure_factor(structure_factor_name, radius_effective_mode),
    )

remove_structure_factor()

Revert to the form factor alone.

Source code in src/iquana/multifit.py
def remove_structure_factor(self) -> None:
    """Revert to the form factor alone."""
    self._parent._mutate(self._name, self._fitter.remove_structure_factor)

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

Configure a parameter of this dataset.

Routed through the parent, so setting a value or vary flag on a member of a shared group updates the whole group and keeps it consistent.

Source code in src/iquana/multifit.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 parameter of this dataset.

    Routed through the parent, so setting a value or vary flag on a member
    of a shared group updates the whole group and keeps it consistent.
    """
    self._parent.set_param(f'{self._name}.{name}', value=value, min=min, max=max, vary=vary)

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

Configure polydispersity for a parameter of this dataset.

One call is one transaction. The width and vary flag belong to the quantity and reach every dataset sharing it; the distribution type, quadrature count and truncation are this dataset's own configuration. If any part of the call is rejected, none of it is applied.

Source code in src/iquana/multifit.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 of this dataset.

    One call is one transaction. The width and vary flag belong to the
    quantity and reach every dataset sharing it; the distribution type,
    quadrature count and truncation are this dataset's own configuration.
    If any part of the call is rejected, none of it is applied.
    """
    self._parent._configure_pd(
        self._name,
        param_name,
        pd_width=pd_width,
        pd_n=pd_n,
        pd_nsigma=pd_nsigma,
        pd_type=pd_type,
        vary=vary,
    )

enable_polydispersity(enabled=True)

Enable or disable polydispersity for this dataset.

Source code in src/iquana/multifit.py
def enable_polydispersity(self, enabled: bool = True) -> None:
    """Enable or disable polydispersity for this dataset."""
    self._parent._mutate(self._name, lambda: self._fitter.enable_polydispersity(enabled))

Link two parameters within this dataset.

Applied to the dataset's own parameter manager, exactly as :meth:IQuana.link_params would, so this handle's get_links() and the graph agree about it afterwards. The graph picks it up as a mandatory local edge. The cross-dataset form is :meth:MultiFitter.link_params, which takes qualified names.

Source code in src/iquana/multifit.py
def link_params(self, name: str, to: str) -> None:
    """Link two parameters *within* this dataset.

    Applied to the dataset's own parameter manager, exactly as
    :meth:`IQuana.link_params` would, so this handle's ``get_links()``
    and the graph agree about it afterwards. The graph picks it up as a
    mandatory local edge. The cross-dataset form is
    :meth:`MultiFitter.link_params`, which takes qualified names.
    """
    self._parent._mutate(self._name, lambda: self._fitter.link_params(name, to))

Remove a link created by :meth:link_params.

Source code in src/iquana/multifit.py
def unlink_params(self, name: str) -> None:
    """Remove a link created by :meth:`link_params`."""
    self._parent._mutate(self._name, lambda: self._fitter.unlink_params(name))

set_q_range(qmin=None, qmax=None)

Restrict the Q range used for this dataset.

Source code in src/iquana/multifit.py
def set_q_range(self, qmin: float | None = None, qmax: float | None = None) -> None:
    """Restrict the Q range used for this dataset."""
    self._parent._mutate(self._name, lambda: self._fitter.set_q_range(qmin, qmax))

reset_q_range()

Use this dataset's full Q range again.

Source code in src/iquana/multifit.py
def reset_q_range(self) -> None:
    """Use this dataset's full Q range again."""
    self._parent._mutate(self._name, self._fitter.reset_q_range)

set_resolution(mode='data', **kwargs)

Choose how instrument resolution is applied to this dataset.

Source code in src/iquana/multifit.py
def set_resolution(self, mode: str = 'data', **kwargs: Any) -> None:
    """Choose how instrument resolution is applied to this dataset."""
    self._parent._mutate(self._name, lambda: self._fitter.set_resolution(mode, **kwargs))

get_params()

Print this dataset's parameter table.

Source code in src/iquana/multifit.py
def get_params(self) -> None:
    """Print this dataset's parameter table."""
    self._fitter.get_params()

get_pd_param(param_name)

Polydispersity configuration for one parameter.

pd is the width the analysis will actually use, resolved through the constraint graph, so a shared or constrained width reads the same here as it does in :meth:MultiFitter.get_parameter_table. The distribution type, quadrature count and truncation are per-dataset configuration and are reported as this dataset holds them.

Source code in src/iquana/multifit.py
def get_pd_param(self, param_name: str) -> dict[str, Any]:
    """Polydispersity configuration for one parameter.

    ``pd`` is the width the analysis will actually use, resolved through the
    constraint graph, so a shared or constrained width reads the same here
    as it does in :meth:`MultiFitter.get_parameter_table`. The distribution
    type, quadrature count and truncation are per-dataset configuration and
    are reported as this dataset holds them.
    """
    config = dict(self._fitter.get_pd_param(param_name))
    resolved = self._parent._resolved_value(
        ParameterRef(self._name, self._fitter._param_manager.resolve_name(param_name), pd=True)
    )
    if resolved is not None:
        config['pd'] = resolved
        config['active'] = resolved > 0
    return config

get_pd_params()

Print this dataset's polydispersity table.

Source code in src/iquana/multifit.py
def get_pd_params(self) -> None:
    """Print this dataset's polydispersity table."""
    self._fitter.get_pd_params()

get_polydisperse_parameters()

Parameters of this model that support polydispersity.

Source code in src/iquana/multifit.py
def get_polydisperse_parameters(self) -> list[str]:
    """Parameters of this model that support polydispersity."""
    return self._fitter.get_polydisperse_parameters()

get_varying_pd_params()

Polydispersity widths set to vary for this dataset.

Source code in src/iquana/multifit.py
def get_varying_pd_params(self) -> list[str]:
    """Polydispersity widths set to vary for this dataset."""
    return self._fitter.get_varying_pd_params()

get_q_range()

Source code in src/iquana/multifit.py
def get_q_range(self) -> tuple[float, float] | None:
    return self._fitter.get_q_range()

get_resolution()

Source code in src/iquana/multifit.py
def get_resolution(self) -> dict[str, Any]:
    return self._fitter.get_resolution()

get_structure_factor()

Source code in src/iquana/multifit.py
def get_structure_factor(self) -> str | None:
    return self._fitter.get_structure_factor()

get_components()

Source code in src/iquana/multifit.py
def get_components(self) -> list[tuple[str, str, str]]:
    return self._fitter.get_components()

Every equality relationship whose follower is a parameter of this dataset.

Covers this dataset's own links and the cross-dataset ones declared on the parent, because a reader asking what constrains this dataset needs both. A target in another dataset is reported qualified ({'radius': 'h2o.radius'}); a target in this one is reported bare ({'radius_effective': 'radius'}), matching :meth:IQuana.get_links.

Source code in src/iquana/multifit.py
def get_links(self) -> dict[str, str]:
    """Every equality relationship whose follower is a parameter of this dataset.

    Covers this dataset's own links *and* the cross-dataset ones declared on
    the parent, because a reader asking what constrains this dataset needs
    both. A target in another dataset is reported qualified
    (``{'radius': 'h2o.radius'}``); a target in this one is reported bare
    (``{'radius_effective': 'radius'}``), matching
    :meth:`IQuana.get_links`.
    """
    links = dict(self._fitter.get_links())
    for follower, target in self._parent._cross_dataset_equalities().items():
        if follower.dataset != self._name:
            continue
        local = f'{follower.name}_pd' if follower.pd else follower.name
        links[local] = (
            (f'{target.name}_pd' if target.pd else target.name)
            if target.dataset == self._name
            else target.qualified
        )
    return links

MultiFitResult

The result of a simultaneous fit. Carries the joint statistics, every parameter with its status and uncertainty, the joint covariance over the free parameters, and a per-dataset breakdown.

iquana.multi_results.MultiFitResult dataclass

The result of one simultaneous fit.

Returned by :meth:iquana.MultiFitter.fit, which hands back the same object it retains as fitter.result — there is one result per fit, not a copy per caller. It is caller-owned and mutable: nothing stops you editing its dictionaries or arrays, and if you do, the fitter's view changes with it. Take a copy.deepcopy before modifying one if you need the two to diverge, or work from :meth:to_dict, which is already a fresh structure.

What the result does not share with the fitter is the analysis it came from: the observations, the model curves, the selection and every parameter value are copied in at fit time, so reconfiguring or removing a dataset afterwards cannot change what this result reports.

Source code in src/iquana/multi_results.py
@dataclass(slots=True)
class MultiFitResult:
    """The result of one simultaneous fit.

    Returned by :meth:`iquana.MultiFitter.fit`, which hands back the same
    object it retains as ``fitter.result`` — there is one result per fit, not a
    copy per caller. It is **caller-owned and mutable**: nothing stops you
    editing its dictionaries or arrays, and if you do, the fitter's view changes
    with it. Take a ``copy.deepcopy`` before modifying one if you need the two
    to diverge, or work from :meth:`to_dict`, which is already a fresh
    structure.

    What the result does *not* share with the fitter is the analysis it came
    from: the observations, the model curves, the selection and every parameter
    value are copied in at fit time, so reconfiguring or removing a dataset
    afterwards cannot change what this result reports.
    """

    engine: str
    method: str
    converged: bool | None
    message: str
    n_datasets: int
    n_points: int
    n_free: int
    dof: int
    chisq: float
    reduced_chisq: float
    objective: float
    reduced_objective: float
    #: Dataset name -> objective coefficient.
    weighting: dict[str, float]
    #: One entry per explicit constraint, as ``{'target', 'kind', 'text'}``.
    constraints: list[dict[str, Any]] = field(default_factory=list)
    #: One entry per multi-member class, as ``{'root', 'members', 'status'}``.
    sharing: list[dict[str, Any]] = field(default_factory=list)
    parameters: dict[str, MultiParameter] = field(default_factory=dict)
    cov: np.ndarray | None = None
    cov_labels: list[str] = field(default_factory=list)
    cov_source: str | None = None
    #: Why there is no covariance, when there is none.
    cov_note: str = ''
    on_bounds: list[tuple[str, str]] = field(default_factory=list)
    datasets: dict[str, DatasetResult] = field(default_factory=dict)
    fit_context: dict[str, Any] | None = None

    @property
    def weighted(self) -> bool:
        """True when any dataset carries a factor other than 1."""
        return any(not math.isclose(value, 1.0) for value in self.weighting.values())

    @property
    def corr(self) -> np.ndarray | None:
        """Correlation matrix over the free roots, or None when unavailable."""
        return None if self.cov is None else correlation_matrix(self.cov)

    def root_parameters(self) -> list[MultiParameter]:
        """One entry per distinct quantity, whether or not the fit varied it.

        A shared radius appears here once, under its root label; its per-dataset
        members are in :attr:`parameters`. This is the list to report or tabulate
        — iterating :attr:`parameters` would count a shared measurement once per
        dataset.
        """
        return [entry for entry in self.parameters.values() if entry.is_root]

    def free_parameters(self) -> list[MultiParameter]:
        """The quantities the optimizer actually moved, each reported once."""
        return [entry for entry in self.root_parameters() if entry.status in ('free', 'shared')]

    def to_dict(self) -> dict[str, Any]:
        """The whole result as strictly JSON-serializable data."""
        return json_safe(
            {
                'engine': self.engine,
                'method': self.method,
                'converged': self.converged,
                'message': self.message,
                'n_datasets': self.n_datasets,
                'n_points': self.n_points,
                'n_free': self.n_free,
                'dof': self.dof,
                'chisq': self.chisq,
                'reduced_chisq': self.reduced_chisq,
                'objective': self.objective,
                'reduced_objective': self.reduced_objective,
                'weighting': self.weighting,
                'constraints': self.constraints,
                'sharing': self.sharing,
                'parameters': {name: entry.to_dict() for name, entry in self.parameters.items()},
                'cov': self.cov,
                'cov_labels': list(self.cov_labels),
                'cov_source': self.cov_source,
                'cov_note': self.cov_note,
                'on_bounds': [{'parameter': name, 'bound': side} for name, side in self.on_bounds],
                'datasets': {name: entry.to_dict() for name, entry in self.datasets.items()},
                'fit_context': self.fit_context,
            }
        )

    def __str__(self) -> str:
        return str(MultiFitReport(self))

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

weighted property

True when any dataset carries a factor other than 1.

corr property

Correlation matrix over the free roots, or None when unavailable.

root_parameters()

One entry per distinct quantity, whether or not the fit varied it.

A shared radius appears here once, under its root label; its per-dataset members are in :attr:parameters. This is the list to report or tabulate — iterating :attr:parameters would count a shared measurement once per dataset.

Source code in src/iquana/multi_results.py
def root_parameters(self) -> list[MultiParameter]:
    """One entry per distinct quantity, whether or not the fit varied it.

    A shared radius appears here once, under its root label; its per-dataset
    members are in :attr:`parameters`. This is the list to report or tabulate
    — iterating :attr:`parameters` would count a shared measurement once per
    dataset.
    """
    return [entry for entry in self.parameters.values() if entry.is_root]

free_parameters()

The quantities the optimizer actually moved, each reported once.

Source code in src/iquana/multi_results.py
def free_parameters(self) -> list[MultiParameter]:
    """The quantities the optimizer actually moved, each reported once."""
    return [entry for entry in self.root_parameters() if entry.status in ('free', 'shared')]

to_dict()

The whole result as strictly JSON-serializable data.

Source code in src/iquana/multi_results.py
def to_dict(self) -> dict[str, Any]:
    """The whole result as strictly JSON-serializable data."""
    return json_safe(
        {
            'engine': self.engine,
            'method': self.method,
            'converged': self.converged,
            'message': self.message,
            'n_datasets': self.n_datasets,
            'n_points': self.n_points,
            'n_free': self.n_free,
            'dof': self.dof,
            'chisq': self.chisq,
            'reduced_chisq': self.reduced_chisq,
            'objective': self.objective,
            'reduced_objective': self.reduced_objective,
            'weighting': self.weighting,
            'constraints': self.constraints,
            'sharing': self.sharing,
            'parameters': {name: entry.to_dict() for name, entry in self.parameters.items()},
            'cov': self.cov,
            'cov_labels': list(self.cov_labels),
            'cov_source': self.cov_source,
            'cov_note': self.cov_note,
            'on_bounds': [{'parameter': name, 'bound': side} for name, side in self.on_bounds],
            'datasets': {name: entry.to_dict() for name, entry in self.datasets.items()},
            'fit_context': self.fit_context,
        }
    )

iquana.multi_results.MultiParameter dataclass

One parameter of one dataset, as the joint fit resolved it.

Source code in src/iquana/multi_results.py
@dataclass(slots=True)
class MultiParameter:
    """One parameter of one dataset, as the joint fit resolved it."""

    qualified: str
    dataset: str
    name: str
    value: float
    #: None when no uncertainty could be estimated; 0.0 only when genuinely fixed.
    stderr: float | None
    formatted: str
    #: 'free', 'shared', 'derived' or 'fixed'.
    status: str
    #: Qualified label of the class this parameter belongs to.
    root: str
    #: Every qualified name that is this same quantity, including this one.
    members: tuple[str, ...] = ()
    #: Constraint text for a derived or constant parameter, '' otherwise.
    expression: str = ''
    #: Where the uncertainty came from, or why there is none.
    uncertainty_source: str = ''

    @property
    def is_root(self) -> bool:
        """True for the member whose qualified name labels the class."""
        return self.qualified == self.root

    def to_dict(self) -> dict[str, Any]:
        return json_safe(
            {
                'qualified': self.qualified,
                'dataset': self.dataset,
                'name': self.name,
                'value': self.value,
                'stderr': self.stderr,
                'formatted': self.formatted,
                'status': self.status,
                'root': self.root,
                'members': list(self.members),
                'expression': self.expression,
                'uncertainty_source': self.uncertainty_source,
            }
        )

is_root property

True for the member whose qualified name labels the class.

iquana.multi_results.DatasetResult dataclass

One dataset's share of a joint fit, including the data it was fitted to.

chisq here is the raw sum over this dataset's points; it is a diagnostic, not a goodness-of-fit test, because the degrees of freedom of a joint fit belong to the fit and cannot be split between datasets. See :attr:mean_squared_residual for the per-point figure that can be compared across datasets.

The observations are copied in, not referenced. A result outlives the configuration that produced it: datasets can be reconfigured, removed, or replaced by different measurements under the same name. Plots and exports built from this snapshot therefore always show one coherent fit, rather than this fit's model curve beside whatever data the fitter holds now.

Source code in src/iquana/multi_results.py
@dataclass(slots=True)
class DatasetResult:
    """One dataset's share of a joint fit, including the data it was fitted to.

    ``chisq`` here is the raw sum over this dataset's points; it is a
    diagnostic, not a goodness-of-fit test, because the degrees of freedom of a
    joint fit belong to the fit and cannot be split between datasets. See
    :attr:`mean_squared_residual` for the per-point figure that can be compared
    across datasets.

    **The observations are copied in, not referenced.** A result outlives the
    configuration that produced it: datasets can be reconfigured, removed, or
    replaced by different measurements under the same name. Plots and exports
    built from this snapshot therefore always show one coherent fit, rather than
    this fit's model curve beside whatever data the fitter holds now.
    """

    name: str
    model: str
    n_points: int
    chisq: float
    objective_contribution: float
    weight: float
    q_range: tuple[float, float] | None
    resolution: str
    parameters: dict[str, MultiParameter]
    #: Full-length copies of the measured arrays, as they were when fitted.
    observed_q: np.ndarray
    observed_intensity: np.ndarray
    observed_uncertainty: np.ndarray | None
    observed_dq: np.ndarray | None
    #: Model intensities at the fitted points only.
    fitted_curve: np.ndarray
    #: Which of the observed points were fitted; None means all of them.
    fit_index: np.ndarray | None
    #: Residuals in the dataset's own sigma units, (model - I)/dI.
    residuals: np.ndarray
    #: sqrt(weight) times the above: what the optimizer actually minimised.
    objective_residuals: np.ndarray
    #: Per-component curves for a '+' mixture model, on the fitted points.
    #: None for atomic models and '*' mixtures, where the parts do not stack.
    component_curves: dict[str, np.ndarray] | None = None

    def selection(self) -> np.ndarray:
        """Boolean mask of the fitted points over the observed arrays."""
        if self.fit_index is None:
            return np.ones(len(self.observed_q), dtype=bool)
        return np.asarray(self.fit_index, dtype=bool)

    @property
    def rms_residual(self) -> float:
        """Root mean squared normalized residual, sqrt(chi2_d / N_d)."""
        return math.sqrt(self.chisq / self.n_points) if self.n_points else float('nan')

    @property
    def mean_squared_residual(self) -> float:
        """chi2_d / N_d. **Not** a reduced chi-squared: no dof is allocated here."""
        return self.chisq / self.n_points if self.n_points else float('nan')

    def to_dict(self) -> dict[str, Any]:
        return json_safe(
            {
                'name': self.name,
                'model': self.model,
                'n_points': self.n_points,
                'chisq': self.chisq,
                'objective_contribution': self.objective_contribution,
                'rms_residual': self.rms_residual,
                'mean_squared_residual': self.mean_squared_residual,
                'weight': self.weight,
                'q_range': None if self.q_range is None else list(self.q_range),
                'resolution': self.resolution,
                'parameters': {name: entry.to_dict() for name, entry in self.parameters.items()},
            }
        )

rms_residual property

Root mean squared normalized residual, sqrt(chi2_d / N_d).

mean_squared_residual property

chi2_d / N_d. Not a reduced chi-squared: no dof is allocated here.

to_dict()

Source code in src/iquana/multi_results.py
def to_dict(self) -> dict[str, Any]:
    return json_safe(
        {
            'name': self.name,
            'model': self.model,
            'n_points': self.n_points,
            'chisq': self.chisq,
            'objective_contribution': self.objective_contribution,
            'rms_residual': self.rms_residual,
            'mean_squared_residual': self.mean_squared_residual,
            'weight': self.weight,
            'q_range': None if self.q_range is None else list(self.q_range),
            'resolution': self.resolution,
            'parameters': {name: entry.to_dict() for name, entry in self.parameters.items()},
        }
    )

MultiFitReport

Self-rendering summary returned by MultiFitter.get_fit_report().

iquana.multi_results.MultiFitReport

Self-rendering summary of a :class:MultiFitResult.

Text for a terminal, HTML for a notebook cell, Markdown for a document — the same three renderings :class:iquana.report.FitReport provides for a single fit, plus the two tables a joint fit needs: which parameters are the same quantity, and what each dataset contributed.

Source code in src/iquana/multi_results.py
class MultiFitReport:
    """Self-rendering summary of a :class:`MultiFitResult`.

    Text for a terminal, HTML for a notebook cell, Markdown for a document —
    the same three renderings :class:`iquana.report.FitReport` provides for
    a single fit, plus the two tables a joint fit needs: which parameters are
    the same quantity, and what each dataset contributed.
    """

    def __init__(self, result: MultiFitResult) -> None:
        self.result = result

    # ------------------------------------------------------------------
    # Rows
    # ------------------------------------------------------------------

    def _quality_rows(self, symbol: str) -> list[tuple[str, str]]:
        result = self.result
        rows = [
            ('Datasets', str(result.n_datasets)),
            (f'{symbol}/dof', _number(result.reduced_chisq)),
            (symbol, _number(result.chisq)),
        ]
        if result.weighted:
            rows.extend(
                [
                    ('Weighted objective', _number(result.objective)),
                    ('Weighted objective/dof', _number(result.reduced_objective)),
                ]
            )
        rows.extend(
            [
                ('Points fitted', str(result.n_points)),
                ('Free parameters', str(result.n_free)),
                ('Degrees of freedom', str(result.dof)),
                (
                    'Converged',
                    'not reported'
                    if result.converged is None
                    else ('yes' if result.converged else 'no'),
                ),
            ]
        )
        if result.message:
            rows.append(('Optimizer message', result.message))
        return rows

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

    def _root_rows(self) -> list[tuple[str, str, str]]:
        """(label, estimate, status) for each distinct quantity, once each."""
        rows: list[tuple[str, str, str]] = []
        for entry in self.result.root_parameters():
            status = entry.status
            if entry.status == 'derived':
                status = f'derived = {entry.expression}'
            elif entry.status == 'fixed' and entry.expression:
                status = f'fixed = {entry.expression}'
            elif entry.status in ('free', 'shared'):
                side = self._bound_side(entry.qualified)
                if side is not None:
                    status = f'{status}, on bound ({side})'
            rows.append((entry.qualified, entry.formatted, status))
        return rows

    def _sharing_rows(self, arrow: str = ARROW) -> list[tuple[str, str]]:
        return [
            (str(group['root']), f'{arrow} ' + ', '.join(group['members']))
            for group in self.result.sharing
        ]

    def _dataset_rows(self) -> list[tuple[str, ...]]:
        rows: list[tuple[str, ...]] = []
        for entry in self.result.datasets.values():
            q_range = (
                'full'
                if entry.q_range is None
                else f'{entry.q_range[0]:.4g}-{entry.q_range[1]:.4g}'
            )
            rows.append(
                (
                    entry.name,
                    entry.model,
                    str(entry.n_points),
                    _number(entry.chisq),
                    _number(entry.mean_squared_residual),
                    _number(entry.weight),
                    q_range,
                    entry.resolution,
                )
            )
        return rows

    DATASET_HEADERS = (
        'Dataset',
        'Model',
        'Points',
        CHI_SQUARED,
        f'{CHI_SQUARED}/N',
        'Weight',
        'Q range',
        'Resolution',
    )

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

    def __str__(self) -> str:
        result = self.result
        lines = [
            f'Simultaneous fit report: {result.n_datasets} datasets {MIDDLE_DOT} '
            f'{result.engine}/{result.method}',
            '=' * 78,
        ]

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

        rows = self._root_rows()
        if rows:
            lines.extend(['', 'Parameters (one row per distinct quantity):'])
            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
            )

        sharing = self._sharing_rows()
        if sharing:
            lines.extend(['', 'Shared quantities:'])
            width = max(len(root) for root, _ in sharing)
            lines.extend(f'{root:<{width}}  {members}' for root, members in sharing)

        dataset_rows = self._dataset_rows()
        if dataset_rows:
            lines.extend(['', 'Per dataset:'])
            widths = [
                max(len(str(header)), *(len(row[i]) for row in dataset_rows))
                for i, header in enumerate(self.DATASET_HEADERS)
            ]
            header = '  '.join(
                f'{name:<{widths[i]}}' for i, name in enumerate(self.DATASET_HEADERS)
            )
            lines.extend([header, '-' * len(header)])
            lines.extend(
                '  '.join(f'{cell:<{widths[i]}}' for i, cell in enumerate(row))
                for row in dataset_rows
            )
            lines.append(
                f'{CHI_SQUARED}/N is a mean squared normalized residual, not a reduced '
                f'{CHI_SQUARED}: the {result.dof} degrees of freedom belong to the '
                'joint fit.'
            )

        if result.weighted:
            lines.extend(
                [
                    '',
                    'Dataset factors are fitting priorities; the supplied dI remains the '
                    'error model, so uncertainties use the known-error sandwich '
                    'covariance.',
                ]
            )

        if result.cov is None:
            if result.cov_note:
                lines.extend(['', f'No covariance: {result.cov_note}'])
        else:
            lines.extend(['', f'Covariance source: {result.cov_source}'])
            # A note alongside a covariance is a warning about that covariance,
            # not an explanation of its absence, and must not be swallowed.
            if result.cov_note:
                lines.append(f'  WARNING: {result.cov_note}')
            strong = self.strongly_correlated()
            lines.extend(f'  strongly correlated: {a} / {b} ({rho:+.2f})' for a, b, rho in strong)

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

        return '\n'.join(lines)

    def strongly_correlated(
        self, threshold: float = DEFAULT_CORRELATION_THRESHOLD
    ) -> list[tuple[str, str, float]]:
        """Root pairs whose correlation magnitude reaches *threshold*."""
        corr = self.result.corr
        if corr is None:
            return []
        labels = self.result.cov_labels
        pairs: list[tuple[str, str, float]] = []
        for i in range(len(labels)):
            for j in range(i + 1, len(labels)):
                rho = float(corr[i, j])
                if math.isfinite(rho) and abs(rho) >= threshold:
                    pairs.append((labels[i], labels[j], rho))
        return sorted(pairs, key=lambda item: abs(item[2]), reverse=True)

    def to_markdown(self) -> str:
        """Markdown report, for a document, an issue comment or a pull request."""
        result = self.result
        cell = escape_markdown_cell
        out = [
            f'**Simultaneous fit:** {result.n_datasets} datasets · '
            f'{cell(result.engine)}/{cell(result.method)}',
            '',
            '| Statistic | Value |',
            '|---|---|',
        ]
        out.extend(
            f'| {cell(label)} | {cell(value)} |' for label, value in self._quality_rows('χ²')
        )

        rows = self._root_rows()
        if rows:
            out.extend(['', '| Parameter | Estimate | Status |', '|---|---|---|'])
            out.extend(
                f'| `{cell(name)}` | {cell(estimate)} | {cell(status)} |'
                for name, estimate, status in rows
            )

        sharing = self._sharing_rows(arrow='=')
        if sharing:
            out.extend(['', '| Quantity | Members |', '|---|---|'])
            out.extend(f'| `{cell(root)}` | {cell(members)} |' for root, members in sharing)

        dataset_rows = self._dataset_rows()
        if dataset_rows:
            headers = (
                'Dataset',
                'Model',
                'Points',
                'χ²',
                'χ²/N',
                'Weight',
                'Q range',
                'Resolution',
            )
            out.extend(['', '| ' + ' | '.join(headers) + ' |', '|' + '---|' * len(headers)])
            out.extend(
                '| ' + ' | '.join(cell(value) for value in row) + ' |' for row in dataset_rows
            )
            out.append('')
            out.append(
                f'χ²/N is a mean squared normalized residual, not a reduced χ²: the '
                f'{result.dof} degrees of freedom belong to the joint fit.'
            )

        if result.cov is None:
            if result.cov_note:
                out.extend(['', f'**No covariance:** {cell(result.cov_note)}'])
        else:
            out.extend(['', f'Covariance source: `{cell(result.cov_source)}`'])
            if result.cov_note:
                out.append(f'- **Warning:** {cell(result.cov_note)}')
            out.extend(
                f'- Strongly correlated: `{cell(a)}` / `{cell(b)}` ({rho:+.2f})'
                for a, b, rho in self.strongly_correlated()
            )

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

        return '\n'.join(out)

    def _repr_html_(self) -> str:
        """HTML report, so a notebook cell shows tables instead of text."""
        result = self.result
        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 = [
            '<div><p><strong>Simultaneous fit:</strong> '
            f'{result.n_datasets} datasets · '
            f'{escape_html_cell(result.engine)}/{escape_html_cell(result.method)}</p>'
        ]

        parts.append(f'<table style="{style}">')
        for label, value in self._quality_rows('χ²'):
            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>')

        parts.append(
            self._html_table(
                ('Parameter', 'Estimate', 'Status'), self._root_rows(), style, cell, head
            )
        )
        sharing = self._sharing_rows(arrow='=')
        if sharing:
            parts.append(self._html_table(('Quantity', 'Members'), sharing, style, cell, head))

        dataset_rows = self._dataset_rows()
        if dataset_rows:
            headers = (
                'Dataset',
                'Model',
                'Points',
                'χ²',
                'χ²/N',
                'Weight',
                'Q range',
                'Resolution',
            )
            parts.append(self._html_table(headers, dataset_rows, style, cell, head))
            parts.append(
                '<p><small>χ²/N is a mean squared normalized residual, not a reduced '
                f'χ²: the {result.dof} degrees of freedom belong to the joint fit.'
                '</small></p>'
            )

        if result.cov is None:
            if result.cov_note:
                parts.append(
                    f'<p><strong>No covariance:</strong> {escape_html_cell(result.cov_note)}</p>'
                )
        else:
            parts.append(f'<p>Covariance source: {escape_html_cell(result.cov_source)}</p>')
            if result.cov_note:
                parts.append(
                    f'<p><strong>Warning:</strong> {escape_html_cell(result.cov_note)}</p>'
                )
            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 result.on_bounds:
            hits = ', '.join(
                f'{escape_html_cell(name)} ({side})' for name, side in result.on_bounds
            )
            parts.append(f'<p><strong>At a bound:</strong> {hits}</p>')

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

    @staticmethod
    def _html_table(
        headers: tuple[str, ...],
        rows: list[Any],
        style: str,
        cell: str,
        head: str,
    ) -> str:
        if not rows:
            return ''
        parts = [f'<table style="{style}"><tr>']
        parts.extend(f'<th style="{head}">{escape_html_cell(name)}</th>' for name in headers)
        parts.append('</tr>')
        for row in rows:
            parts.append('<tr>')
            parts.extend(f'<td style="{cell}">{escape_html_cell(value)}</td>' for value in row)
            parts.append('</tr>')
        parts.append('</table>')
        return ''.join(parts)

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

strongly_correlated(threshold=DEFAULT_CORRELATION_THRESHOLD)

Root pairs whose correlation magnitude reaches threshold.

Source code in src/iquana/multi_results.py
def strongly_correlated(
    self, threshold: float = DEFAULT_CORRELATION_THRESHOLD
) -> list[tuple[str, str, float]]:
    """Root pairs whose correlation magnitude reaches *threshold*."""
    corr = self.result.corr
    if corr is None:
        return []
    labels = self.result.cov_labels
    pairs: list[tuple[str, str, float]] = []
    for i in range(len(labels)):
        for j in range(i + 1, len(labels)):
            rho = float(corr[i, j])
            if math.isfinite(rho) and abs(rho) >= threshold:
                pairs.append((labels[i], 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.

Source code in src/iquana/multi_results.py
def to_markdown(self) -> str:
    """Markdown report, for a document, an issue comment or a pull request."""
    result = self.result
    cell = escape_markdown_cell
    out = [
        f'**Simultaneous fit:** {result.n_datasets} datasets · '
        f'{cell(result.engine)}/{cell(result.method)}',
        '',
        '| Statistic | Value |',
        '|---|---|',
    ]
    out.extend(
        f'| {cell(label)} | {cell(value)} |' for label, value in self._quality_rows('χ²')
    )

    rows = self._root_rows()
    if rows:
        out.extend(['', '| Parameter | Estimate | Status |', '|---|---|---|'])
        out.extend(
            f'| `{cell(name)}` | {cell(estimate)} | {cell(status)} |'
            for name, estimate, status in rows
        )

    sharing = self._sharing_rows(arrow='=')
    if sharing:
        out.extend(['', '| Quantity | Members |', '|---|---|'])
        out.extend(f'| `{cell(root)}` | {cell(members)} |' for root, members in sharing)

    dataset_rows = self._dataset_rows()
    if dataset_rows:
        headers = (
            'Dataset',
            'Model',
            'Points',
            'χ²',
            'χ²/N',
            'Weight',
            'Q range',
            'Resolution',
        )
        out.extend(['', '| ' + ' | '.join(headers) + ' |', '|' + '---|' * len(headers)])
        out.extend(
            '| ' + ' | '.join(cell(value) for value in row) + ' |' for row in dataset_rows
        )
        out.append('')
        out.append(
            f'χ²/N is a mean squared normalized residual, not a reduced χ²: the '
            f'{result.dof} degrees of freedom belong to the joint fit.'
        )

    if result.cov is None:
        if result.cov_note:
            out.extend(['', f'**No covariance:** {cell(result.cov_note)}'])
    else:
        out.extend(['', f'Covariance source: `{cell(result.cov_source)}`'])
        if result.cov_note:
            out.append(f'- **Warning:** {cell(result.cov_note)}')
        out.extend(
            f'- Strongly correlated: `{cell(a)}` / `{cell(b)}` ({rho:+.2f})'
            for a, b, rho in self.strongly_correlated()
        )

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

    return '\n'.join(out)

to_dict()

The underlying result as JSON-serializable data.

Source code in src/iquana/multi_results.py
def to_dict(self) -> dict[str, Any]:
    """The underlying result as JSON-serializable data."""
    return self.result.to_dict()

Constraint graph

The backend-independent parameter graph behind a simultaneous fit: which parameters are the same quantity, which are computed from others, and how many independent coordinates that leaves. Deliberately free of any optimizer, so the same graph can drive other backends later.

iquana.modeling.constraints

The parameter graph behind a simultaneous fit, with no optimizer in sight.

A multi-dataset fit is two problems wearing one coat. The first is numerical and belongs to bumps: evaluate N models, concatenate residuals, minimise. The second is bookkeeping, and is the one that goes wrong quietly — which parameters are the same quantity under different names, which are computed from others, how many independent coordinates that leaves, and which bounds actually constrain the fit. This module owns the second problem and knows nothing about the first, so the same graph can drive bumps today and scipy later without being redesigned.

Three relationships reach it:

  • Equality. share() (symmetric), link_params() (directed), and the per-dataset links a :class:~iquana.IQuana already carries (link_params, shared= on set_models, radius_effective_mode). These collapse into equivalence classes: one class is one number.
  • Constants. A class pinned to a value contributes no coordinate.
  • Arithmetic. A class computed from other classes through a small expression language, likewise contributing no coordinate but carrying a derivative, so its uncertainty can be propagated from the roots it depends on.

The vocabulary, used consistently below: a ref is one parameter of one dataset; a class is a set of refs that are the same number; a root is a class whose value is not computed from anything else; and a free root is a root the optimizer moves. n_free is a count of free roots, never of refs — that is the whole point of counting them here rather than asking the optimizer.

The three ways of saying "these are equal" differ deliberately, and the difference is in what the target gives up:

  • link_params(a, to=b) says "a follows b", so b's value, vary flag and bounds win. The follower's own configuration is discarded wholesale, which is what the single-dataset API has always meant.
  • share('radius') says "these are one measurement", so the members must already agree (or name a source) and their bounds intersect. Picking one member's bounds would discard a limit set on another.
  • constrain(target, 'other.parameter') defines the target's value and nothing else, so the target keeps its own limits and they intersect as in a share. Anything else would make constrain(x, 'y') and constrain(x, '1 * y') — the same relationship, spelled two ways — permit different values.

ParameterRef dataclass

One parameter of one dataset: the atom the graph is built from.

name is the entry-local parameter name after alias resolution, so h2o.sld and h2o.A_sld under one composite model are the same ref rather than two that happen to move together. pd marks a polydispersity width, which is a distinct quantity from the parameter it belongs to (radius and radius_pd are separate refs with the same name).

Source code in src/iquana/modeling/constraints.py
@dataclass(frozen=True, slots=True, order=True)
class ParameterRef:
    """One parameter of one dataset: the atom the graph is built from.

    *name* is the entry-local parameter name **after alias resolution**, so
    ``h2o.sld`` and ``h2o.A_sld`` under one composite model are the same ref
    rather than two that happen to move together. *pd* marks a polydispersity
    width, which is a distinct quantity from the parameter it belongs to
    (``radius`` and ``radius_pd`` are separate refs with the same *name*).
    """

    dataset: str
    name: str
    pd: bool = False

    @property
    def qualified(self) -> str:
        """``dataset.parameter`` — the public spelling, and the covariance label."""
        return f'{self.dataset}.{self.name}_pd' if self.pd else f'{self.dataset}.{self.name}'

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

qualified property

dataset.parameter — the public spelling, and the covariance label.

ParameterDescriptor dataclass

Everything the graph needs to know about one parameter of one dataset.

Source code in src/iquana/modeling/constraints.py
@dataclass(frozen=True, slots=True)
class ParameterDescriptor:
    """Everything the graph needs to know about one parameter of one dataset."""

    ref: ParameterRef
    value: float
    vary: bool
    minimum: float
    maximum: float
    #: Distribution type for a polydispersity width; '' for ordinary parameters.
    pd_type: str = ''
    #: Whether the owning entry has polydispersity switched on. A PD-width
    #: relationship on an entry with PD disabled is rejected rather than
    #: silently switching it on, which would change what the fit evaluates.
    pd_enabled: bool = True
    units: str = ''

ShareGroup dataclass

A symmetric equality group created by share().

Source code in src/iquana/modeling/constraints.py
@dataclass(frozen=True, slots=True)
class ShareGroup:
    """A symmetric equality group created by ``share()``."""

    members: tuple[ParameterRef, ...]
    #: Member whose value/vary flag wins when members disagree, or None to
    #: require agreement.
    source: ParameterRef | None = None
    #: The unqualified name the user shared, kept for messages and ``describe()``.
    label: str = ''

ConstraintSpec dataclass

One explicit definition of a parameter's value.

kind is 'constant' (release A), 'equality' (release A, a bare reference) or 'expression' (release B). Storing the original text alongside the parsed form keeps error messages and describe() in the user's own spelling.

Source code in src/iquana/modeling/constraints.py
@dataclass(frozen=True, slots=True)
class ConstraintSpec:
    """One explicit definition of a parameter's value.

    ``kind`` is ``'constant'`` (release A), ``'equality'`` (release A, a bare
    reference) or ``'expression'`` (release B). Storing the original *text*
    alongside the parsed form keeps error messages and ``describe()`` in the
    user's own spelling.
    """

    target: ParameterRef
    kind: str
    text: str
    value: float | None = None
    source: ParameterRef | None = None
    expression: Expression | None = None
    #: Independent configuration saved so ``unconstrain()`` can restore it.
    previous: dict[str, Any] = field(default_factory=dict)

    @property
    def dependencies(self) -> frozenset[ParameterRef]:
        if self.kind == 'equality' and self.source is not None:
            return frozenset({self.source})
        if self.expression is not None:
            return self.expression.refs
        return frozenset()

ParameterClass dataclass

One quantity: a set of refs that are equal, and how its value arises.

Source code in src/iquana/modeling/constraints.py
@dataclass(slots=True)
class ParameterClass:
    """One quantity: a set of refs that are equal, and how its value arises."""

    index: int
    members: tuple[ParameterRef, ...]
    representative: ParameterRef
    #: 'free' | 'fixed' | 'constant' | 'derived'
    kind: str
    value: float
    minimum: float
    maximum: float
    expression: Expression | None = None
    constraint_text: str = ''
    #: Index into the free-root vector, or -1 for everything else.
    free_index: int = -1
    #: Whether the class holds polydispersity widths.
    pd: bool = False

    @property
    def label(self) -> str:
        """The covariance label and public name of this quantity."""
        return self.representative.qualified

    @property
    def shared(self) -> bool:
        return len(self.members) > 1

label property

The covariance label and public name of this quantity.

CompiledGraph dataclass

The resolved graph: classes, free roots, and how to evaluate both.

Source code in src/iquana/modeling/constraints.py
@dataclass(slots=True)
class CompiledGraph:
    """The resolved graph: classes, free roots, and how to evaluate both."""

    classes: list[ParameterClass]
    class_of: dict[ParameterRef, int]
    #: Class indices of the free roots, in optimizer-vector order.
    free_roots: list[int]
    #: Class indices of derived classes, already topologically sorted.
    derived_order: list[int]
    #: Ranges each class can reach, after target bounds were propagated back.
    ranges: dict[int, tuple[float, float]]

    @property
    def n_free(self) -> int:
        return len(self.free_roots)

    @property
    def labels(self) -> list[str]:
        """Free-root labels, in the order the optimizer vector uses."""
        return [self.classes[index].label for index in self.free_roots]

    def start_vector(self) -> np.ndarray:
        """Configured starting values of the free roots."""
        return np.array([self.classes[i].value for i in self.free_roots], dtype=float)

    def class_values(self, root_values: Sequence[float] | None = None) -> list[float]:
        """Value of every class, computing derived ones in dependency order."""
        values = [entry.value for entry in self.classes]
        if root_values is not None:
            for position, index in enumerate(self.free_roots):
                values[index] = float(root_values[position])
        for index in self.derived_order:
            entry = self.classes[index]
            assert entry.expression is not None  # kind == 'derived' guarantees it
            values[index] = entry.expression.evaluate(
                {ref: values[self.class_of[ref]] for ref in entry.expression.refs}
            )
        return values

    def resolve(self, root_values: Sequence[float] | None = None) -> dict[ParameterRef, float]:
        """Every reference's value, including derived and shared members."""
        values = self.class_values(root_values)
        return {ref: values[index] for ref, index in self.class_of.items()}

    def gradients(self, root_values: Sequence[float] | None = None) -> dict[int, np.ndarray]:
        """∂(class value)/∂(free roots), one vector per class.

        Built in the same topological order as the values, so a derived class
        that depends on another derived class chains correctly. Constants and
        fixed roots get a zero vector, which is the truthful statement that they
        do not move with the fit — as distinct from an *unknown* uncertainty,
        which the result layer reports separately.
        """
        values = self.class_values(root_values)
        jacobians = {index: np.zeros(self.n_free) for index in range(len(self.classes))}
        for position, index in enumerate(self.free_roots):
            jacobians[index][position] = 1.0
        for index in self.derived_order:
            entry = self.classes[index]
            assert entry.expression is not None
            local = {ref: values[self.class_of[ref]] for ref in entry.expression.refs}
            total = np.zeros(self.n_free)
            for ref, partial in entry.expression.gradient(local).items():
                total += partial * jacobians[self.class_of[ref]]
            jacobians[index] = total
        return jacobians

labels property

Free-root labels, in the order the optimizer vector uses.

class_values(root_values=None)

Value of every class, computing derived ones in dependency order.

Source code in src/iquana/modeling/constraints.py
def class_values(self, root_values: Sequence[float] | None = None) -> list[float]:
    """Value of every class, computing derived ones in dependency order."""
    values = [entry.value for entry in self.classes]
    if root_values is not None:
        for position, index in enumerate(self.free_roots):
            values[index] = float(root_values[position])
    for index in self.derived_order:
        entry = self.classes[index]
        assert entry.expression is not None  # kind == 'derived' guarantees it
        values[index] = entry.expression.evaluate(
            {ref: values[self.class_of[ref]] for ref in entry.expression.refs}
        )
    return values

gradients(root_values=None)

∂(class value)/∂(free roots), one vector per class.

Built in the same topological order as the values, so a derived class that depends on another derived class chains correctly. Constants and fixed roots get a zero vector, which is the truthful statement that they do not move with the fit — as distinct from an unknown uncertainty, which the result layer reports separately.

Source code in src/iquana/modeling/constraints.py
def gradients(self, root_values: Sequence[float] | None = None) -> dict[int, np.ndarray]:
    """∂(class value)/∂(free roots), one vector per class.

    Built in the same topological order as the values, so a derived class
    that depends on another derived class chains correctly. Constants and
    fixed roots get a zero vector, which is the truthful statement that they
    do not move with the fit — as distinct from an *unknown* uncertainty,
    which the result layer reports separately.
    """
    values = self.class_values(root_values)
    jacobians = {index: np.zeros(self.n_free) for index in range(len(self.classes))}
    for position, index in enumerate(self.free_roots):
        jacobians[index][position] = 1.0
    for index in self.derived_order:
        entry = self.classes[index]
        assert entry.expression is not None
        local = {ref: values[self.class_of[ref]] for ref in entry.expression.refs}
        total = np.zeros(self.n_free)
        for ref, partial in entry.expression.gradient(local).items():
            total += partial * jacobians[self.class_of[ref]]
        jacobians[index] = total
    return jacobians

resolve(root_values=None)

Every reference's value, including derived and shared members.

Source code in src/iquana/modeling/constraints.py
def resolve(self, root_values: Sequence[float] | None = None) -> dict[ParameterRef, float]:
    """Every reference's value, including derived and shared members."""
    values = self.class_values(root_values)
    return {ref: values[index] for ref, index in self.class_of.items()}

start_vector()

Configured starting values of the free roots.

Source code in src/iquana/modeling/constraints.py
def start_vector(self) -> np.ndarray:
    """Configured starting values of the free roots."""
    return np.array([self.classes[i].value for i in self.free_roots], dtype=float)

ConstraintError

Bases: ValueError

A parameter graph that cannot be compiled into a well-posed fit.

Source code in src/iquana/modeling/constraints.py
class ConstraintError(ValueError):
    """A parameter graph that cannot be compiled into a well-posed fit."""

ExpressionError

Bases: ConstraintError

A constraint expression outside the supported grammar or domain.

Source code in src/iquana/modeling/constraints.py
class ExpressionError(ConstraintError):
    """A constraint expression outside the supported grammar or domain."""

compile_graph(descriptors, *, local_links, directed_links, share_groups, constraints)

Resolve declarations into a graph, or refuse with a specific reason.

Nothing is evaluated by an optimizer here. Every error this raises is one a user can act on before a fit starts, which is the point: a graph mistake that survives to the optimizer shows up as a puzzling parameter count or a constraint that quietly stopped holding.

Parameters:

Name Type Description Default
descriptors Mapping[ParameterRef, ParameterDescriptor]

Every referencable parameter, keyed by ref.

required
local_links Mapping[ParameterRef, ParameterRef]

Per-entry equality links a fitter already carried (follower -> target). Mandatory edges, not suggestions.

required
directed_links Mapping[ParameterRef, ParameterRef]

Cross-dataset link_params (follower -> target).

required
share_groups Sequence[ShareGroup]

Symmetric groups from share().

required
constraints Mapping[ParameterRef, ConstraintSpec]

Explicit constant/equality/expression definitions.

required

Raises:

Type Description
ConstraintError

On an empty bound intersection, disagreeing share members, two definitions of one quantity, a dependency cycle, an infeasible starting point, or a constraint whose value cannot be kept inside its target's bounds.

Source code in src/iquana/modeling/constraints.py
def compile_graph(
    descriptors: Mapping[ParameterRef, ParameterDescriptor],
    *,
    local_links: Mapping[ParameterRef, ParameterRef],
    directed_links: Mapping[ParameterRef, ParameterRef],
    share_groups: Sequence[ShareGroup],
    constraints: Mapping[ParameterRef, ConstraintSpec],
) -> CompiledGraph:
    """Resolve declarations into a graph, or refuse with a specific reason.

    Nothing is evaluated by an optimizer here. Every error this raises is one a
    user can act on before a fit starts, which is the point: a graph mistake
    that survives to the optimizer shows up as a puzzling parameter count or a
    constraint that quietly stopped holding.

    Args:
        descriptors: Every referencable parameter, keyed by ref.
        local_links: Per-entry equality links a fitter already carried
            (follower -> target). Mandatory edges, not suggestions.
        directed_links: Cross-dataset ``link_params`` (follower -> target).
        share_groups: Symmetric groups from ``share()``.
        constraints: Explicit constant/equality/expression definitions.

    Raises:
        ConstraintError: On an empty bound intersection, disagreeing share
            members, two definitions of one quantity, a dependency cycle, an
            infeasible starting point, or a constraint whose value cannot be
            kept inside its target's bounds.
    """
    union = _DisjointSet(descriptors)

    # Two different kinds of "follower", because they forfeit different things.
    #
    # `value_followers` give up their value and vary flag: their configuration is
    # a shadow of what they follow, so it cannot vote on the class.
    #
    # `bound_followers` give up their limits as well. Only the *directed* forms
    # do — `link_params()` documents that the follower adopts the target's
    # configuration wholesale, which is what the single-dataset API has always
    # meant. `constrain(target, 'other.parameter')` is not one of them: it
    # documents the opposite guarantee, that the target's limits are enforced.
    # Treating the two alike would let `constrain(x, 'y')` and
    # `constrain(x, '1 * y')` — the same relationship — permit different values.
    value_followers: set[ParameterRef] = set()
    bound_followers: set[ParameterRef] = set()
    for edges in (local_links, directed_links):
        for follower, target in edges.items():
            union.union(follower, target)
            value_followers.add(follower)
            bound_followers.add(follower)
    for spec in constraints.values():
        if spec.kind == 'equality' and spec.source is not None:
            union.union(spec.target, spec.source)
            value_followers.add(spec.target)
    for group in share_groups:
        for member in group.members[1:]:
            union.union(group.members[0], member)

    _reject_unit_mismatch(share_groups, descriptors)

    members_by_root: dict[ParameterRef, list[ParameterRef]] = {}
    for ref in descriptors:
        members_by_root.setdefault(union.find(ref), []).append(ref)

    sources = {
        union.find(group.source): group.source for group in share_groups if group.source is not None
    }

    classes: list[ParameterClass] = []
    class_of: dict[ParameterRef, int] = {}
    for root in sorted(members_by_root, key=lambda ref: ref.qualified):
        members = tuple(sorted(members_by_root[root], key=lambda ref: ref.qualified))
        entry = _build_class(
            len(classes),
            members,
            value_followers,
            bound_followers,
            sources.get(root),
            descriptors,
        )
        classes.append(entry)
        for member in members:
            class_of[member] = entry.index

    _apply_constraints(classes, class_of, constraints)
    derived_order = _topological_order(classes, class_of)

    free_roots: list[int] = []
    for entry in classes:
        if entry.kind == 'free':
            entry.free_index = len(free_roots)
            free_roots.append(entry.index)

    graph = CompiledGraph(
        classes=classes,
        class_of=class_of,
        free_roots=free_roots,
        derived_order=derived_order,
        ranges={},
    )
    graph.ranges = _validate_and_tighten(graph)
    return graph

parse_expression(text, resolve)

Parse and validate one constraint expression.

The text is parsed with :func:ast.parse and then walked node by node against an allowlist. It is never handed to eval or exec, and no symbol table is exposed: an expression is data that this module interprets, which is also what lets a saved analysis carry one safely.

Supported: finite numeric literals, qualified dataset.parameter references, parentheses, unary +/-, the four arithmetic operators, and ** by a constant integer exponent up to :data:MAX_ABS_POWER. Rejected: calls, indexing, comparisons, comprehensions, attribute chains deeper than one dot, names without a dataset, and every form of assignment or import.

Parameters:

Name Type Description Default
text str

The expression source.

required
resolve Callable[[str, str], ParameterRef]

Turns (dataset, local_name) into a :class:ParameterRef, raising for an unknown dataset or parameter. Supplied by the caller because only it knows the datasets and their alias layers.

required

Raises:

Type Description
ExpressionError

On anything outside the grammar above.

Source code in src/iquana/modeling/constraints.py
def parse_expression(text: str, resolve: Callable[[str, str], ParameterRef]) -> Expression:
    """Parse and validate one constraint expression.

    The text is parsed with :func:`ast.parse` and then walked node by node
    against an allowlist. It is never handed to ``eval`` or ``exec``, and no
    symbol table is exposed: an expression is data that this module interprets,
    which is also what lets a saved analysis carry one safely.

    Supported: finite numeric literals, qualified ``dataset.parameter``
    references, parentheses, unary ``+``/``-``, the four arithmetic operators,
    and ``**`` by a constant integer exponent up to :data:`MAX_ABS_POWER`.
    Rejected: calls, indexing, comparisons, comprehensions, attribute chains
    deeper than one dot, names without a dataset, and every form of assignment
    or import.

    Args:
        text: The expression source.
        resolve: Turns ``(dataset, local_name)`` into a :class:`ParameterRef`,
            raising for an unknown dataset or parameter. Supplied by the caller
            because only it knows the datasets and their alias layers.

    Raises:
        ExpressionError: On anything outside the grammar above.
    """
    if not isinstance(text, str):
        raise ExpressionError(
            f'A constraint expression must be a string, got {type(text).__name__}.'
        )
    source = text.strip()
    if not source:
        raise ExpressionError('A constraint expression must not be empty.')
    if len(source) > MAX_EXPRESSION_LENGTH:
        raise ExpressionError(
            f'Constraint expression is {len(source)} characters long; the limit is '
            f'{MAX_EXPRESSION_LENGTH}. Constraints describe a relationship between a '
            'few parameters, not a computation.'
        )

    try:
        tree = ast.parse(source, mode='eval')
    except SyntaxError as error:
        raise ExpressionError(
            f"Could not parse constraint expression '{text}': {error.msg}"
        ) from error

    node = _convert(tree.body, source, resolve, depth=0)
    return Expression(text=source, root=node, refs=frozenset(_node_refs(node)))

parse_reference(text)

Split 'h2o.radius' into ('h2o', 'radius').

Exactly one dot separates the two halves. Composite parameter names contain underscores (dab_cor_length) and nothing else that could be mistaken for a separator, so no name is ever split on anything but that dot.

Raises:

Type Description
ConstraintError

If the text is not a single dataset.parameter pair.

Source code in src/iquana/modeling/constraints.py
def parse_reference(text: str) -> tuple[str, str]:
    """Split ``'h2o.radius'`` into ``('h2o', 'radius')``.

    Exactly one dot separates the two halves. Composite parameter names contain
    underscores (``dab_cor_length``) and nothing else that could be mistaken for
    a separator, so no name is ever split on anything but that dot.

    Raises:
        ConstraintError: If the text is not a single ``dataset.parameter`` pair.
    """
    if not isinstance(text, str):
        raise ConstraintError(f'A parameter reference must be a string, got {type(text).__name__}.')
    parts = text.strip().split('.')
    if len(parts) != 2 or not parts[0] or not parts[1]:
        raise ConstraintError(
            f"Invalid parameter reference '{text}'. Use exactly 'dataset.parameter', "
            "for example 'h2o.radius' or 'h2o.radius_pd'."
        )
    return parts[0], parts[1]

FitReport

Goodness-of-fit summary returned by IQuana.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.

iquana.report.FitReport dataclass

Goodness-of-fit summary for one completed fit.

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

Source code in src/iquana/report.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
@dataclass(slots=True)
class FitReport:
    """Goodness-of-fit summary for one completed fit.

    Built by :meth:`iquana.IQuana.get_fit_report`. Not exported at the
    top level of the package, but a public type all the same: the import path
    ``iquana.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 one is rendered as a
        plain number, because its ``formatted`` string carries a ``(fixed)``
        suffix that the status column already states.

        A follower of a *fitted* target shows the uncertainty it inherits from
        that target, since an equality link makes the two one quantity. A
        follower of a fixed target has no uncertainty to show and falls back to
        the plain number.
        """
        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 = (
                    str(info.get('formatted'))
                    if _has_uncertainty(info)
                    else _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, dot: str = '·') -> str:
        # Terminal text passes MIDDLE_DOT, which falls back to ASCII on a
        # console that cannot encode '·'; Markdown and HTML are UTF-8 documents.
        return (
            f'{self.model} {dot} {self.engine}/{self.method} {dot} '
            f'resolution: {self.resolution} {dot} 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(MIDDLE_DOT)}', '=' * 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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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 IQuana.report(): the settings block, the FitReport tables and the fit plot, as one HTML page or Markdown document.

iquana.reporting.Report

A rendered analysis report.

Returned by IQuana.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/iquana/reporting.py
class Report:
    """A rendered analysis report.

    Returned by ``IQuana.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/iquana/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/iquana/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/iquana/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().

iquana.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/iquana/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/iquana/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/iquana/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/iquana/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.

iquana.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 IQuana.get_posterior.

Source code in src/iquana/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 ``IQuana.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/iquana/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.

iquana.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 iquana import IQuana, 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 = IQuana() 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: IQuana.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/iquana/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: ``IQuana.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/iquana/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/iquana/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/iquana/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/iquana/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.

iquana.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 iquana 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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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 IQuana.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/iquana/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 ``IQuana.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/iquana/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/iquana/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/iquana/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/iquana/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.

iquana.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 iquana 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:IQuana.set_data or to :mod:iquana.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:IQuana.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:IQuana.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/iquana/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:`IQuana.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:`IQuana.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/iquana/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/iquana/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/iquana/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:iquana.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/iquana/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:`iquana.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:IQuana.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/iquana/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:`IQuana.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:IQuana 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
IQuana

A configured IQuana.

Source code in src/iquana/examples.py
def load_fitter(name: str, quiet: bool = True) -> IQuana:
    """Return a :class:`IQuana` 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 ``IQuana``.
    """
    example = get_example(name)
    data = load(name)

    with _maybe_silenced(quiet):
        fitter = IQuana()
        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 — such data has no uncertainties, so the bumps engine and fit_bayesian() need a weighting other than 'dI': see :meth:~iquana.IQuana.set_weighting ('none').

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:~iquana.IQuana.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:~iquana.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/iquana/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 — such data has no
            uncertainties, so the bumps engine and ``fit_bayesian()`` need a
            weighting other than ``'dI'``: see
            :meth:`~iquana.IQuana.set_weighting` (``'none'``).
        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:`~iquana.IQuana.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:`~iquana.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:iquana.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 = IQuana()
>>> 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/iquana/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:`iquana.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 = IQuana()
        >>> 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 iquana logger rather than with print, so the running commentary can be turned off:

import iquana

iquana.set_verbosity('quiet')   # keep warnings, drop the progress messages
iquana.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.

iquana.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('iquana') instead of print, so a caller can turn them off::

import iquana
iquana.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('iquana')
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 IQuana 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/iquana/console.py
def set_verbosity(level: int | str = 'info') -> None:
    """Set how much IQuana 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.

iquana.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/iquana/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 (``IQuana.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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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.

iquana.modeling.polydispersity.PolydispersityManager

Manage polydispersity configuration and backup/restore state.

Source code in src/iquana/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/iquana/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/iquana/modeling/polydispersity.py
def get_parameters(self) -> list[str]:
    return list(self._param_names)

has_parameters()

Source code in src/iquana/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/iquana/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/iquana/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/iquana/modeling/polydispersity.py
def set_enabled(self, enabled: bool) -> None:
    self._enabled = enabled

is_enabled()

Source code in src/iquana/modeling/polydispersity.py
def is_enabled(self) -> bool:
    return self._enabled

get_fitting_params()

Source code in src/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/modeling/polydispersity.py
def has_backup(self) -> bool:
    return self._backup is not None

clear()

Source code in src/iquana/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.

iquana.modeling.structure_factor.StructureFactorManager

Manage structure factor state and form-factor parameter backups.

Source code in src/iquana/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/iquana/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/iquana/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/iquana/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/iquana/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/iquana/modeling/structure_factor.py
def has_backup(self) -> bool:
    return bool(self._form_factor_params)

clear()

Source code in src/iquana/modeling/structure_factor.py
def clear(self) -> None:
    self._name = None
    self._radius_effective_mode = 'unconstrained'
    self._form_factor_params = {}

iquana.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/iquana/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 iquana.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