Skip to content

summary

HTML_DATA_COLLECTION_TEMPLATE = '\n<tr>\n <th>Experiment datablock</th>\n <th>experiment_name</th>\n</tr>\n<tr>\n <td>Measured intensity range:</td>\n <td>[range_min, range_max]</td>\n</tr>\n<tr>\n <td>No. of data points</td>\n <td>num_data_points</td>\n</tr>\n<tr>\n <td>Resolution function</td>\n <td>resolution_function</td>\n</tr>\n\n' module-attribute

HTML_FIGURES_TEMPLATE = '\n<img src="path_sld_plot" alt="SLD plot" width="640" height="480">\n<br>\n<img src="path_fit_experiment_plot" alt="Fit experiment plot" width="640" height="480">\n' module-attribute

HTML_INTERACTIVE_FIGURES_TEMPLATE = '\nsld_plot_div\n<br>\nfit_experiment_plot_div\n' module-attribute

HTML_PARAMETER_HEADER_TEMPLATE = '\n<tr>\n <th>parameter_name</th>\n <th>parameter_value</th>\n <th>parameter_unit</th>\n <th>parameter_error</th>\n</tr>\n' module-attribute

HTML_PARAMETER_TEMPLATE = '\n<tr>\n <td>parameter_name</td>\n <td>parameter_value</td>\n <td>parameter_unit</td>\n <td>parameter_error</td>\n</tr>\n' module-attribute

HTML_PROJECT_INFORMATION_TEMPLATE = '\n<tr>\n <td><h3>Project information</h3></td>\n</tr>\n\n<tr>\n <th>Title</th>\n <th>project_title</th>\n</tr>\n<tr>\n <td>Description</td>\n <td>project_description</td>\n</tr>\n<tr>\n <td>No. of experiments</td>\n <td>num_experiments</td>\n</tr>\n' module-attribute

HTML_REFINEMENT_TEMPLATE = '\n<tr>\n <td>Calculation engine</td>\n <td>calculation_engine</td>\n</tr>\n<tr>\n <td>Minimization engine</td>\n <td>minimization_engine</td>\n</tr>\n<tr>\n <td>Goodness-of-fit: reduced <i>&chi;</i><sup>2</sup></td>\n <td>goodness_of_fit</td>\n</tr>\n<tr>\n <td>No. of free parameters in the fit:</td>\n <td>num_fit_free_params</td>\n</tr>\n<tr>\n <td>No. of parameters (all models):</td>\n <td>num_total_params</td>\n</tr>\n<tr>\n <td>No. of free parameters (all models):</td>\n <td>num_free_params</td>\n</tr>\n<tr>\n <td>No. of fixed parameters (all models):</td>\n <td>num_fixed_params</td>\n</tr>\n<tr>\n <td>No. of constraints (all models):</td>\n <td>num_constriants</td>\n</tr>\n' module-attribute

HTML_TEMPLATE = '<!DOCTYPE html>\n\n<html>\n\n <style>\n th, td {\n padding-right: 18px;\n }\n th {\n text-align: left;\n }\n </style>\n\n <body>\n <table>\n\n <!-- Summary title -->\n <tr>\n <td><h1>Summary</h1></td>\n </tr>\n\n <!-- Project -->\n project_information_section\n\n <tr></tr>\n\n <!-- Sample -->\n <tr>\n <td><h2>Sample</h2></td>\n </tr>\n sample_section\n\n <tr></tr>\n\n <!-- Experiments -->\n <tr>\n <td><h2>Experiments</h2></td>\n </tr>\n experiments_section\n\n <tr></tr>\n\n <!-- Analysis -->\n <tr>\n <td><h2>Refinement</h2></td>\n </tr>\n refinement_section\n\n </table>\n\n figures_section\n\n </body>\n</html>' module-attribute

_NAME_MAX_LEN = 20 module-attribute

_CHANNEL_COLORS = {'pp': '#0173B2', 'pm': '#029E73', 'mp': '#CC78BC', 'mm': '#DE8F05'} module-attribute

_TOOLTIP_SCHEME = 'nametooltip' module-attribute

_ENGINE_URLS = {'refnx': 'https://refnx.readthedocs.io', 'refl1d': 'https://refl1d.readthedocs.io', 'lm': 'https://lmfit.github.io/lmfit-py/', 'bumps': 'https://bumps.readthedocs.io', 'dfo': 'https://github.com/fitbenchmarking/dfo-ls'} module-attribute

Project

Source code in src/easyreflectometry/project.py
 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
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
class Project:
    def __init__(self):
        """Init function."""
        self._info = self._default_info()
        self._path_project_parent = Path(os.path.expanduser('~'))
        self._models = ModelCollection(populate_if_none=False, unique_name='project_models')
        self._materials = MaterialCollection(populate_if_none=False, unique_name='project_materials')
        self._calculator = CalculatorFactory()
        self._experiments: Dict[DataGroup] = {}
        self._fitter: MultiFitter = None
        self._fit_settings = FitSettings()
        #: Warnings from the last :meth:`from_dict`, for the user; cleared by each load.
        self.load_report: list[str] = []
        self._colors: list[str] = None
        self._report = None
        self._q_min: float = None
        self._q_max: float = None
        self._q_resolution: int = None
        self._current_material_index = 0
        self._current_model_index = 0
        self._current_assembly_index = 0
        self._current_layer_index = 0
        self._fitter_model_index = None
        self._current_experiment_index = 0
        self._inequality_constraints: List[InequalitySpec] = []
        self._last_fit = None
        #: (derived model, the model it was derived from), see :meth:`add_contrast`.
        self._contrasts: List[tuple] = []
        self._links: List[LinkRecord] = []

        # Project flags
        self._created = False
        self._with_experiments = False

    def reset(self):
        """Reset function."""
        del self._models
        del self._materials
        global_object.map._clear()

        self.__init__()

    @property
    def parameters(self) -> List[Parameter]:
        """Get all unique parameters from all models in the project.

        Parameters shared across multiple models (e.g. material SLD) are
        only included once to avoid double-counting.
        """
        parameters = []
        seen_ids: set[int] = set()
        if self._models is not None:
            for model in self._models:
                for param in model.get_all_parameters():
                    pid = id(param)
                    if pid not in seen_ids:
                        seen_ids.add(pid)
                        parameters.append(param)
        return parameters

    def _sync_parameter_states(self) -> None:
        """Apply project-level parameter enablement and default limits.

        Superphase thickness/roughness and subphase thickness are physically
        meaningless and are marked as disabled. Thickness and roughness default
        ranges are then applied only to enabled parameters created from project
        defaults, leaving explicit user-provided bounds untouched.
        """
        if self._models is None:
            return

        disabled_ids: set[int] = set()
        for model in self._models:
            sample = model.sample
            if sample is None or len(sample) == 0:
                continue
            superphase = sample.superphase
            if superphase is not None:
                disabled_ids.add(id(superphase.thickness))
                disabled_ids.add(id(superphase.roughness))
            subphase = sample.subphase
            if subphase is not None:
                disabled_ids.add(id(subphase.thickness))

        for model in self._models:
            sample = model.sample
            if sample is None or len(sample) == 0:
                continue
            for assembly in sample:
                for layer in assembly.layers:
                    self._sync_layer_parameter_state(layer.thickness, 'thickness', disabled_ids)
                    self._sync_layer_parameter_state(layer.roughness, 'roughness', disabled_ids)
                    magnetism = getattr(layer, 'magnetism', None)
                    if magnetism is not None:
                        # Magnetic parameters exist only on magnetic layers, so
                        # they are never in `disabled_ids`; theta_m carries
                        # explicit 0-360 bounds and needs no default window.
                        self._sync_layer_parameter_state(magnetism.rho_m, 'rho_m', disabled_ids)

        # The field only acts on a magnetic model under a magnetic calculator;
        # elsewhere it is inert and must not show up as a fit control. The
        # fitter reads `fixed`, not `enabled`, so an inert field is fixed too.
        supports_magnetism = self.calculator_supports_magnetism
        for model in self._models:
            active = supports_magnetism and model.has_magnetism
            for parameter in (model.guide_field_angle, model.applied_field):
                parameter.enabled = active
                if not active:
                    _fix_if_free(parameter)
        self._fix_unused_magnetic_material_inputs()

    def _fix_unused_magnetic_material_inputs(self) -> None:
        """Keep the inputs of a magnetic material that acts on nothing out of the fit.

        A :class:`MaterialMagnetic`'s moment only reaches the reflectivity
        through a layer coupled to it (``Layer.rho_m_coupled``); with its nuclear
        SLD decoupled, its density is in the same position. Either one left
        free after the last coupled layer goes would be a zero-sensitivity fit
        parameter, so it is fixed here, on every path that re-syncs the
        project (layer and coupling edits, engine switches, loading).
        """
        coupled: set[int] = set()
        magnetic: dict[int, object] = {}
        for model in self._models:
            for assembly in model.sample:
                for layer in assembly.layers:
                    material = getattr(layer, 'material', None)
                    if getattr(material, 'magnetic_moment', None) is not None:
                        magnetic[id(material)] = material
                    if getattr(layer, 'rho_m_coupled', False):
                        coupled.add(id(material))
        for material in self._materials or []:
            if getattr(material, 'magnetic_moment', None) is not None:
                magnetic.setdefault(id(material), material)
        if not magnetic:
            return
        # A moment or density another parameter depends on (a user constraint)
        # still acts; the material's own derived SLDs do not count.
        own = {id(parameter) for material in magnetic.values() for parameter in (material.rho_m, material.sld, material.isld)}
        referenced = {
            id(source)
            for parameter in self.parameters
            if not parameter.independent and id(parameter) not in own
            for source in parameter.dependency_map.values()
        }
        for material_id, material in magnetic.items():
            if material_id in coupled:
                continue
            if id(material.magnetic_moment) not in referenced:
                _fix_if_free(material.magnetic_moment)
            if not material.sld_coupled and id(material.density) not in referenced:
                _fix_if_free(material.density)

    def _sync_layer_parameter_state(self, parameter: Parameter, kind: str, disabled_ids: set[int]) -> None:
        """Update a layer parameter's enabled state and pending default limits."""
        if id(parameter) in disabled_ids:
            parameter.enabled = False
            return

        if getattr(parameter, 'default_limits_pending', False):
            delattr(parameter, 'default_limits_pending')
            # A dependent parameter (a conformal follower) takes its bounds from its leader.
            if getattr(parameter, 'enabled', True) and parameter.independent:
                parameter.min = -np.inf
                parameter.max = np.inf
                apply_default_limits(parameter, kind)

    @property
    def q_min(self):
        """Q min."""
        if self._q_min is None:
            return Q_MIN
        return self._q_min

    @q_min.setter
    def q_min(self, value: float) -> None:
        """Q min."""
        self._q_min = value

    @property
    def q_max(self):
        """Q max."""
        if self._q_max is None:
            return Q_MAX
        return self._q_max

    @q_max.setter
    def q_max(self, value: float) -> None:
        """Q max."""
        self._q_max = value

    @property
    def q_resolution(self):
        """Q resolution."""
        if self._q_resolution is None:
            return Q_RESOLUTION
        return self._q_resolution

    @q_resolution.setter
    def q_resolution(self, value: int) -> None:
        """Q resolution."""
        self._q_resolution = value

    @property
    def current_material_index(self) -> Optional[int]:
        """Current material index."""
        return self._current_material_index

    @current_material_index.setter
    def current_material_index(self, value: int) -> None:
        """Current material index."""
        if value < 0 or value >= len(self._materials):
            raise ValueError(f'Index {value} out of range')
        if self._current_material_index != value:
            self._current_material_index = value

    @property
    def current_model_index(self) -> Optional[int]:
        """Current model index."""
        return self._current_model_index

    @current_model_index.setter
    def current_model_index(self, value: int) -> None:
        """Current model index."""
        if value < 0 or value >= len(self._models):
            raise ValueError(f'Index {value} out of range')
        if self._current_model_index != value:
            self._current_model_index = value
            self._current_assembly_index = 0
            self._current_layer_index = 0

    @property
    def current_assembly_index(self) -> Optional[int]:
        """Current assembly index."""
        return self._current_assembly_index

    @current_assembly_index.setter
    def current_assembly_index(self, value: int) -> None:
        """Current assembly index."""
        if value < 0 or value >= len(self._models[self._current_model_index].sample):
            raise ValueError(f'Index {value} out of range')
        if self._current_assembly_index != value:
            self._current_assembly_index = value
            self._current_layer_index = 0

    @property
    def current_layer_index(self) -> Optional[int]:
        """Current layer index."""
        return self._current_layer_index

    @current_layer_index.setter
    def current_layer_index(self, value: int) -> None:
        """Current layer index."""
        if value < 0 or value >= len(self._models[self._current_model_index].sample[self._current_assembly_index].layers):
            raise ValueError(f'Index {value} out of range')
        if self._current_layer_index != value:
            self._current_layer_index = value

    @property
    def current_experiment_index(self) -> Optional[int]:
        """Current experiment index."""
        return self._current_experiment_index

    @current_experiment_index.setter
    def current_experiment_index(self, value: int) -> None:
        """Current experiment index."""
        if value < 0 or value >= len(self._experiments):
            raise ValueError(f'Index {value} out of range')
        if self._current_experiment_index != value:
            self._current_experiment_index = value
            # Resetting the model index to 0 when changing the experiment
            # self.current_model_index = 0

    @property
    def created(self) -> bool:
        """Created function."""
        return self._created

    @property
    def path(self):
        """Path function."""
        return self._path_project_parent / self._info['name']

    def set_path_project_parent(self, path: Union[Path, str]):
        """Set path project parent."""
        self._path_project_parent = Path(path)

    @property
    def models(self) -> ModelCollection:
        """Models function."""
        return self._models

    @models.setter
    def models(self, models: ModelCollection) -> None:
        """Models function."""
        self._replace_collection(models, self._models)
        self._invalidate_fitter()
        # Use setter to update indicies for current model, assembly and layer
        self.current_model_index = 0
        # Only track materials not already in the project's material collection
        # (e.g. layers built from self._materials, as in default_model(), would
        # otherwise be re-added and trigger a spurious duplicate-item warning).
        self._extend_palette_from_models()
        for model in self._models:
            model.interface = self._calculator
        self._sync_parameter_states()

    @property
    def fitter(self) -> MultiFitter:
        """The fitter of the current model, configured from :attr:`fit_settings`.

        Every hand-out writes the project's settings onto it, so configure the
        fit through :attr:`fit_settings` (or :attr:`minimizer`), not through
        the returned fitter's core fitter.
        """
        if len(self._models):
            if (self._fitter is None) or (self._fitter_model_index != self._current_model_index):
                self._fitter = MultiFitter(self._models[self._current_model_index])
                self._fitter.settings = self._fit_settings
                self._fitter_model_index = self._current_model_index
                # Fits run through this fitter pick up the project's inequality
                # constraints automatically (resolved at fit time). A weak
                # reference avoids a project -> fitter -> project cycle that
                # would keep a discarded project (and its unique names) alive.
                self._fitter.constraints_factory_provider = _weak_constraints_provider(self)
            self._fit_settings.configure(self._fitter.easy_science_multi_fitter)
        return self._fitter

    def _invalidate_fitter(self) -> None:
        """Drop the cached fitter; the next :attr:`fitter` access builds a new one."""
        self._fitter = None
        self._fitter_model_index = None

    @property
    def fit_settings(self) -> FitSettings:
        """The project's fit configuration; edits apply to the next fit prepared."""
        return self._fit_settings

    @fit_settings.setter
    def fit_settings(self, settings: FitSettings) -> None:
        settings.validate()
        self._fit_settings = settings
        if self._fitter is not None:
            self._fitter.settings = settings

    def prepare_fit(
        self,
        experiments: Optional[list] = None,
        *,
        objective: Optional[str] = None,
        skip_invalid: bool = False,
    ) -> PreparedFit:
        """Prepare a fit of the experiments with a snapshot of :attr:`fit_settings`.

        The run is not executed: call :meth:`PreparedFit.execute`, or run its
        ``core_fitter`` in a worker thread, then hand the results to
        :meth:`record_fit`. Inequality constraints are resolved when it executes.

        A parameter of a fitted model that follows a free parameter of a model
        left out of the fit keeps following it, and that parameter is varied
        too (``prepared.added_roots``); the left-out data does not enter the fit.

        Parameters
        ----------
        experiments : list, optional
            The experiments to fit, in order. By default, the loaded ones
            whose ``include_in_fit`` is set.
        objective : str, optional
            Zero-variance objective overriding the settings' one.
        skip_invalid : bool, optional
            Leave out experiments that have no model in this project (listed
            in ``prepared.skipped``) instead of raising. By default, False.

        Returns
        -------
        PreparedFit
            The prepared run.

        Raises
        ------
        FitScopeError
            Nothing to fit, or an experiment has no model in this project.
        FitPreconditionError
            If a free parameter's bounds are unusable for the minimizer.
        """
        experiments, invalid = self._fit_scope(experiments, skip_invalid, action='fit')
        fitter = self._fitter_for(experiments)
        prepared = fitter.prepare(objective=objective)
        prepared.skipped = [experiment.name for experiment in invalid]
        prepared.experiments = [
            experiment for experiment in experiments for _ in (getattr(experiment, 'available_channels', None) or [None])
        ]
        return prepared

    def prepare_sample(
        self,
        experiments: Optional[list] = None,
        *,
        skip_invalid: bool = False,
    ) -> MultiFitter:
        """A fitter ready for Bayesian sampling of the experiments, with :attr:`fit_settings`.

        The sampling is not run: call ``mcmc_sample()`` on the result (with
        no ``DataGroup``), from a worker thread if need be. Each dataset --
        each spin channel of a polarized experiment -- is sampled with the
        resolution it was measured with, exactly as :meth:`prepare_fit` fits
        it, so the posterior and the classical fit agree on what the model
        is compared to. Inequality constraints are resolved when it runs.

        Parameters
        ----------
        experiments : list, optional
            The experiments to sample, in order. By default, the loaded ones
            whose ``include_in_fit`` is set.
        skip_invalid : bool, optional
            Leave out experiments that have no model in this project instead
            of raising. By default, False.

        Returns
        -------
        MultiFitter
            The fitter, built with :meth:`MultiFitter.for_experiments`. Its
            ``settings`` are a snapshot of :attr:`fit_settings` taken here, as
            :meth:`prepare_fit` takes one: an edit made before ``mcmc_sample()``
            runs applies to the next sampling, not to this one. Its
            ``skipped`` lists the names of the experiments left out by
            ``skip_invalid``, like ``PreparedFit.skipped``.

        Raises
        ------
        FitScopeError
            Nothing to sample, or an experiment has no model in this project.
        """
        experiments, invalid = self._fit_scope(experiments, skip_invalid, action='sample')
        fitter = self._fitter_for(experiments)
        fitter.skipped = [experiment.name for experiment in invalid]
        return fitter

    def _fit_scope(self, experiments: Optional[list], skip_invalid: bool, action: str) -> tuple[list, list]:
        """The experiments a fit or sampling covers, and the ones left out for lack of a model.

        `action` is the verb for the messages: ``'fit'`` or ``'sample'``.
        """
        if experiments is None:
            experiments = [self._experiments[key] for key in sorted(self._experiments) if self._experiments[key].include_in_fit]
        invalid = [experiment for experiment in experiments if self._model_index(experiment.model) is None]
        if invalid and not skip_invalid:
            names = ', '.join(f"'{experiment.name}'" for experiment in invalid)
            done = {'fit': 'fitted', 'sample': 'sampled'}[action]
            raise FitScopeError(f'{names} cannot be {done}: no model of this project is assigned.')
        experiments = [experiment for experiment in experiments if experiment not in invalid]
        if not experiments:
            raise FitScopeError(f'No experiment to {action}: include at least one experiment that has a model.')
        return experiments, invalid

    def _fitter_for(self, experiments: list) -> MultiFitter:
        """A `for_experiments` fitter over `experiments`, with a snapshot of this project's settings and its constraints.

        The snapshot is taken now, so a fit or a sampling prepared here is not
        changed by a settings edit made before it runs (sampling prepares its
        run only when ``mcmc_sample()`` is called, possibly in another thread).
        """
        fitter = MultiFitter.for_experiments(experiments, constraints_factory_provider=_weak_constraints_provider(self))
        fitter.settings = copy.deepcopy(self._fit_settings)
        return fitter

    def fit_parameters(self, experiments: Optional[list] = None) -> List[Parameter]:
        """The parameters a fit of `experiments` varies, each once; nothing is prepared.

        The free parameters of their models, and the free parameters of other
        models those follow (see :meth:`prepare_fit`). Experiments without a
        model of this project contribute nothing.

        Parameters
        ----------
        experiments : list, optional
            By default, the loaded ones whose ``include_in_fit`` is set.
        """
        if experiments is None:
            experiments = [self._experiments[key] for key in sorted(self._experiments) if self._experiments[key].include_in_fit]
        models = []
        for experiment in experiments:
            index = self._model_index(experiment.model)
            if index is not None and not any(model is self._models[index] for model in models):
                models.append(self._models[index])
        parameters = {id(parameter): parameter for model in models for parameter in model.get_free_parameters()}
        for root in _free_roots(models):
            parameters.setdefault(id(root), root)
        return list(parameters.values())

    def record_fit(self, prepared: PreparedFit, results: Optional[list] = None, status: str = 'completed') -> FitRun:
        """Record the outcome of a run prepared by :meth:`prepare_fit`; see :attr:`last_fit`.

        A completed run's results also reach :attr:`fitter`'s goodness-of-fit
        properties. A failed or cancelled one is recorded without statistics.
        """
        completed = status == 'completed' and bool(results)
        metrics = prepared.finalize(results) if completed else None
        # The run's own settings; the project's may have changed since it was prepared.
        settings = prepared.settings or self._fit_settings
        # Experiments may have been removed since too: the keys are those of now.
        keys = {id(experiment): key for key, experiment in self._experiments.items()}
        inputs = [
            (keys.get(id(experiment)), experiment.name, None if channel is None else channel.value)
            for experiment, channel in zip(prepared.experiments, prepared.channels)
        ]
        self._last_fit = FitRun.from_results(prepared, results, status, settings.minimizer.name, metrics, inputs)
        if completed:
            self.fitter.record_fit_results(results, metrics)
        return self._last_fit

    @property
    def last_fit(self) -> Optional[FitRun]:
        """The record of the latest fit recorded with :meth:`record_fit`, or None."""
        return self._last_fit

    # ----- structural parameter paths -----

    def _walk_parameters(self):
        """Yield ``(structural path, parameter)`` for every parameter under the models;
        a parameter of an object shared by several models at its first path only."""
        yield from paths.walk(self._models, ['models'])

    def parameter_path(self, parameter) -> Optional[str]:
        """Structural path of `parameter` within this project, e.g.
        ``models/0/sample/1/layers/0/thickness``.

        Paths are stable across save/load (unlike unique names, which are
        regenerated) and are the way inequality constraints reference
        parameters. Returns ``None`` when the parameter is not reachable
        from the project's models.
        """
        return next((path for path, candidate in self._walk_parameters() if candidate is parameter), None)

    def resolve_parameter_path(self, path: str):
        """Return the parameter at a structural `path` (see :meth:`parameter_path`)."""
        tokens = [t for t in str(path).split('/') if t != '']
        if not tokens or tokens[0] != 'models':
            raise KeyError(f"Parameter path must start with 'models': '{path}'.")
        obj = self._models
        for token in tokens[1:]:
            if token.lstrip('-').isdigit():
                try:
                    obj = obj[int(token)]
                except (IndexError, KeyError, TypeError):
                    raise KeyError(f"Parameter path '{path}': index {token} is out of range.") from None
            else:
                if token.startswith('_') or not hasattr(obj, token):
                    raise KeyError(f"Parameter path '{path}': unknown attribute '{token}'.")
                obj = getattr(obj, token)
        if not isinstance(obj, DescriptorNumberType):
            raise KeyError(f"Parameter path '{path}' does not point to a parameter.")
        return obj

    # ----- inequality constraints -----

    @property
    def inequality_constraints(self) -> List[InequalitySpec]:
        """The project's inequality constraints (a copy of the list)."""
        return list(self._inequality_constraints)

    def add_inequality_constraint(self, spec: InequalitySpec, validate: bool = True) -> InequalitySpec:
        """Register an inequality constraint; returns it.

        With ``validate`` the paths are resolved and the units of both sides
        compared, raising ``KeyError``/``ValueError`` on problems.
        """
        if not isinstance(spec, InequalitySpec):
            raise TypeError('spec must be an InequalitySpec')
        if validate:
            check_units(spec, self.resolve_parameter_path)
        self._inequality_constraints.append(spec)
        return spec

    def remove_inequality_constraint(self, which: Union[int, str, InequalitySpec]) -> None:
        """Remove a constraint by index, by name or by identity.

        A name removes **every** spec carrying that name; use the index or
        the spec object to remove a single one when names are shared.
        """
        if isinstance(which, InequalitySpec):
            self._inequality_constraints = [s for s in self._inequality_constraints if s is not which]
            return
        if isinstance(which, str):
            matches = [s for s in self._inequality_constraints if s.name == which]
            if not matches:
                raise KeyError(f"No inequality constraint named '{which}'.")
            for spec in matches:
                self._inequality_constraints.remove(spec)
            return
        del self._inequality_constraints[int(which)]

    def clear_inequality_constraints(self) -> None:
        self._inequality_constraints = []

    def evaluate_inequality_constraints(self) -> List[InequalityEvaluation]:
        """Evaluate every constraint (enabled or not) at the current values."""
        return [evaluate_spec(spec, self.resolve_parameter_path) for spec in self._inequality_constraints]

    def violated_inequality_constraints(self) -> List[InequalitySpec]:
        """Enabled constraints that the *current* parameter values violate.

        A fit started from an infeasible point begins on the BUMPS penalty
        plateau; callers should refuse or warn before launching.
        """
        violated = []
        for spec in self._inequality_constraints:
            if spec.enabled and not evaluate_spec(spec, self.resolve_parameter_path).satisfied:
                violated.append(spec)
        return violated

    def build_constraints_factory(self):
        """``constraints_factory`` hook for the enabled inequality constraints, or ``None``."""
        return build_constraints_factory(self._inequality_constraints, self.resolve_parameter_path)

    # ----- equality constraints (parameter dependencies) -----

    @staticmethod
    def _is_user_constrained(parameter) -> bool:
        """Whether `parameter` carries a constraint this project should persist.

        Both halves are needed: a constraint removed with the raw
        ``make_independent()`` leaves the marker behind, and re-applying it on
        load would resurrect what the user removed.
        """
        return getattr(parameter, USER_CONSTRAINT_FLAG, False) and not parameter.independent

    def _constraint_candidates(self) -> List[Parameter]:
        """Every parameter the project owns, model-reachable or not.

        Deliberately wider than :attr:`parameters`: a constraint on a material
        that no model uses cannot be addressed by a structural path, and has to
        be seen here so that :meth:`_constraint_record` can refuse it out loud
        rather than let it disappear at save time.
        """
        candidates = list(self.parameters)
        seen = {id(parameter) for parameter in candidates}
        if self._materials is not None:
            for parameter in self._materials.get_all_parameters():
                if id(parameter) not in seen:
                    seen.add(id(parameter))
                    candidates.append(parameter)
        return candidates

    def _user_constraints(self) -> List[dict]:
        """Records describing the constraints created via :mod:`easyreflectometry.constraints`.

        Parameters are addressed by structural path rather than by EasyScience
        serializer id: an id is minted lazily when a parameter first gains an
        observer and deleted again when it loses its last one, so it is not a
        durable handle.

        A parameter is recorded only when it is both marked *and* still
        dependent. A constraint removed with the raw ``make_independent()``
        leaves the marker behind, and re-applying that on load would resurrect
        something the user removed. Internal constraints carry no marker at all
        — their owning class rebuilds them in its own ``from_dict``.

        Raises
        ------
        ValueError
            If a constrained parameter, or a live parameter it depends on, is
            not reachable from the models, so no path can address it.
        """
        # Which parameters are constrained is decided from `_constraint_candidates`,
        # which enumerates them without walking properties. The structural walk has
        # to walk properties and leaves reference cycles behind (delaying collection
        # of the project and its unique names), so it runs only when there is
        # something to record, and only to supply the paths. Driving both from
        # one list keeps a constraint from being dropped because the two
        # enumerations disagree.
        constrained = [parameter for parameter in self._constraint_candidates() if self._is_user_constrained(parameter)]
        if not constrained:
            return []
        paths = {id(parameter): path for path, parameter in self._walk_parameters()}
        return [self._constraint_record(parameter, paths) for parameter in constrained]

    @staticmethod
    def _constraint_record(parameter, paths: dict) -> dict:
        """One save record for `parameter`, addressing everything through `paths`."""

        def _addressable(target, described_as: str) -> str:
            path = paths.get(id(target))
            if path is None:
                raise ValueError(
                    f"Cannot save the constraint on '{parameter.name}': {described_as} is not "
                    "reachable from the project's models. Only parameters that belong to a "
                    'model can take part in a saved constraint.'
                )
            return path

        dependencies = {}
        for alias, dependency in parameter._dependency_map.items():
            if isinstance(dependency, Parameter):
                # Embedding a live parameter by value would silently turn a
                # dependency into a frozen constant on load.
                dependencies[alias] = {'path': _addressable(dependency, f"its dependency '{dependency.name}'")}
            else:
                # An object-less constant built for the expression (the explicit
                # total of `constrain_to_sum`); nothing else serializes it, so it
                # is embedded here.
                dependencies[alias] = {
                    'name': dependency.name,
                    'value': float(dependency.value),
                    'unit': str(dependency.unit),
                }
        return {
            'target': _addressable(parameter, 'the parameter itself'),
            'expression': parameter._clean_dependency_string,
            'dependencies': dependencies,
        }

    def _sum_partner_max_backups(self) -> Dict[str, float]:
        """``{structural path: original max}`` for every parameter whose maximum
        :func:`easyreflectometry.constraints.clamp_sum_partners` narrowed.

        The backups live as an attribute on the parameters, which no other
        serialization records; without this, removing a sum constraint after a
        save/load could never widen the bounds back.
        """
        flagged = [parameter for parameter in self.parameters if hasattr(parameter, SUM_PARTNER_MAX_BACKUP)]
        if not flagged:
            return {}
        paths = {id(parameter): path for path, parameter in self._walk_parameters()}
        backups: Dict[str, float] = {}
        for parameter in flagged:
            path = paths.get(id(parameter))
            if path is not None:
                backups[path] = float(getattr(parameter, SUM_PARTNER_MAX_BACKUP))
        return backups

    def _restore_sum_partner_max_backups(self, backups: Dict[str, float]) -> None:
        """Re-attach the backups written by :meth:`_sum_partner_max_backups`."""
        for path, previous in (backups or {}).items():
            try:
                parameter = self.resolve_parameter_path(path)
            except KeyError:
                logger.warning('Cannot restore the bound backup for %r: the parameter no longer exists.', path)
                continue
            setattr(parameter, SUM_PARTNER_MAX_BACKUP, float(previous))

    def _restore_user_constraints(self, records: List[dict]) -> None:
        """Re-apply the constraint records written by :meth:`_user_constraints`.

        Records are applied in the order they were written. A chain
        (``a`` follows ``b`` follows ``c``) resolves whichever order it is
        restored in, because re-constraining a parameter propagates the new
        value to anything already following it.
        """
        for record in records:
            dependencies = {}
            for alias, reference in record['dependencies'].items():
                if 'path' not in reference:
                    dependencies[alias] = DescriptorNumberType(**reference)
                    continue
                try:
                    dependencies[alias] = self.resolve_parameter_path(reference['path'])
                except KeyError as error:
                    raise KeyError(
                        f'Cannot restore the constraint on {record["target"]!r}: its dependency '
                        f'{reference["path"]!r} does not exist in this project.'
                    ) from error
            constrain(self.resolve_parameter_path(record['target']), record['expression'], **dependencies)

    @property
    def calculator(self) -> str:
        """Calculator function."""
        return self._calculator.current_interface_name

    @calculator.setter
    def calculator(self, calculator: str) -> None:
        """Calculator function."""
        if calculator == self._calculator.current_interface_name:
            return

        self._calculator.switch(calculator)
        self._calculator.reset_storage()

        for model in self._models:
            model.generate_bindings()
        self._sync_parameter_states()

        # The cached fitter holds fit functions bound to the previous backend.
        self._invalidate_fitter()

    @property
    def calculator_supports_magnetism(self) -> bool:
        """Whether the active calculator can model magnetic samples.

        The GUI uses this to gate magnetism-related controls (e.g. when the
        refnx or bornagain backend is selected).
        """
        return self._calculator().supports_magnetism

    @property
    def calculators_supporting_magnetism(self) -> List[str]:
        """Names of the available calculators that can model magnetic samples.

        Lets an application offer the switch a magnetic sample needs ("this
        requires refl1d — change to it?") instead of only reporting that the
        current calculator cannot do it. The active calculator is not touched.
        """
        supporting = []
        for name in self._calculator.available_interfaces:
            calculator = next(
                (candidate for candidate in CalculatorBase._calculators if candidate.name == name),
                None,
            )
            if calculator is not None and calculator().supports_magnetism:
                supporting.append(name)
        return supporting

    @property
    def models_have_magnetism(self) -> bool:
        """Whether any model in the project carries a magnetic layer.

        A calculator without magnetism cannot be selected while this holds — the
        binding it would have to build does not exist.
        """
        return any(model.has_magnetism for model in self._models)

    @property
    def minimizer(self) -> AvailableMinimizers:
        """The selected minimizer; an alias for ``fit_settings.minimizer``."""
        return self._fit_settings.minimizer

    @minimizer.setter
    def minimizer(self, minimizer: AvailableMinimizers) -> None:
        """Select the minimizer; it applies to the next fit."""
        if not isinstance(minimizer, AvailableMinimizers):
            raise ValueError(f'minimizer must be an AvailableMinimizers member, got {minimizer!r}.')
        previous = self._fit_settings.minimizer
        self._fit_settings.minimizer = minimizer
        logger.info('Minimizer changed from %s to %s', previous.name, self._fit_settings.minimizer.name)

    @property
    def experiments(self) -> Dict[int, Union[DataSet1D, PolarizedDataSet]]:
        """Experiments function."""
        return self._experiments

    @experiments.setter
    def experiments(self, experiments: Dict[int, Union[DataSet1D, PolarizedDataSet]]) -> None:
        """Experiments function."""
        self._experiments = experiments

    def remove_experiment(self, index: int) -> Dict[int, int]:
        """Remove the experiment at `index`; the later ones move down a key.

        Keys stay ``0..n-1`` and equal to display positions, so a caller that
        keeps per-experiment state (a selection, visibility) remaps it with
        the returned table.

        Returns
        -------
        Dict[int, int]
            Old key to new key of every remaining experiment.

        Raises
        ------
        IndexError
            No experiment at `index`.
        """
        if index not in self._experiments:
            raise IndexError(f'No experiment at index {index}')
        remaining = [key for key in sorted(self._experiments) if key != index]
        remap = {old: new for new, old in enumerate(remaining)}
        self._experiments = {remap[key]: self._experiments[key] for key in remaining}
        current = self._current_experiment_index
        self._current_experiment_index = remap.get(current, min(current, len(remaining) - 1)) if remaining else 0
        self._with_experiments = bool(self._experiments)
        if self._last_fit is not None:
            self._last_fit = self._last_fit.remapped(remap)
        return remap

    def model_index_for_experiment(self, index: int) -> Optional[int]:
        """Index of the model the experiment at `index` is bound to, or None when it has none."""
        return self._model_index_for_experiment(self._experiments[index])

    def set_model_for_experiment(self, index: int, model_index: int) -> None:
        """Bind the experiment at `index` to the model at `model_index`."""
        self._experiments[index].model = self._models[model_index]

    def experiments_for_model(self, model_index: int) -> List[int]:
        """Keys of the experiments bound to the model at `model_index`."""
        model = self._models[model_index]
        return [key for key, experiment in sorted(self._experiments.items()) if experiment.model is model]

    # ----- contrasts -----

    def add_contrast(
        self,
        reference_index: int,
        name: str,
        substitutions: Optional[Sequence[Union[ReplaceMaterial, ReplaceFormula]]] = None,
    ) -> int:
        """Add a contrast of the model at `reference_index`; see :func:`easyreflectometry.contrasts.derive_contrast`.

        Replacement materials join the materials palette. Returns the index of the new model.
        """
        reference = self._models[reference_index]
        model = derive_contrast(reference, name=name, substitutions=substitutions)
        model.color = self._models.next_color()
        self._models.append(model)
        model.interface = self._calculator
        self._extend_palette_from_models()
        self._sync_parameter_states()
        self._contrasts.append((model, reference))
        self._invalidate_fitter()
        return len(self._models) - 1

    def contrast_reference(self, model_index: int) -> Optional[int]:
        """Index of the model the model at `model_index` was derived from, if it was and still exists."""
        model = self._models[model_index]
        return self._model_index(next((reference for derived, reference in self._contrasts if derived is model), None))

    def parameter_models(self, parameter: Parameter) -> List[int]:
        """Indices of the models `parameter` belongs to (several when an object is shared)."""
        return self._parameter_owners().get(id(parameter), [])

    def _parameter_owners(self) -> Dict[int, List[int]]:
        owners: Dict[int, List[int]] = {}
        for index, model in enumerate(self._models):
            for parameter in {id(p): p for p in model.get_all_parameters()}.values():
                owners.setdefault(id(parameter), []).append(index)
        return owners

    @property
    def links(self) -> List[LinkRecord]:
        """The links made by :meth:`apply_link` (a copy of the list)."""
        return list(self._links)

    def plan_link(self, follower_index: int, reference_index: int, materials: Optional[str] = None) -> LinkPlan:
        """What tying the model at `follower_index` to the one at `reference_index` would do; see
        :func:`easyreflectometry.contrasts.plan_link`. Nothing is changed."""
        owners = self._parameter_owners()
        return plan_link(
            self._models[follower_index],
            self._models[reference_index],
            owners=lambda parameter: owners.get(id(parameter), []),
            follower_index=follower_index,
            materials=materials,
        )

    def apply_link(self, plan: LinkPlan) -> LinkRecord:
        """Tie the rows of `plan` marked ``'tie'``, all or none.

        Raises
        ------
        ValueError
            A row is still ``'undecided'``, or the project changed since the plan
            was made so that a row can no longer be tied as planned (it was applied
            already, a model now shares the parameter, ...). Nothing is tied then.
        """
        undecided = [row.path for row in plan.rows if row.action == 'undecided']
        if undecided:
            raise ValueError(f'Decide whether to tie {", ".join(undecided)} before linking.')
        # The project may have changed since the plan was made (a model now shares a
        # parameter, the plan was applied already): plan again and compare.
        follower_index, reference_index = self._model_index(plan.follower), self._model_index(plan.reference)
        if follower_index is None or reference_index is None:
            raise ValueError('The plan refers to a model that is no longer in the project; plan the link again.')
        outdated = plan.outdated(self.plan_link(follower_index, reference_index))
        if outdated:
            raise ValueError(f'The plan is out of date at {", ".join(outdated)}; plan the link again.')
        pairs = []
        try:
            for row in plan.rows:
                if row.action == 'tie':
                    state = snapshot(row.follower)
                    constrain_equal(row.follower, row.reference)
                    pairs.append((row.follower, row.reference, state))
        except Exception:
            for follower, _, state in pairs:
                unconstrain(follower)
                restore(follower, state)
            raise
        record = LinkRecord(plan.follower, plan.reference, pairs)
        self._links.append(record)
        return record

    def unlink(self, record: LinkRecord, restore_state: bool = True) -> List[int]:
        """Remove the ties `record` made. Each follower gets back its value, bounds and
        fixed state from before the link (`restore_state`), or keeps its current ones.
        Returns the indices of the models whose parameters were freed."""
        freed = []
        for follower, reference, state in record.pairs:
            if follows(follower, reference):
                unconstrain(follower)
                if restore_state:
                    restore(follower, state)
                freed.append(follower)
        self._links = [link for link in self._links if link is not record]
        owners = self._parameter_owners()
        return sorted({index for parameter in freed for index in owners.get(id(parameter), [])})

    def detach(self, parameter: Parameter, model_index: Optional[int] = None) -> None:
        """Make `parameter` independent (in the model at `model_index` only, when it is shared).

        A tie is removed (with the state from before it, when a link made it).
        A parameter of an assembly shared with other models gets a copy of that
        assembly in the model at `model_index`, with every other parameter tied
        to the shared one, so only `parameter`'s counterpart is free.

        A tie on a shared parameter is one tie: removing it frees the parameter
        in every model sharing it, so that is done without a `model_index`.

        Raises
        ------
        ValueError
            The parameter is derived; `model_index` is not one of its models; it
            is shared and `model_index` is not given; it is shared and tied and
            `model_index` is given; or it belongs to a material the model shares
            (assign the layer another material instead).
        """
        owners = self.parameter_models(parameter)
        if model_index is not None and model_index not in owners:
            name = self._models[model_index].name if 0 <= model_index < len(self._models) else model_index
            raise ValueError(f"'{parameter.name}' is not part of '{name}'.")
        if not parameter.independent:
            if not getattr(parameter, USER_CONSTRAINT_FLAG, False):
                raise ValueError(f"'{parameter.name}' is derived from other parameters; it cannot be detached.")
            if len(owners) > 1 and model_index is not None:
                raise ValueError(
                    f"'{parameter.name}' is one parameter of {len(owners)} models and follows another one; "
                    'removing the tie frees it in all of them, so detach it without a model.'
                )
            self._untie(parameter)
            return
        if len(owners) < 2:
            return
        if model_index is None:
            raise ValueError(f"'{parameter.name}' is shared by several models; say which one to detach it in.")
        model = self._models[model_index]
        positions = [
            position
            for position, assembly in enumerate(model.sample)
            if any(p is parameter for p in assembly.get_all_parameters())
        ]
        if len(positions) != 1:
            raise ValueError(
                f"'{parameter.name}' is part of '{model.name}' at several positions ({positions}); "
                'give them their own assemblies first.'
            )
        position = positions[0]
        assembly = model.sample[position]
        kept = kept_children(assembly, {}, set())
        if any(parameter is p for material in kept.values() for p in material.get_all_parameters()):
            raise ValueError(f"'{parameter.name}' belongs to a shared material; give the layer its own material instead.")
        copy = references.rebuild(assembly, kept)
        twin = dict(paths.walk(copy, []))[next(path for path, p in paths.walk(assembly, []) if p is parameter)]
        tie_corresponding(copy, assembly, excluded={id(twin)})
        model.sample[position] = copy
        model.interface = self._calculator
        self._sync_parameter_states()
        self._invalidate_fitter()

    def _untie(self, parameter: Parameter) -> None:
        """Make `parameter` independent; when a link tied it, with its state from before the link."""
        unconstrain(parameter)
        for record in self._links:
            for pair in record.pairs:
                if pair[0] is parameter:
                    record.pairs.remove(pair)
                    restore(parameter, pair[2])
                    self._links = [link for link in self._links if link.pairs]
                    return

    def _extend_palette_from_models(self) -> None:
        """Add the models' materials the palette does not hold yet."""
        new_materials = [material for material in self._get_materials_in_models() if material not in self._materials]
        self._materials.extend(new_materials)

    @property
    def path_json(self):
        """Path json."""
        return self.path / 'project.json'

    def get_index_air(self) -> int:
        """Get index air."""
        if 'Air' not in [material.name for material in self._materials]:
            self._materials.add_material(Material(name='Air', sld=0.0, isld=0.0))
        return [material.name for material in self._materials].index('Air')

    def get_index_si(self) -> int:
        """Get index si."""
        if 'Si' not in [material.name for material in self._materials]:
            self._materials.add_material(Material(name='Si', sld=2.07, isld=0.0))
        return [material.name for material in self._materials].index('Si')

    def get_index_sio2(self) -> int:
        """Get index sio2."""
        if 'SiO2' not in [material.name for material in self._materials]:
            self._materials.add_material(Material(name='SiO2', sld=3.47, isld=0.0))
        return [material.name for material in self._materials].index('SiO2')

    def get_index_d2o(self) -> int:
        """Get index d2o."""
        if 'D2O' not in [material.name for material in self._materials]:
            self._materials.add_material(Material(name='D2O', sld=6.36, isld=0.0))
        return [material.name for material in self._materials].index('D2O')

    def load_orso_file(self, path: Union[Path, str]) -> None:
        """Load an ORSO file, creating a model from its ``sample.model`` (when present) and an experiment.

        .. deprecated::
            Use :meth:`load_new_experiment` (data) together with
            :meth:`set_sample_from_orso` /
            :func:`easyreflectometry.orso_utils.load_orso_model` (model)
            instead. This wrapper now routes through the same
            ``DataSet1D`` + title + resolution path as every other importer.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the ORSO file.
        """
        warnings.warn(
            'Project.load_orso_file is deprecated; use load_new_experiment (data) and '
            'set_sample_from_orso/load_orso_model (model) instead.',
            DeprecationWarning,
            stacklevel=2,
        )
        from easyreflectometry.orso_utils import _load_orso_any
        from easyreflectometry.orso_utils import load_orso_model

        orso_data = _load_orso_any(str(path))
        sample = load_orso_model(orso_data)
        if sample is not None:
            self.models = ModelCollection([Model(sample=sample, name=sample.name)])
        else:
            self.default_model()
        self.load_experiment_for_model_at_index(path, 0)

    def set_sample_from_orso(self, sample: Sample) -> None:
        """Replace the current project model collection with a single model built from an ORSO-parsed sample.

        This is a convenience helper for the ORSO import pipeline where a complete
        :class:`~easyreflectometry.sample.Sample` is constructed elsewhere.

        Parameters
        ----------
        sample : Sample
            Sample to set as the project's (single) model.

        Returns
        -------
        None
            ``None``.
        """
        model = Model(sample=sample)
        self.models = ModelCollection([model])

    def add_sample_from_orso(self, sample: Sample) -> None:
        """Add a new model with the given sample to the existing model collection.

        The created model is appended to :attr:`models`, its calculator interface is
        set to the project's current calculator, and any materials referenced in the
        sample are added to the project's material collection.

        After adding the model, :attr:`current_model_index` is updated to point to
        the newly added model.

        Parameters
        ----------
        sample : Sample
            Sample to add as a new model.

        Returns
        -------
        None
            ``None``.
        """
        if sample is None:
            raise ValueError('The ORSO file does not contain a valid sample model definition.')
        self._check_sample_calculable(sample)
        # Take the collection's next colour: a supplied model keeps its own
        # colour, and the Model default would give every loaded sample the
        # same first palette colour.
        model = Model(sample=sample, color=self._models.next_color())
        self.models.add_model(model)
        # Set interface after adding to collection
        model.interface = self._calculator
        # Extract materials from the new model and add to project materials
        self._materials.extend(self._get_materials_from_model(model))
        self._sync_parameter_states()
        # Switch to the newly added model so its data is visible in the UI
        self.current_model_index = len(self._models) - 1

    def replace_models_from_orso(self, sample: Sample) -> None:
        """Replace all models and materials with a single model from an ORSO sample.

        All existing models and their associated materials are removed. A new
        model is created from *sample*, assigned to the project's calculator,
        and the material collection is rebuilt from the new model only.

        Parameters
        ----------
        sample : Sample
            Sample to set as the project's only model.

        Returns
        -------
        None
            ``None``.
        """
        if sample is None:
            raise ValueError('The ORSO file does not contain a valid sample model definition.')
        self._check_sample_calculable(sample)
        model = Model(sample=sample)
        if sample.name:
            model.user_data['original_name'] = sample.name  # Store original name for reference
        self.models = ModelCollection([model])
        model.interface = self._calculator
        self._materials = self._get_materials_from_model(model)
        self.current_model_index = 0

    def _check_sample_calculable(self, sample: Sample) -> None:
        """Refuse a sample the current calculator cannot model, before the
        project is changed: binding a magnetic sample to a calculator without
        magnetism fails only after the model was already added or the old
        models replaced.

        Raises
        ------
        NotImplementedError
            The sample has magnetic layers and the calculator cannot model them.
        """
        magnetic = any(getattr(layer, 'magnetism', None) is not None for assembly in sample for layer in assembly.layers)
        if magnetic and not self.calculator_supports_magnetism:
            engines = ', '.join(self.calculators_supporting_magnetism) or 'none of the available engines'
            raise NotImplementedError(
                f'The sample has magnetic layers, which the {self.calculator} calculation engine cannot model; '
                f'switch to {engines} and load it again.'
            )

    def _get_materials_from_model(self, model: Model) -> 'MaterialCollection':
        """Get all materials from a single model's sample."""
        materials_in_model = MaterialCollection(populate_if_none=False)
        for assembly in model.sample:
            for layer in assembly.layers:
                if layer.material not in materials_in_model:
                    materials_in_model.append(layer.material)
        return materials_in_model

    def _apply_experiment_metadata(
        self,
        path: Union[Path, str],
        experiment: DataSet1D,
        fallback_name: str,
        data_group=None,
        data_key: Optional[str] = None,
    ) -> None:
        """Set experiment name from ORSO title and configure the resolution function.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the experiment data file.
        experiment : DataSet1D
            The loaded experiment dataset to configure.
        fallback_name : str
            Name to use when no ORSO title is available.
        data_group :
            Pre-loaded scipp DataGroup (avoids reloading the file). By default, None.
        data_key : Optional[str], optional
            Specific dataset key to use for title extraction (e.g. ``'R_1'``). By default, None.
        """
        # Prefer ORSO title when available (keeps UI descriptive)
        title = None
        try:
            if data_group is None:
                data_group = load_data_from_orso_file(str(path))
            if data_key is None:
                data_key = list(data_group['data'].keys())[0]
            title = extract_orso_title(data_group, data_key)
        except (KeyError, AttributeError, ValueError, IndexError):
            title = None

        if title:
            experiment.name = title
        elif not experiment.name or experiment.name == 'Series':
            experiment.name = fallback_name

    def _apply_resolution_function(
        self,
        experiment: DataSet1D,
        model: Model,
    ) -> None:
        """Set the resolution of *experiment*, and of *model*, from the experiment's variance data.

        Uses the measured per-point q-resolution (``Pointwise``) when the
        experiment carries q-variance data (``xe``, i.e. sQz²); otherwise
        falls back to the default 5% FWHM percentage resolution.

        The measured resolution is stored on the experiment itself, so every
        dataset is fitted and plotted with the resolution it was measured
        with even when several datasets share one model. The model's
        resolution is set as well: it is what simulations, the GUI's
        resolution field and datasets without a measured resolution use.

        Parameters
        ----------
        experiment : DataSet1D
            The experiment whose variance data drives the choice.
        model : Model
            The model whose resolution function is set.
        """
        measured = resolution_from_dataset(experiment)
        experiment.resolution_function = measured
        model.resolution_function = PercentageFwhm(5.0) if measured is None else measured

    @staticmethod
    def _auto_set_background(experiment: DataSet1D) -> None:
        """Set the model background to the minimum y-value of the experiment data."""
        if experiment.model is not None and len(experiment.y) > 0:
            experiment.model.background = max(np.min(experiment.y), 1e-10)

    def _model_in_use(self, model: Model, ignoring=None) -> bool:
        """Whether a loaded experiment other than `ignoring` is bound to `model`."""
        return any(experiment.model is model for experiment in self._experiments.values() if experiment is not ignoring)

    def _bind_loaded_dataset(self, experiment: DataSet1D, model: Model, replacing=None) -> None:
        """Bind a freshly loaded dataset to `model`.

        The dataset keeps the resolution it was measured with. The model takes
        its background and resolution from the dataset only while no other
        experiment uses it, so loading another contrast or angle onto a model
        leaves what its first dataset (or the user) set. A background set by
        hand before the first dataset is loaded is replaced by that dataset's.
        """
        configure_model = not self._model_in_use(model, ignoring=replacing)
        experiment.model = model
        if configure_model:
            self._auto_set_background(experiment)
            self._apply_resolution_function(experiment, model)
        else:
            experiment.resolution_function = resolution_from_dataset(experiment)

    def load_new_experiment(self, path: Union[Path, str], data_group=None) -> None:
        """Load new experiment.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the experiment data file.
        data_group :
            Pre-loaded scipp DataGroup for *path* (avoids re-parsing the
            file). By default, None.
        """
        if data_group is None:
            data_group = load_measurement_file(str(path))
        new_experiment = load_as_dataset(str(path), data_group=data_group)
        new_index = len(self._experiments)

        model_index = 0
        if new_index < len(self.models):
            model_index = new_index

        self._apply_experiment_metadata(path, new_experiment, f'Experiment {new_index}', data_group=data_group)
        self._bind_loaded_dataset(new_experiment, self.models[model_index])
        self._experiments[new_index] = new_experiment
        self._with_experiments = True

    def count_datasets_in_file(self, path: Union[Path, str]) -> int:
        """Return the number of datasets contained in the file at *path*.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the data file.

        Returns
        -------
        int
            Number of datasets found; 1 if a non-ORSO file cannot be
            introspected. A corrupt ORSO file (banner present but unparsable)
            raises instead of being silently miscounted.
        """
        try:
            data_group = load_measurement_file(str(path))
            return len(data_group['data'])
        except Exception:
            if is_orso_file(str(path)):
                raise
            return 1

    def load_all_experiments_from_file(self, path: Union[Path, str]) -> int:
        """Load all datasets from a file as separate experiments sharing the current model.

        For a multi-dataset ORSO file (e.g. a multi-angle measurement), each dataset is
        registered as an independent experiment.  All experiments share the model that is
        currently selected.  Single-dataset files go through
        :meth:`load_new_experiment`.  A corrupt ORSO file raises (no silent
        plain-text fallback).

        Parameters
        ----------
        path : Union[Path, str]
            Path to the data file.

        Returns
        -------
        int
            Number of experiments that were added.
        """
        data_group = load_measurement_file(str(path))

        data_keys = sorted(data_group['data'].keys())
        if len(data_keys) <= 1:
            self.load_new_experiment(path, data_group=data_group)
            return 1

        # Every dataset is built before any is added, so a failure adds none.
        new_experiments = []
        for offset, data_key in enumerate(data_keys):
            fallback_name = f'Experiment {len(self._experiments) + offset}'
            new_experiment = dataset_from_datagroup(data_group, data_key=data_key)
            new_experiment.name = fallback_name
            self._apply_experiment_metadata(path, new_experiment, fallback_name, data_group=data_group, data_key=data_key)
            new_experiments.append(new_experiment)

        model = self.models[self._current_model_index]
        for new_experiment in new_experiments:
            self._bind_loaded_dataset(new_experiment, model)
            self._experiments[len(self._experiments)] = new_experiment
        self._with_experiments = True
        return len(data_keys)

    def _load_single_dataset(self, path: Union[Path, str]) -> DataSet1D:
        """Load a file that holds exactly one dataset, keeping its ORSO title as name."""
        data_group = load_measurement_file(str(path))
        if len(data_group['data']) > 1:
            raise ValueError(
                f"File '{path}' contains multiple datasets; a contrast is combined from single-dataset files "
                '(use load_all_experiments_from_file to load each dataset of this file as its own experiment).'
            )
        dataset = load_as_dataset(str(path), data_group=data_group)
        self._apply_experiment_metadata(path, dataset, Path(path).name, data_group=data_group)
        return dataset

    def load_experiment_from_files(
        self,
        paths: List[Union[Path, str]],
        model_index: Optional[int] = None,
        name: Optional[str] = None,
    ) -> int:
        """Load several files measured on one contrast as a single experiment.

        A contrast is often measured as several curves -- one per incident
        angle or wavelength band -- each with its own q-resolution. The files
        are concatenated (sorted by q) into one experiment whose every point
        keeps the resolution it was measured with, so the fit smears each
        point correctly. See :func:`easyreflectometry.data.merge_datasets`.

        Parameters
        ----------
        paths : List[Union[Path, str]]
            The files, one dataset each.
        model_index : Optional[int], optional
            Index of the model the experiment belongs to. By default, the
            current model.
        name : Optional[str], optional
            Name of the experiment. By default, the ORSO title of the first
            file, or ``'Experiment N'``.

        Returns
        -------
        int
            Index of the newly loaded experiment.
        """
        if not paths:
            raise ValueError('At least one file is required.')
        if model_index is None:
            model_index = self._current_model_index
        model = self.models[model_index]
        new_index = len(self._experiments)

        datasets = [self._load_single_dataset(path) for path in paths]
        # 'Series' is the DataSet1D default that _apply_experiment_metadata
        # replaces by the ORSO title of the first file, or else the fallback.
        experiment = merge_datasets(
            datasets, name='Series' if name is None else name, fill_resolution=self._fill_resolution_for(model)
        )
        if name is None:
            self._apply_experiment_metadata(paths[0], experiment, f'Experiment {new_index}')

        # The merged resolution is kept as merge_datasets built it from the
        # inputs' own resolutions; only a model no other experiment uses follows it.
        configure_model = not self._model_in_use(model)
        experiment.model = model
        if configure_model:
            self._auto_set_background(experiment)
            model.resolution_function = (
                PercentageFwhm(5.0) if experiment.resolution_function is None else experiment.resolution_function
            )
        self._experiments[new_index] = experiment
        self._with_experiments = True
        return new_index

    def append_to_experiment_at_index(self, index: int, path: Union[Path, str]) -> None:
        """Add another measured curve of the same contrast to an existing experiment.

        The file's points are merged into the experiment at *index* (sorted by
        q), each keeping its own resolution: the file's measured one, and the
        experiment's ``resolution_function`` as currently assigned. The
        experiment's name and model are kept.

        Parameters
        ----------
        index : int
            Index of the experiment to extend.
        path : Union[Path, str]
            The file to add, one dataset.

        Raises
        ------
        IndexError
            No experiment is loaded at *index*.
        ValueError
            The experiment is polarized (extend its channels one file at a
            time instead), or the file holds several datasets.
        """
        if index not in self._experiments:
            raise IndexError(f'No experiment at index {index}')
        experiment = self._experiments[index]
        if isinstance(experiment, PolarizedDataSet):
            raise ValueError('Cannot append a file to a polarized experiment; its channels are separate datasets.')
        model = experiment.model
        addition = self._load_single_dataset(path)
        merged = merge_datasets([experiment, addition], name=experiment.name, fill_resolution=self._fill_resolution_for(model))
        merged.model = model
        merged.include_in_fit = experiment.include_in_fit
        # merge_datasets kept the experiment's own resolution (measured or
        # explicitly assigned) for its points; the model follows the merged
        # one unless another experiment uses it. Without any, the experiment
        # keeps using the model's as it is.
        if model is not None and merged.resolution_function is not None and not self._model_in_use(model, ignoring=experiment):
            model.resolution_function = merged.resolution_function
        self._experiments[index] = merged

    @staticmethod
    def _fill_resolution_for(model: Optional[Model]) -> Optional[ResolutionFunction]:
        """The resolution to assume for merged points that carry none: the model's, unless that is itself measured."""
        if model is None:
            return None
        resolution_function = model.resolution_function
        if isinstance(resolution_function, PercentageFwhm):
            return resolution_function
        return None

    def suggest_polarized_channel_assignment(self, paths: List[Union[Path, str]]) -> Dict[str, Optional[PolarizationChannel]]:
        """Suggest a spin-channel assignment for a set of data files.

        For each file, the ORSO header polarization is used when present, falling
        back to filename heuristics ('_uu'/'_up' → pp, '_dd'/'_down' → mm, ...).
        Files that cannot be identified map to None; a GUI should present the
        result as an editable file → channel table.

        Parameters
        ----------
        paths : List[Union[Path, str]]
            Paths of the per-channel data files.

        Returns
        -------
        Dict[str, Optional[PolarizationChannel]]
            Detected channel (or None) per path.
        """
        return {str(path): detect_polarization_channel(str(path)) for path in paths}

    def load_polarized_experiment(
        self,
        paths: Dict[Union[PolarizationChannel, str], Union[Path, str]],
        model_index: Optional[int] = None,
    ) -> int:
        """Load a polarized experiment from one data file per spin channel.

        The channel datasets form a single :class:`PolarizedDataSet` experiment
        sharing one model. Two-channel NSF-only experiments simply pass 'pp' and
        'mm' entries; spin-flip channels are optional.

        Parameters
        ----------
        paths : Dict[Union[PolarizationChannel, str], Union[Path, str]]
            Explicit channel → file assignment (e.g. from the GUI import dialog,
            pre-filled via :meth:`suggest_polarized_channel_assignment`).
        model_index : Optional[int], optional
            Index of the model the experiment belongs to. By default, the
            current model.

        Returns
        -------
        int
            Index of the newly loaded experiment, so a caller can make it
            current.
        """
        paths = {PolarizationChannel(channel): path for channel, path in paths.items()}
        channels = {}
        title_data_group = None
        for channel, path in paths.items():
            # Parse each file once: dataset count, data, and title all come
            # from the same DataGroup (previously up to 3-4 parses per file).
            data_group = load_measurement_file(str(path))
            # One file per channel means one dataset per file; a multi-dataset ORSO
            # file has no defined channel-to-dataset assignment here.
            if len(data_group['data']) > 1:
                raise ValueError(
                    f"File '{path}' contains multiple datasets; use load_polarized_experiment_from_file "
                    f'for a single multi-dataset file, or export the {channel.value} channel to its own file.'
                )
            dataset = load_as_dataset(str(path), data_group=data_group)
            # Keep the source file visible per channel (file → channel provenance).
            dataset.name = f'{channel.value}: {Path(path).name}'
            channels[channel] = dataset
            if title_data_group is None:
                title_data_group = data_group

        first_path = next(iter(paths.values()))
        return self._register_polarized_experiment(channels, model_index, str(first_path), title_data_group)

    def load_polarized_experiment_from_file(
        self,
        path: Union[Path, str],
        model_index: Optional[int] = None,
    ) -> int:
        """Load a polarized experiment from a single multi-dataset ORSO file.

        Multi-dataset packing is the ORSO format's intended way to store spin
        states: each ``data_set:`` block is classified by its **own** header
        (``instrument_settings.polarization``), so per-dataset overrides are
        honoured. Only the fully-analysed cross-sections ``pp/pm/mp/mm`` are
        mapped; ``po/mo/op/om/unpolarized/vector`` are not coerced.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the multi-dataset ORSO file.
        model_index : Optional[int], optional
            Index of the model the experiment belongs to. By default, the
            current model.

        Returns
        -------
        int
            Index of the newly loaded experiment.

        Raises
        ------
        ValueError :
            If any dataset lacks a mappable polarization header, or two
            datasets declare the same channel. Such files cannot be classified
            unambiguously; use :meth:`load_all_experiments_from_file` (e.g. for
            multi-angle files) or per-channel files instead.
        """
        from easyreflectometry.orso_utils import _load_orso_any
        from easyreflectometry.orso_utils import _orso_dataset_key
        from easyreflectometry.orso_utils import load_orso_data

        orso_data = _load_orso_any(str(path))
        classified = detect_polarization_channels_per_dataset(orso_data)

        channels = {}
        for i, (declared, channel) in enumerate(classified):
            label = _orso_dataset_key(orso_data[i], i)
            if channel is None:
                reason = 'declares no mappable spin channel' if declared else 'declares no polarization'
                raise ValueError(
                    f"Dataset '{label}' in '{path}' {reason} "
                    f'(only pp/pm/mp/mm are mapped; po/mo/op/om/unpolarized are not coerced). '
                    f'Use load_all_experiments_from_file for non-polarized multi-dataset files, '
                    f'or assign channels explicitly via load_polarized_experiment.'
                )
            if channel in channels:
                raise ValueError(
                    f"Duplicate spin channel '{channel.value}' in '{path}': more than one dataset "
                    f'declares it. Assign channels explicitly via load_polarized_experiment.'
                )
            channels[channel] = label

        data_group = load_orso_data(orso_data)
        channel_datasets = {}
        for channel, label in channels.items():
            dataset = dataset_from_datagroup(data_group, data_key=f'R_{label}')
            dataset.name = f'{channel.value}: {label}'
            channel_datasets[channel] = dataset

        return self._register_polarized_experiment(channel_datasets, model_index, str(path), data_group)

    def _register_polarized_experiment(self, channels, model_index, title_path, title_data_group) -> int:
        """Shared tail of the polarized loaders: build, name, and register the experiment.

        The background follows the first (in canonical order) channel. Every
        channel keeps the resolution it was measured with (its own
        ``Pointwise`` when the file carries sQz), which the fit and the
        per-channel model curves use; the model's resolution follows the
        first channel for simulations and the GUI's resolution field.

        Parameters
        ----------
        channels :
            Channel → DataSet1D mapping.
        model_index :
            Index of the model, or None for the current one.
        title_path :
            Path used for the fallback experiment title lookup.
        title_data_group :
            Pre-loaded DataGroup for the title lookup (avoids re-parsing).

        Returns
        -------
        int
            Index of the newly loaded experiment.
        """
        new_index = len(self._experiments)
        if model_index is None:
            model_index = self._current_model_index
        model = self.models[model_index]

        experiment = PolarizedDataSet(
            name=f'Polarized experiment {new_index}',
            channels=channels,
            model=model,
        )
        # Name from the ORSO title of the first file, when available.
        self._apply_experiment_metadata(
            title_path, experiment, f'Polarized experiment {new_index}', data_group=title_data_group
        )

        configure_model = not self._model_in_use(model)
        for channel in experiment.available_channels:
            experiment[channel].resolution_function = resolution_from_dataset(experiment[channel])
        if configure_model:
            # The model follows the first channel, see `_bind_loaded_dataset`.
            first_dataset = experiment[experiment.available_channels[0]]
            self._auto_set_background(first_dataset)
            self._apply_resolution_function(first_dataset, model)

        self._experiments[new_index] = experiment
        self._with_experiments = True
        return new_index

    def save_experiment_as_orso(self, path: Union[Path, str], index: Optional[int] = None) -> None:
        """Write an experiment to an ORSO file (`.ort` text, or `.orb` binary).

        The experiment's model (when set) is exported as the ORSO
        ``sample.model`` (slab representation); data columns are written as
        sigma. A polarized experiment becomes one file with one ``data_set:``
        block per spin channel.

        Parameters
        ----------
        path : Union[Path, str]
            Destination path; a ``.orb`` extension selects the binary format.
        index : Optional[int], optional
            Index of the experiment to save. By default, the current one.
        """
        if index is None:
            index = self._current_experiment_index
        experiment = self._experiments[index]
        save_orso_experiment(experiment, str(path), model=experiment.model)

    def load_experiment_for_model_at_index(self, path: Union[Path, str], index: Optional[int] = 0) -> None:
        """Load experiment for model at index."""
        data_group = load_measurement_file(str(path))
        experiment = load_as_dataset(str(path), data_group=data_group)

        self._apply_experiment_metadata(path, experiment, f'Experiment {index}', data_group=data_group)
        self._bind_loaded_dataset(experiment, self.models[index], replacing=self._experiments.get(index))
        self._experiments[index] = experiment
        self._with_experiments = True

    def _bind_calculator(self, model) -> None:
        """Bind the project's calculator to a model unless it already is.

        Reassigning ``model.interface`` re-propagates the interface over the
        whole sample tree — an expensive rebuild. The plot getters run on every
        chart refresh, so an already-bound model must be left alone; engine
        switches go through the ``calculator`` setter, which regenerates the
        bindings itself.
        """
        if model.interface is not self._calculator:
            model.interface = self._calculator

    def sld_data_for_model_at_index(self, index: int = 0) -> DataSet1D:
        """Sld data for model at index."""
        self._bind_calculator(self.models[index])
        sld = self.models[index].interface().sld_profile(self._models[index].unique_name)
        return DataSet1D(
            name=f'SLD for Model {index}',
            x=sld[0],
            y=sld[1],
        )

    def model_has_magnetism_at_index(self, index: int = 0) -> bool:
        """Whether the model at index carries magnetism (False when there is no such model)."""
        try:
            return bool(self.models[index].has_magnetism)
        except (IndexError, AttributeError):
            return False

    def magnetic_sld_data_for_model_at_index(self, index: int = 0) -> Dict[str, Union[DataSet1D, float]]:
        """Nuclear and magnetic depth profiles of a magnetic model.

        Parameters
        ----------
        index : int
            Index of the model.

        Returns
        -------
        Dict[str, DataSet1D]
            Profiles versus depth z, keyed:

            - ``'sld'``: nuclear ρ(z), the same curve as
              :meth:`sld_data_for_model_at_index`;
            - ``'rho_m'``: magnetic SLD ρM(z);
            - ``'theta_m'``: in-plane moment angle θM(z) in degrees, restricted
              to the depths that carry a moment — the angle of a zero-length
              vector is meaningless, so points below
              :data:`MAGNETIC_MOMENT_FLOOR_FRACTION` of the largest ρM are left
              out instead of drawing an arbitrary angle through vacuum;
            - ``'rho_m_perp'``: magnitude of the magnetic SLD transverse to the
              field, |ρM⊥|(z);
            - ``'spin_up'`` / ``'spin_down'``: nuclear plus / minus the
              longitudinal magnetic SLD, ρ(z) ± ρM∥(z), with ρM∥ the component
              along the field (see
              :func:`~easyreflectometry.special.calculations.field_frame_components`).
              These are the potentials of the two spin states only when the
              moment is collinear with the field and the applied field is zero;
              with a transverse moment the eigenmodes split by ±|ρM| instead.
              The applied field is not included.

            plus the floats ``'guide_field_angle'`` and ``'applied_field'`` of the model.

        Raises
        ------
        ValueError
            The model has no magnetic layer, so there is no magnetic profile.
        NotImplementedError
            The active calculator cannot model magnetism.
        """
        model = self.models[index]
        if not model.has_magnetism:
            raise ValueError(
                f'Model {index} has no magnetic layer; there is no magnetic SLD profile to show. '
                'Attach magnetism to a layer first.'
            )
        self._bind_calculator(model)
        z, sld, rho_m, theta_m = model.interface().magnetic_sld_profile(model.unique_name)
        z = np.asarray(z, dtype=float)
        sld = np.asarray(sld, dtype=float)
        rho_m = np.asarray(rho_m, dtype=float)
        theta_m = np.asarray(theta_m, dtype=float)
        guide_field_angle = float(model.guide_field_angle.value)
        parallel, perpendicular = field_frame_components(rho_m, theta_m, guide_field_angle)
        # Only report the angle where there is a moment to have an angle, and
        # make it continuous along z: 359° followed by 1° is a 2° turn, but a
        # plotted line through the wrapped values sweeps the whole circle.
        magnitude = np.abs(rho_m)
        has_moment = magnitude > MAGNETIC_MOMENT_FLOOR_FRACTION * magnitude.max(initial=0.0)
        theta_display = _unwrapped_angle(theta_m, has_moment)
        return {
            'sld': DataSet1D(name=f'SLD for Model {index}', x=z, y=sld),
            'rho_m': DataSet1D(name=f'Magnetic SLD for Model {index}', x=z, y=rho_m),
            'theta_m': DataSet1D(name=f'Moment angle for Model {index}', x=z[has_moment], y=theta_display[has_moment]),
            'rho_m_perp': DataSet1D(name=f'Transverse magnetic SLD for Model {index}', x=z, y=perpendicular),
            'spin_up': DataSet1D(name=f'Nuclear + longitudinal magnetic SLD for Model {index}', x=z, y=sld + parallel),
            'spin_down': DataSet1D(name=f'Nuclear - longitudinal magnetic SLD for Model {index}', x=z, y=sld - parallel),
            'guide_field_angle': guide_field_angle,
            'applied_field': float(model.applied_field.value),
        }

    def volume_fraction_data_for_model_at_index(
        self,
        index: int = 0,
        *,
        z: Optional[np.ndarray] = None,
        max_delta_z: Optional[float] = None,
        merge_equivalent: bool = True,
        groups: Optional[Mapping[str, Sequence[str]]] = None,
    ) -> Dict[str, DataSet1D]:
        """Volume fraction (occupancy) depth profiles of the components of a model.

        Built from the sample alone, so no calculator binding is needed. See
        :func:`easyreflectometry.sample.volume_fraction.volume_fraction_profile`
        for the depth convention and the caveats.

        Parameters
        ----------
        index : int
            Index of the model.
        z : np.ndarray, optional
            Depths (Å) to evaluate at; ``z = 0`` is the superphase / first
            layer interface. By default a 500-point grid spanning the sample,
            the same grid the refnx calculator uses for its SLD profile.
        max_delta_z : float, optional
            Maximum spacing of the default grid. Cannot be combined with ``z``.
        merge_equivalent : bool, optional
            Merge materials with equal name and SLD into one component. By default, True.
        groups : Mapping[str, Sequence[str]], optional
            Presentation groups forwarded to
            :meth:`~easyreflectometry.sample.VolumeFractionProfile.grouped`,
            e.g. ``{'Water': ['D2O', 'H2O']}``.

        Returns
        -------
        Dict[str, DataSet1D]
            One profile per component, keyed by its stable component key (or
            group label) in front-to-back order; the display label is in the
            dataset's ``name``. The ``y`` values sum to one at every ``x``.
        """
        model = self.models[index]
        profile = volume_fraction_profile(model.sample, z, max_delta_z=max_delta_z, merge_equivalent=merge_equivalent)
        if groups:
            profile = profile.grouped(groups)
        return {
            key: DataSet1D(
                name=f'{profile.labels[key]} volume fraction for Model {index}',
                x=profile.z,
                y=values,
                x_label='z (Å)',
                y_label='Volume fraction',
            )
            for key, values in profile.fractions.items()
        }

    def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D:
        """The unsmeared reflectivity of the model at `index`: the sample alone, no resolution.

        The zero-width resolution is bound to this one calculation; the
        model's own resolution, which is calculator state shared with a fit
        possibly running in another thread, is not touched.
        """
        return self.model_data_for_model_at_index(index, q_range, resolution_function=PercentageFwhm(0))

    def model_data_for_model_at_index(
        self,
        index: int = 0,
        q_range: Optional[np.array] = None,
        channel: Optional[Union[PolarizationChannel, str]] = None,
        resolution_function: Optional[ResolutionFunction] = None,
    ) -> DataSet1D:
        """Model data for model at index.

        Parameters
        ----------
        index : int
            Index of the model.
        q_range : Optional[np.array]
            Points to calculate at; the project q range by default.
        channel : Optional[Union[PolarizationChannel, str]]
            Spin cross-section to calculate. `None` (the default) gives the
            ordinary, channel-agnostic reflectivity. A channel requires a
            magnetic model (except 'pp', which falls back to the unpolarized
            calculation) and a calculator supporting magnetism, otherwise the
            calculator raises.
        resolution_function : Optional[ResolutionFunction]
            Resolution to smear with; the model's by default. Pass a dataset's
            own resolution to get the curve the fit compares that dataset to
            (see :meth:`model_data_for_experiment_at_index`).
        """
        if q_range is None:
            q_range = np.linspace(self.q_min, self.q_max, self.q_resolution)
        self._bind_calculator(self.models[index])
        interface = self.models[index].interface()
        unique_name = self._models[index].unique_name
        if channel is None:
            reflectivity = interface.reflectity_profile(q_range, unique_name, resolution_function=resolution_function)
            name = f'Reflectivity for Model {index}'
        else:
            channel = PolarizationChannel(channel)
            reflectivity = interface.reflectivity_profile_channel(
                q_range, unique_name, channel, resolution_function=resolution_function
            )
            name = f'Reflectivity ({channel.value}) for Model {index}'
        return DataSet1D(
            name=name,
            x=q_range,
            y=reflectivity,
        )

    def model_data_for_experiment_at_index(
        self,
        index: int = 0,
        channel: Optional[Union[PolarizationChannel, str]] = None,
        q_range: Optional[np.array] = None,
    ) -> DataSet1D:
        """The model curve an experiment is compared to: its model, at its q, with its resolution.

        Parameters
        ----------
        index : int
            Index of the experiment.
        channel : Optional[Union[PolarizationChannel, str]]
            Spin channel of a polarized experiment; required for one, not
            allowed for an unpolarized one.
        q_range : Optional[np.array]
            Points to calculate at; the experiment's own q by default.

        Raises
        ------
        IndexError
            No experiment is loaded at `index`.
        ValueError
            The experiment has no model, or `channel` does not match the
            experiment's kind.
        """
        experiment = self.experimental_data_for_model_at_index(index)
        if isinstance(experiment, PolarizedDataSet):
            if channel is None:
                raise ValueError(f'Experiment at index {index} is polarized; a channel is required.')
            dataset = experiment[channel]
        else:
            if channel is not None:
                raise ValueError(f'Experiment at index {index} is not polarized; it has no channel.')
            dataset = experiment
        model = experiment.model
        if model is None:
            raise ValueError(f'Experiment at index {index} has no model.')
        model_index = next(i for i, candidate in enumerate(self.models) if candidate is model)
        return self.model_data_for_model_at_index(
            model_index,
            q_range=np.asarray(dataset.x) if q_range is None else q_range,
            channel=channel,
            resolution_function=getattr(dataset, 'resolution_function', None),
        )

    def experimental_data_for_model_at_index(
        self,
        index: int = 0,
        channel: Optional[Union[PolarizationChannel, str]] = None,
    ) -> Union[DataSet1D, PolarizedDataSet]:
        """Experimental data for model at index.

        Parameters
        ----------
        index : int
            Index of the experiment.
        channel : Optional[Union[PolarizationChannel, str]]
            Spin channel of a polarized experiment. `None` (the default) keeps
            the historical behavior and returns the stored experiment as is: a
            `DataSet1D` for an unpolarized experiment, the whole
            `PolarizedDataSet` for a polarized one.

        Returns
        -------
        Union[DataSet1D, PolarizedDataSet]
            The experiment, or the `DataSet1D` of the requested channel.

        Raises
        ------
        IndexError
            No experiment is loaded at `index`.
        KeyError
            `channel` is a valid spin channel but was not measured.
        ValueError
            `channel` was given for an unpolarized experiment, or is not one of
            'pp', 'pm', 'mp', 'mm'.
        """
        if index not in self._experiments.keys():
            raise IndexError(f'No experiment data for model at index {index}')

        experiment = self._experiments[index]
        if channel is None:
            return experiment

        if not isinstance(experiment, PolarizedDataSet):
            raise ValueError(
                f"Experiment at index {index} is not polarized; it has no '{channel}' channel. "
                'Call without a channel argument to get its data.'
            )
        try:
            channel = PolarizationChannel(channel)
        except ValueError as exception:
            known = ', '.join(member.value for member in PolarizationChannel)
            raise ValueError(f"Unknown spin channel '{channel}'; expected one of {known}.") from exception
        if channel not in experiment:
            measured = ', '.join(member.value for member in experiment.available_channels)
            raise KeyError(f"Channel '{channel.value}' was not measured in experiment {index} (measured: {measured}).")
        return experiment[channel]

    def experiment_is_polarized_at_index(self, index: int = 0) -> bool:
        """Whether the experiment at index holds per-channel (polarized) data.

        Returns False when no experiment is loaded at `index`, so consumers can
        use it as a plain predicate.
        """
        return isinstance(self._experiments.get(index), PolarizedDataSet)

    def experiment_channels_at_index(self, index: int = 0) -> List[PolarizationChannel]:
        """Measured spin channels of the experiment at index ([] when unpolarized)."""
        experiment = self._experiments.get(index)
        if not isinstance(experiment, PolarizedDataSet):
            return []
        return experiment.available_channels

    def experiment_supports_spin_asymmetry_at_index(self, index: int = 0) -> bool:
        """Whether SA can be formed for the experiment at index.

        Spin asymmetry needs both non-spin-flip channels; an NSF-incomplete or
        spin-flip-only experiment has no SA. The two channel datasets must also
        be structurally usable (non-empty, matching lengths, a strictly ordered
        q grid) — an application asks this before offering a spin-asymmetry
        view, so a dataset that cannot be turned into one must not be advertised
        and then fail.
        """
        channels = self.experiment_channels_at_index(index)
        if not _has_both_nsf_channels(channels):
            return False
        experiment = self._experiments[index]
        try:
            for channel in (PolarizationChannel.PP, PolarizationChannel.MM):
                _ordered_channel_arrays(experiment[channel])
        except ValueError as exception:
            logger.warning('Experiment %s cannot produce a spin asymmetry: %s', index, exception)
            return False
        return True

    def spin_asymmetry_for_experiment_at_index(self, index: int = 0) -> Dict[str, object]:
        """Spin asymmetry SA = (R⁺⁺ − R⁻⁻) / (R⁺⁺ + R⁻⁻) of a polarized experiment.

        The measured SA is formed on the q grid of the pp channel. A mm channel
        measured on a different grid is linearly interpolated onto it — values
        with the usual weights, variances with the *squared* weights, which is
        the propagation rule for independent endpoints (the covariance this
        introduces between neighbouring SA points is not representable in
        `DataSet1D` and is discarded). Interpolation happens **only inside the q
        range both channels cover**: `np.interp` would otherwise clamp to the
        edge value and turn extrapolated points into fabricated measurements.

        Points are dropped when the denominator cannot carry a meaningful
        asymmetry:

        - it is not positive, or is lost to cancellation between the two
          channels (see :data:`SPIN_ASYMMETRY_CANCELLATION_FRACTION`) — this
          guard needs no uncertainties and is what keeps a background-subtracted
          tail from throwing ±10³ values onto the axis;
        - it is not above :data:`SPIN_ASYMMETRY_SIGNIFICANCE` times its own
          uncertainty, when the channels carry usable uncertainties;
        - the point itself is not usable (non-finite reflectivity, or a negative
          or non-finite variance).

        Parameters
        ----------
        index : int
            Index of the experiment.

        Returns
        -------
        Dict[str, object]
            - ``'measured'``: `DataSet1D` of SA versus q. As everywhere in this
              library, ``ye`` holds **variances**, not standard deviations.
            - ``'calculated'``: `DataSet1D` of the model SA on the same q
              points, or None when the model is not magnetic (an unpolarized
              model has SA ≡ 0, which is not worth drawing).
            - ``'masked_points'``: how many measured points were dropped for any
              of the reasons above.
            - ``'low_significance_points'``: of those, how many had a
              denominator below the significance threshold.
            - ``'small_denominator_points'``: of those, how many had a
              denominator that was non-positive or lost to cancellation.
            - ``'invalid_points'``: of those, how many carried a non-finite
              reflectivity or an unusable variance.
            - ``'out_of_overlap_points'``: number of pp points dropped because
              the mm channel does not cover their q.

        Raises
        ------
        IndexError
            No experiment is loaded at `index`.
        ValueError
            The experiment does not carry both pp and mm channels, or their
            datasets are not structurally usable.
        """
        if index not in self._experiments.keys():
            raise IndexError(f'No experiment data for model at index {index}')
        channels = self.experiment_channels_at_index(index)
        if not _has_both_nsf_channels(channels):
            raise ValueError(f'Experiment {index} does not have both non-spin-flip channels; spin asymmetry needs pp and mm.')

        experiment = self._experiments[index]
        q, r_pp, var_pp = _ordered_channel_arrays(experiment[PolarizationChannel.PP])
        q_mm, r_mm_source, var_mm_source = _ordered_channel_arrays(experiment[PolarizationChannel.MM])

        out_of_overlap = 0
        if q.shape == q_mm.shape and np.allclose(q, q_mm, rtol=1e-9, atol=0.0):
            # The usual case: both channels come from one instrument scan.
            # A tolerance keeps grids that only differ by float round-trips
            # (text files) on this path.
            r_mm, var_mm = r_mm_source, var_mm_source
        else:
            # Different grids: put mm on the pp grid, but only where mm has data
            # — np.interp clamps outside its range, which would silently invent
            # measurements at the edges.
            inside = (q >= q_mm.min()) & (q <= q_mm.max())
            out_of_overlap = int(np.count_nonzero(~inside))
            if out_of_overlap:
                logger.warning(
                    'Spin asymmetry of experiment %s: %s of %s pp points lie outside the mm q range '
                    '[%.5g, %.5g] and are dropped.',
                    index,
                    out_of_overlap,
                    q.size,
                    q_mm.min(),
                    q_mm.max(),
                )
            q, r_pp, var_pp = q[inside], r_pp[inside], var_pp[inside]
            r_mm, var_mm = _interpolate_with_variance(q, q_mm, r_mm_source, var_mm_source)

        asymmetry, variance, keep, reasons = _spin_asymmetry(r_pp, r_mm, var_pp, var_mm)
        measured = DataSet1D(
            name=f'Spin asymmetry for Experiment {index}',
            x=q[keep],
            y=asymmetry[keep],
            ye=variance[keep],
            x_label='q (1/angstrom)',
            y_label='Spin asymmetry',
        )

        calculated = None
        model_index = self._model_index_for_experiment(experiment)
        if model_index is not None and self.models[model_index].has_magnetism and measured.x.size > 0:
            calculated_pp = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='pp').y
            calculated_mm = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='mm').y
            calculated_asymmetry, _, _, _ = _spin_asymmetry(calculated_pp, calculated_mm)
            calculated = DataSet1D(
                name=f'Calculated spin asymmetry for Experiment {index}',
                x=measured.x,
                y=calculated_asymmetry,
                x_label='q (1/angstrom)',
                y_label='Spin asymmetry',
            )

        return {
            'measured': measured,
            'calculated': calculated,
            'masked_points': int(np.count_nonzero(~keep)),
            'out_of_overlap_points': out_of_overlap,
            **reasons,
        }

    def _model_index_for_experiment(self, experiment) -> Optional[int]:
        """Index of the model an experiment is bound to, or None."""
        return self._model_index(getattr(experiment, 'model', None))

    def default_model(self):
        """Default model."""
        self._replace_collection(MaterialCollection(interface=self._calculator), self._materials)

        layers = [
            Layer(
                material=self._materials[0],
                thickness=0.0,
                roughness=0.0,
                name='Vacuum Layer',
                interface=self._calculator,
            ),
            Layer(
                material=self._materials[1],
                thickness=100.0,
                roughness=3.0,
                name='D2O Layer',
                interface=self._calculator,
            ),
            Layer(
                material=self._materials[2],
                thickness=0.0,
                roughness=1.2,
                name='Si Layer',
                interface=self._calculator,
            ),
        ]
        assemblies = [
            Multilayer(layers[0], name='Superphase', interface=self._calculator),
            Multilayer(layers[1], name='D2O', interface=self._calculator),
            Multilayer(layers[2], name='Subphase', interface=self._calculator),
        ]
        sample = Sample(*assemblies, interface=self._calculator)
        model = Model(sample=sample, interface=self._calculator)
        model.is_default = True
        self.models = ModelCollection([model])

    def is_default_model(self, index: int) -> bool:
        """Check if the model at the given index is a default model.

        Parameters
        ----------
        index : int
            Index of the model to check.

        Returns
        -------
        bool
            True if the model was created as a default placeholder.
        """
        if index < 0 or index >= len(self._models):
            return False

        return self._models[index].is_default

    def plan_model_removal(self, index: int) -> 'ModelRemoval':
        """What removing the model at `index` would affect; nothing is changed.

        Raises
        ------
        IndexError :
            If the index is out of range.
        """
        if index < 0 or index >= len(self._models):
            raise IndexError(f'Model index {index} out of range')
        model = self._models[index]
        survivors = [other for other in self._models if other is not model]
        surviving = {id(parameter) for other in survivors for parameter in other.get_all_parameters()}
        # Parameters only this model owns; one shared with a surviving model stays.
        removed = {id(parameter) for parameter in model.get_all_parameters() if id(parameter) not in surviving}
        dependents = [
            parameter
            for other in survivors
            for parameter in other.get_all_parameters()
            if not parameter.independent and any(id(leader) in removed for leader in parameter.dependency_map.values())
        ]
        inequality_constraints = [
            spec
            for spec in self._inequality_constraints
            if any(id(self._parameter_or_none(path)) in removed for path in spec.paths.values())
        ]
        return ModelRemoval(
            experiments=self.experiments_for_model(index),
            dependents=list({id(parameter): parameter for parameter in dependents}.values()),
            inequality_constraints=inequality_constraints,
        )

    def remove_model_at_index(self, index: int, experiments: Union[str, int, None] = None) -> 'ModelRemoval':
        """Remove the model at `index`, resolving everything that refers to it.

        Parameters of other models that follow a parameter only this model
        owns become independent, keeping their current value (or getting back
        the one from before the link, when :meth:`apply_link` made the tie). Inequality
        constraints on such parameters are removed; the others are re-pathed.
        Objects the model shares with other models stay with them. See
        :meth:`plan_model_removal`.

        Parameters
        ----------
        index : int
            Index of the model to remove.
        experiments : Union[str, int, None], optional
            What happens to the experiments bound to the model: ``'remove'``
            removes them, an int rebinds them to the model at that index
            (counted before the removal). Required when there are any.

        Returns
        -------
        ModelRemoval
            What was affected; its ``remap`` keys the remaining experiments.

        Raises
        ------
        IndexError :
            If the index is out of range.
        ValueError :
            If trying to remove the last remaining model, or experiments are
            bound to it and `experiments` does not say what to do with them.
            Nothing is changed then.
        """
        plan = self.plan_model_removal(index)
        if len(self._models) <= 1:
            raise ValueError('Cannot remove the last model from the project')
        if plan.experiments and experiments != 'remove':
            if not isinstance(experiments, int) or experiments == index or not 0 <= experiments < len(self._models):
                raise ValueError(
                    f'Experiments {plan.experiments} use the model; pass experiments="remove" '
                    'or the index of another model to bind them to.'
                )

        for parameter in plan.dependents:
            self._untie(parameter)
        removed = self._models[index]
        self._links = [link for link in self._links if removed not in (link.follower, link.reference)]
        self._contrasts = [pair for pair in self._contrasts if removed not in pair]
        remap = {key: key for key in self._experiments}
        if experiments == 'remove':
            for key in reversed(plan.experiments):
                step = self.remove_experiment(key)
                remap = {old: step[new] for old, new in remap.items() if new in step}
        elif plan.experiments:
            for key in plan.experiments:
                self._experiments[key].model = self._models[experiments]
        self._keeping_inequality_paths(lambda: self._models.pop(index))
        self._invalidate_fitter()

        # Adjust current model index if necessary
        if self._current_model_index >= len(self._models):
            self._current_model_index = len(self._models) - 1
        elif self._current_model_index > index:
            self._current_model_index -= 1

        # Reset assembly and layer indices for the new current model
        self._current_assembly_index = 0
        self._current_layer_index = 0
        return ModelRemoval(plan.experiments, plan.dependents, plan.inequality_constraints, remap)

    def move_model(self, index: int, new_index: int) -> None:
        """Move the model at `index` to `new_index`, keeping inequality constraints on their parameters."""
        if index == new_index:
            return
        current = self._models[self._current_model_index]
        self._keeping_inequality_paths(lambda: self._models.insert(new_index, self._models.pop(index)))
        self._current_model_index = self._model_index(current)
        self._invalidate_fitter()

    def _parameter_or_none(self, path: str):
        try:
            return self.resolve_parameter_path(path)
        except KeyError:
            return None

    def _keeping_inequality_paths(self, edit) -> List[InequalitySpec]:
        """Run a structural `edit`, then re-path every inequality constraint to the
        parameters it referred to before. Constraints whose parameters are gone are
        removed and returned."""
        targets = [
            (spec, {alias: self._parameter_or_none(path) for alias, path in spec.paths.items()})
            for spec in self._inequality_constraints
        ]
        edit()
        locations = {id(parameter): path for path, parameter in self._walk_parameters()}
        kept, removed = [], []
        for spec, parameters in targets:
            new_paths = {alias: locations.get(id(parameter)) for alias, parameter in parameters.items()}
            if None in new_paths.values():
                removed.append(spec)
                continue
            spec.lhs_paths = {alias: new_paths[alias] for alias in spec.lhs_paths}
            spec.rhs_paths = {alias: new_paths[alias] for alias in spec.rhs_paths}
            kept.append(spec)
        self._inequality_constraints = kept
        return removed

    def add_material(self, material: Material) -> None:
        """Add a material to the project material collection.

        :param material: the material to add.
        :raises ValueError: if the material is already in the collection. The collection is
            left untouched in that case, so a caller cannot mistake a refused add for a
            successful one.
        """
        if material in self._materials:
            raise ValueError(f'Material {material.name} is already in the material collection.')
        self._materials.append(material)

    def remove_material(self, index: int) -> None:
        """Remove the material at *index* from the project material collection.

        :param index: position of the material in the collection.
        :raises IndexError: if there is no material at *index*.
        :raises ValueError: if the material is used by one of the models. Removing it would
            leave the model referring to a material the project no longer holds, so the
            collection is left untouched.
        """
        material = self._materials[index]
        if material in self._get_materials_in_models():
            raise ValueError(f'Material {material.name} is used in models and cannot be removed.')
        self._materials.pop(index)

    def _default_info(self):
        """Default info."""
        return dict(
            name='DefaultEasyReflectometryProject',
            short_description='Reflectometry, 1D',
            modified=datetime.datetime.now().strftime('%d.%m.%Y %H:%M'),
        )

    def create(self):
        """Create the project directory tree on disk.

        :raises FileExistsError: if the project directory already exists. Nothing is written in
            that case and `created` stays False, so a caller cannot mistake a collision for a new
            project.
        """
        if os.path.exists(self.path):
            raise FileExistsError(f'Directory {self.path} already exists. Choose a different name or location.')
        os.makedirs(self.path)
        os.makedirs(self.path / 'experiments')
        self._created = True
        self._timestamp_modification()

    def save_as_json(self, overwrite=False):
        """Save the project as json.

        The write is atomic: the serialized project is written to a temporary file in the
        destination directory and then moved into place with `os.replace`. Any failure while
        serializing or writing therefore leaves a previously saved project file untouched.

        Failures are raised rather than reported on stdout, so that callers (notably the GUI)
        can tell a successful save from a failed one.

        :param overwrite: whether an existing project file may be replaced.
        :raises FileExistsError: if the project file exists and `overwrite` is False.
        :raises ValueError: if the project cannot be serialized, e.g. when a constraint depends
            on a parameter that is not reachable from the models.
        :raises OSError: if the file cannot be written or moved into place.
        """
        if self.path_json.exists() and not overwrite:
            raise FileExistsError(f'File already exists {self.path_json}. Pass overwrite=True to replace it.')
        # Serialize before touching the file system, so that a serialization failure
        # leaves no temporary file behind and no doubt about the existing file.
        try:
            project_json = json.dumps(self.as_dict(include_materials_not_in_model=True), indent=4)
        except TypeError as error:
            # json.dumps reports a value it cannot encode (a stray numpy scalar, say) as a
            # TypeError; to the caller that is the same "cannot be serialized" failure.
            raise ValueError(f'The project cannot be serialized: {error}') from error
        self.path_json.parent.mkdir(exist_ok=True, parents=True)
        # The temporary file must share a directory with the destination, otherwise the
        # replace below is not guaranteed to be atomic (and fails across volumes on Windows).
        # The name is not a dot-file so that a leftover is visible while debugging.
        file_descriptor, temporary_name = tempfile.mkstemp(
            dir=self.path_json.parent, prefix=f'{self.path_json.name}.', suffix='.tmp'
        )
        temporary_path = Path(temporary_name)
        try:
            with os.fdopen(file_descriptor, mode='w') as file:
                file.write(project_json)
                file.flush()
                # Rename without fsync can leave an empty file behind after a power loss.
                os.fsync(file.fileno())
            self._apply_project_file_mode(temporary_path)
            os.replace(temporary_path, self.path_json)
        except Exception:
            temporary_path.unlink(missing_ok=True)
            raise
        logger.debug(f'Saved project to {self.path_json}')

    def _apply_project_file_mode(self, temporary_path: Path) -> None:
        """Give the temporary file the permissions the project file should end up with.

        `tempfile.mkstemp` creates files owner-only (0600) and `os.replace` carries that mode
        onto the destination. Without this step every save on POSIX would strip group and other
        read access, and overwriting a shared 0644 project file would silently make it private.
        An existing file keeps its mode; a new file gets the ordinary umask-derived one.
        """
        if self.path_json.exists():
            mode = stat.S_IMODE(self.path_json.stat().st_mode)
        else:
            umask = os.umask(0)
            os.umask(umask)
            mode = 0o666 & ~umask
        try:
            os.chmod(temporary_path, mode)
        except OSError:
            # Some file systems refuse chmod (network shares, some Windows mounts); the write
            # itself is still fine, so this is not a reason to fail the save.
            logger.debug(f'Could not set the mode of {temporary_path}')

    def load_from_json(self, path: Optional[Union[Path, str]] = None):
        """Load a project file, replacing the current project state.

        :param path: the project file; defaults to this project's own `path_json`.
        :raises FileNotFoundError: if there is no file at `path`. The current project is left
            untouched.
        :raises ValueError: if the file is not valid JSON or predates the supported file format.
        """
        if path is None:
            path = self.path_json
        path = Path(path)
        if not path.exists():
            raise FileNotFoundError(f'File {path} does not exist')
        with open(path, 'r') as file:
            project_dict = json.load(file)
            self.reset()
            self.from_dict(project_dict)
        self._path_project_parent = path.parents[1]
        self._created = True

    #: Schema version embedded in every serialized project. Bumped from 1 → 2
    #: when the sample/model classes migrated from the legacy
    #: ``easyscience.ObjBase``/``CollectionBase`` pipeline to
    #: ``ModelBase``/``EasyList``. The on-disk shape of nested objects (Layer,
    #: Material, MaterialMixture, MaterialSolvated, LayerAreaPerMolecule, etc.)
    #: changed in a way that is not backward-compatible with v1 files.
    FILE_FORMAT = 2
    #: Written instead of :attr:`FILE_FORMAT` when an older reader would load another
    #: project: the models share objects (reference nodes, see
    #: :mod:`easyreflectometry.sample.references`), an experiment is left out of the fit,
    #: or an experiment's model is found by a name several models have. Readers that
    #: predate it refuse the file rather than misread it.
    FILE_FORMAT_SHARED = 3

    def as_dict(self, include_materials_not_in_model=False):
        """As dict.

        Objects used in several places (a material in two layers, an assembly
        in two models) are written once and referred to elsewhere. The
        materials palette is written in order under ``materials``.
        ``include_materials_not_in_model`` adds the unused materials once more
        under their older key, for readers that predate ``materials``.
        """
        project_dict = {}
        project_dict['file_format'] = self.FILE_FORMAT
        project_dict['info'] = self._info
        project_dict['with_experiments'] = self._with_experiments
        with references.writing():
            if self._models is not None:
                project_dict['models'] = self._models.as_dict()
                project_dict['models']['unique_name'] = self._models.unique_name + '_to_prevent_collisions_on_load'
            project_dict['materials'] = [material.as_dict() for material in self._materials]
        references.prune(project_dict)
        if include_materials_not_in_model:
            self._as_dict_add_materials_not_in_model_dict(project_dict)
        if self._with_experiments:
            self._as_dict_add_experiments(project_dict)
        if self._needs_new_format(project_dict):
            project_dict['file_format'] = self.FILE_FORMAT_SHARED
        project_dict['fit_settings'] = self._fit_settings.to_dict()
        # Kept for readers that predate `fit_settings`.
        project_dict['fitter_minimizer'] = project_dict['fit_settings']['minimizer']
        if self._calculator is not None:
            project_dict['calculator'] = self._calculator.current_interface_name
        if self._colors is not None:
            project_dict['colors'] = self._colors
        if self._inequality_constraints:
            project_dict['inequality_constraints'] = [spec.to_dict() for spec in self._inequality_constraints]
        parameter_constraints = self._user_constraints()
        if parameter_constraints:
            project_dict['parameter_constraints'] = parameter_constraints
        sum_partner_max_backups = self._sum_partner_max_backups()
        if sum_partner_max_backups:
            project_dict['sum_partner_max_backups'] = sum_partner_max_backups
        self._as_dict_add_contrasts(project_dict)
        return project_dict

    def _model_index(self, model: Model) -> Optional[int]:
        return next((index for index, candidate in enumerate(self._models) if candidate is model), None)

    def _as_dict_add_contrasts(self, project_dict: dict) -> None:
        """Record contrast provenance and links by model index and parameter path.

        Entries whose models, or tied parameters, are no longer in the project
        (models replaced wholesale) are left out.
        """
        contrasts = [[self._model_index(derived), self._model_index(reference)] for derived, reference in self._contrasts]
        contrasts = [pair for pair in contrasts if None not in pair]
        if contrasts:
            project_dict['contrasts'] = contrasts
        parameter_paths = {id(parameter): path for path, parameter in self._walk_parameters()} if self._links else {}
        links = []
        for link in self._links:
            models = [self._model_index(link.follower), self._model_index(link.reference)]
            pairs = [
                [parameter_paths[id(follower)], parameter_paths[id(reference)], state]
                for follower, reference, state in link.pairs
                if id(follower) in parameter_paths and id(reference) in parameter_paths
            ]
            if None not in models and pairs:
                links.append({'follower': models[0], 'reference': models[1], 'pairs': pairs})
        if links:
            project_dict['links'] = links

    def _from_dict_restore_contrasts(self, project_dict: dict, report: list[str]) -> None:
        """Restore contrast provenance and links; an entry that does not fit the models is reported and dropped."""
        self._contrasts, self._links = [], []
        for derived, reference in project_dict.get('contrasts', []):
            try:
                self._contrasts.append((self._models[derived], self._models[reference]))
            except (IndexError, TypeError):
                report.append(f'Contrast provenance {derived} from {reference} refers to a missing model; dropped.')
        for raw in project_dict.get('links', []):
            try:
                pairs = [
                    (self.resolve_parameter_path(follower), self.resolve_parameter_path(reference), state)
                    for follower, reference, state in raw['pairs']
                ]
                self._links.append(LinkRecord(self._models[raw['follower']], self._models[raw['reference']], pairs))
            except (KeyError, IndexError, TypeError) as error:
                report.append(f'A link between models could not be restored: {error}')

    def _as_dict_add_materials_not_in_model_dict(self, project_dict: dict):
        """As dict add materials not in model dict."""
        materials_not_in_model = []
        for material in self._materials:
            if material not in self._get_materials_in_models():
                materials_not_in_model.append(material)
        if len(materials_not_in_model) > 0:
            project_dict['materials_not_in_model'] = MaterialCollection(materials_not_in_model).as_dict(skip=['interface'])

    def _needs_new_format(self, project_dict: dict) -> bool:
        """Whether a reader of :attr:`FILE_FORMAT` would load another project from `project_dict`.

        Such a reader keeps no shared objects (older readers ignore ``materials``, so
        only references inside the models count), fits every experiment, and finds an
        experiment's model by name.
        """
        if references.contains_references(project_dict.get('models')):
            return True
        if any(included is False for included in project_dict.get('experiments_fit_scope', {}).values()):
            return True
        names = [model.name for model in self._models] if self._models is not None else []
        return any(names.count(name) > 1 for name in project_dict.get('experiments_models', {}).values())

    def _as_dict_add_experiments(self, project_dict: dict):
        """As dict add experiments."""
        project_dict['experiments'] = {}
        project_dict['experiments_models'] = {}
        project_dict['experiments_model_indices'] = {}
        project_dict['experiments_names'] = {}
        project_dict['experiments_fit_scope'] = {}

        for key, experiment in self._experiments.items():
            if isinstance(experiment, PolarizedDataSet):
                self._as_dict_add_polarized_experiment(project_dict, key, experiment)
                continue
            project_dict['experiments'][key] = [
                list(experiment.x),
                list(experiment.y),
                list(experiment.ye),
            ]
            resolution = self._dataset_resolution_as_dict(experiment)
            if experiment.xe is not None or resolution is not None:
                # Fourth entry xe (None when cleared), fifth the dataset's own
                # resolution. The fifth is written even when None, an explicit
                # "use the model's resolution"; only its absence (files written
                # before it existed) means "derive it from xe".
                project_dict['experiments'][key].append(None if experiment.xe is None else list(experiment.xe))
                project_dict['experiments'][key].append(resolution)
            self._as_dict_add_experiment_model(project_dict, key, experiment)

    @staticmethod
    def _dataset_resolution_as_dict(dataset: DataSet1D) -> Optional[dict]:
        """The dataset's own resolution, serialized, or None when it uses the model's."""
        resolution_function = getattr(dataset, 'resolution_function', None)
        if resolution_function is None:
            return None
        return resolution_function.as_dict()

    @staticmethod
    def _dataset_resolution_from_dict(dataset: DataSet1D, arrays: list, position: int) -> None:
        """Restore a dataset's own resolution from ``arrays[position]``.

        A present entry is used as it is, ``None`` included (the dataset uses
        the model's resolution). Older files, written before the entry
        existed, get the measured resolution derived from ``xe``.
        """
        if len(arrays) <= position:
            dataset.resolution_function = resolution_from_dataset(dataset)
        elif arrays[position] is None:
            dataset.resolution_function = None
        else:
            dataset.resolution_function = ResolutionFunction.from_dict(arrays[position])

    def _as_dict_add_experiment_model(self, project_dict: dict, key: int, experiment) -> None:
        """Record the experiment's name, fit inclusion and model: by index (authoritative) and by name (older readers)."""
        project_dict['experiments_names'][key] = experiment.name
        project_dict['experiments_fit_scope'][key] = experiment.include_in_fit
        model_index = self._model_index_for_experiment(experiment)
        if model_index is not None:
            project_dict['experiments_model_indices'][key] = model_index
            project_dict['experiments_models'][key] = experiment.model.name

    def _as_dict_add_polarized_experiment(self, project_dict: dict, key: int, experiment: PolarizedDataSet) -> None:
        """Serialize a `PolarizedDataSet`: one (name, x, y, ye, xe, resolution) array set per measured channel.

        `experiments[key]` is a plain list for an ordinary `DataSet1D` (see
        `_as_dict_add_experiments`); a dict here — tagged `'polarized': True` —
        is how `_from_dict_extract_experiments` tells the two apart on load.
        """
        project_dict['experiments'][key] = {
            'polarized': True,
            'channels': {
                channel.value: [
                    experiment[channel].name,
                    list(experiment[channel].x),
                    list(experiment[channel].y),
                    list(experiment[channel].ye),
                    None if experiment[channel].xe is None else list(experiment[channel].xe),
                    # Written even when None; see `_dataset_resolution_from_dict`.
                    self._dataset_resolution_as_dict(experiment[channel]),
                ]
                for channel in experiment.available_channels
            },
        }
        self._as_dict_add_experiment_model(project_dict, key, experiment)

    def from_dict(self, project_dict: dict):
        """From dict."""
        keys = list(project_dict.keys())
        # Validate file format. v1 files were written by the legacy
        # `ObjBase`/`CollectionBase` pipeline; their inner shapes (Layer,
        # Material, MaterialMixture, …) are not compatible with the v2
        # `ModelBase`/`EasyList` deserializer. Older files must be re-created.
        file_format = project_dict.get('file_format')
        if file_format is None:
            raise ValueError(
                'This project file predates file_format=2 and cannot be loaded by '
                'this version of easyreflectometry. The serialization format changed '
                'when the sample/model classes migrated from the legacy ObjBase / '
                'CollectionBase pipeline. Please re-create the project from its '
                'underlying data using the current API.'
            )
        if file_format not in (self.FILE_FORMAT, self.FILE_FORMAT_SHARED):
            raise ValueError(
                f'Unsupported project file_format={file_format!r}; this version of '
                f'easyreflectometry only reads file_format={self.FILE_FORMAT} and {self.FILE_FORMAT_SHARED}. '
                'Please either update easyreflectometry or re-create the project.'
            )
        report: list[str] = []
        self._info = project_dict['info']
        self._with_experiments = project_dict['with_experiments']
        if 'calculator' in keys:
            self._calculator.switch(project_dict['calculator'])
        with references.reading(report):
            if 'models' in keys:
                self.models = ModelCollection.from_dict(project_dict['models'])
            palette = [references.deserialize(entry) for entry in project_dict.get('materials', [])]
        if 'materials' in keys:
            self._replace_collection(palette, self._materials)
        else:
            # A file predating `materials`: the models' materials, then the unused ones.
            self._replace_collection(self._get_materials_in_models(), self._materials)
            if 'materials_not_in_model' in keys:
                self._materials.extend(MaterialCollection.from_dict(project_dict['materials_not_in_model']))
        # Settings are replaced wholesale, never merged with the previous project's.
        # A file predating `fit_settings` only stored `fitter_minimizer`.
        legacy = {'minimizer': project_dict.get('fitter_minimizer')}
        self._fit_settings, settings_report = FitSettings.from_dict(project_dict.get('fit_settings', legacy))
        report.extend(settings_report)
        self._invalidate_fitter()
        self._last_fit = None  # the previous contents' run, not this project's
        if 'experiments' in keys:
            self._experiments = self._from_dict_extract_experiments(project_dict, report)
        else:
            self._experiments = {}
        self.load_report = report

        # Resolve any pending parameter dependencies parked by the core
        # deserializer. Only cores that serialize nested dependencies produce
        # them; on the others this is a no-op safety net and `parameter_constraints`
        # below carries the user constraints instead.
        resolve_all_parameter_dependencies(self)
        self._restore_user_constraints(project_dict.get('parameter_constraints', []))
        self._restore_sum_partner_max_backups(project_dict.get('sum_partner_max_backups', {}))
        self._warn_on_unreadable_dependencies(project_dict.get('models'))
        # Inequality constraints are declarative (paths), nothing to resolve yet:
        # they are bound to parameters when a fit starts.
        self._inequality_constraints = [InequalitySpec.from_dict(raw) for raw in project_dict.get('inequality_constraints', [])]
        self._from_dict_restore_contrasts(project_dict, self.load_report)

    @staticmethod
    def _warn_on_unreadable_dependencies(models_dict) -> None:
        """Warn about embedded dependencies this build cannot restore.

        A core that serializes dependencies inside each nested parameter writes
        ``_dependency_string`` there. Cores without that feature drop the field
        silently on load, so such a file would lose its equality constraints
        with no signal at all. Detect it and say so; the constraints have to be
        re-applied by hand.

        The field is written for internal dependencies too (material mixtures,
        conformal roughness, ``Model.total_thickness``), and those are rebuilt
        by their owning class regardless — so its presence does not prove
        anything was actually lost. The wording is hedged accordingly.
        """

        def _contains_dependency(node) -> bool:
            if isinstance(node, dict):
                return '_dependency_string' in node or any(_contains_dependency(v) for v in node.values())
            if isinstance(node, (list, tuple)):
                return any(_contains_dependency(item) for item in node)
            return False

        if _contains_dependency(models_dict):
            warnings.warn(
                'This project was saved by a build that stores parameter dependencies inside '
                'each parameter, which this build cannot restore. Internal constraints are '
                'rebuilt automatically, but any custom equality constraints have been dropped '
                'and must be re-applied.',
                stacklevel=2,
            )

    def _from_dict_extract_experiments(self, project_dict: dict, report: list[str]) -> Dict[int, DataSet1D | PolarizedDataSet]:
        """From dict extract experiments."""
        experiments = {}
        for key, raw in project_dict['experiments'].items():
            name = project_dict['experiments_names'].get(key, f'Experiment {key}')
            model = self._saved_experiment_model(project_dict, key, name, report)
            if isinstance(raw, dict) and raw.get('polarized'):
                experiment = self._polarized_experiment_from_dict(name, raw, model)
            else:
                experiment = self._dataset_from_dict(name, raw, model)
            experiment.include_in_fit = project_dict.get('experiments_fit_scope', {}).get(key, True) is not False
            experiments[int(key)] = experiment
        return experiments

    def _dataset_from_dict(self, name: str, raw: list, model: Optional[Model]) -> DataSet1D:
        """Reconstruct an unpolarized experiment serialized by `_as_dict_add_experiments`."""
        dataset = DataSet1D(
            name=name,
            x=raw[0],
            y=raw[1],
            ye=raw[2],
            xe=raw[3] if len(raw) > 3 else None,
            model=model,
            auto_background=False,
        )
        self._dataset_resolution_from_dict(dataset, raw, 4)
        return dataset

    def _saved_experiment_model(self, project_dict: dict, key, name: str, report: list[str]) -> Optional[Model]:
        """The model a saved experiment is bound to: by index, else by name (files that
        predate the index); an ambiguous or missing one is reported."""
        index = project_dict.get('experiments_model_indices', {}).get(key)
        if isinstance(index, int) and 0 <= index < len(self._models):
            return self._models[index]
        model_name = project_dict.get('experiments_models', {}).get(key)
        matches = [model for model in self._models if model.name == model_name]
        if not matches:
            report.append(f"Experiment '{name}' has no model; assign one before fitting it.")
            return None
        if len(matches) > 1:
            report.append(f"Experiment '{name}': several models are named '{model_name}'; the first is used.")
        return matches[0]

    def _polarized_experiment_from_dict(self, name: str, raw: dict, model: Optional[Model]) -> PolarizedDataSet:
        """Reconstruct a `PolarizedDataSet` serialized by `_as_dict_add_polarized_experiment`."""
        channels = {}
        for channel_value, arrays in raw['channels'].items():
            dataset = DataSet1D(
                name=arrays[0],
                x=arrays[1],
                y=arrays[2],
                ye=arrays[3],
                xe=arrays[4],
                model=model,
                auto_background=False,
            )
            self._dataset_resolution_from_dict(dataset, arrays, 5)
            channels[channel_value] = dataset
        return PolarizedDataSet(name=name, channels=channels, model=model)

    def _get_materials_in_models(self) -> MaterialCollection:
        """Get materials in models."""
        materials_in_model = MaterialCollection(populate_if_none=False)
        seen: set[int] = set()
        for model in self._models:
            for assembly in model.sample:
                for layer in assembly.layers:
                    if id(layer.material) not in seen:  # one material in two layers, once
                        seen.add(id(layer.material))
                        materials_in_model.append(layer.material)
        return materials_in_model

    def _replace_collection(self, src_collection: BaseCollection, dst_collection: BaseCollection) -> None:
        """Replace collection."""
        # Clear the destination collection
        for i in range(len(dst_collection)):
            dst_collection.pop(0)

        for element in src_collection:
            dst_collection.append(element)

    def _timestamp_modification(self):
        """Timestamp modification."""
        self._info['modified'] = datetime.datetime.now().strftime('%d.%m.%Y %H:%M')

parameters property

Get all unique parameters from all models in the project.

Parameters shared across multiple models (e.g. material SLD) are only included once to avoid double-counting.

q_min property writable

Q min.

q_max property writable

Q max.

q_resolution property writable

Q resolution.

current_material_index property writable

Current material index.

current_model_index property writable

Current model index.

current_assembly_index property writable

Current assembly index.

current_layer_index property writable

Current layer index.

current_experiment_index property writable

Current experiment index.

created property

Created function.

path property

Path function.

models property writable

Models function.

fitter property

The fitter of the current model, configured from :attr:fit_settings.

Every hand-out writes the project's settings onto it, so configure the fit through :attr:fit_settings (or :attr:minimizer), not through the returned fitter's core fitter.

fit_settings property writable

The project's fit configuration; edits apply to the next fit prepared.

last_fit property

The record of the latest fit recorded with :meth:record_fit, or None.

inequality_constraints property

The project's inequality constraints (a copy of the list).

calculator property writable

Calculator function.

calculator_supports_magnetism property

Whether the active calculator can model magnetic samples.

The GUI uses this to gate magnetism-related controls (e.g. when the refnx or bornagain backend is selected).

calculators_supporting_magnetism property

Names of the available calculators that can model magnetic samples.

Lets an application offer the switch a magnetic sample needs ("this requires refl1d — change to it?") instead of only reporting that the current calculator cannot do it. The active calculator is not touched.

models_have_magnetism property

Whether any model in the project carries a magnetic layer.

A calculator without magnetism cannot be selected while this holds — the binding it would have to build does not exist.

minimizer property writable

The selected minimizer; an alias for fit_settings.minimizer.

experiments property writable

Experiments function.

The links made by :meth:apply_link (a copy of the list).

path_json property

Path json.

__init__()

Init function.

Source code in src/easyreflectometry/project.py
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
def __init__(self):
    """Init function."""
    self._info = self._default_info()
    self._path_project_parent = Path(os.path.expanduser('~'))
    self._models = ModelCollection(populate_if_none=False, unique_name='project_models')
    self._materials = MaterialCollection(populate_if_none=False, unique_name='project_materials')
    self._calculator = CalculatorFactory()
    self._experiments: Dict[DataGroup] = {}
    self._fitter: MultiFitter = None
    self._fit_settings = FitSettings()
    #: Warnings from the last :meth:`from_dict`, for the user; cleared by each load.
    self.load_report: list[str] = []
    self._colors: list[str] = None
    self._report = None
    self._q_min: float = None
    self._q_max: float = None
    self._q_resolution: int = None
    self._current_material_index = 0
    self._current_model_index = 0
    self._current_assembly_index = 0
    self._current_layer_index = 0
    self._fitter_model_index = None
    self._current_experiment_index = 0
    self._inequality_constraints: List[InequalitySpec] = []
    self._last_fit = None
    #: (derived model, the model it was derived from), see :meth:`add_contrast`.
    self._contrasts: List[tuple] = []
    self._links: List[LinkRecord] = []

    # Project flags
    self._created = False
    self._with_experiments = False

reset()

Reset function.

Source code in src/easyreflectometry/project.py
181
182
183
184
185
186
187
def reset(self):
    """Reset function."""
    del self._models
    del self._materials
    global_object.map._clear()

    self.__init__()

set_path_project_parent(path)

Set path project parent.

Source code in src/easyreflectometry/project.py
430
431
432
def set_path_project_parent(self, path: Union[Path, str]):
    """Set path project parent."""
    self._path_project_parent = Path(path)

prepare_fit(experiments=None, *, objective=None, skip_invalid=False)

Prepare a fit of the experiments with a snapshot of :attr:fit_settings.

The run is not executed: call :meth:PreparedFit.execute, or run its core_fitter in a worker thread, then hand the results to :meth:record_fit. Inequality constraints are resolved when it executes.

A parameter of a fitted model that follows a free parameter of a model left out of the fit keeps following it, and that parameter is varied too (prepared.added_roots); the left-out data does not enter the fit.

Parameters:

Name Type Description Default
experiments list

The experiments to fit, in order. By default, the loaded ones whose include_in_fit is set.

None
objective str

Zero-variance objective overriding the settings' one.

None
skip_invalid bool

Leave out experiments that have no model in this project (listed in prepared.skipped) instead of raising. By default, False.

False

Returns:

Type Description
PreparedFit

The prepared run.

Raises:

Type Description
FitScopeError

Nothing to fit, or an experiment has no model in this project.

FitPreconditionError

If a free parameter's bounds are unusable for the minimizer.

Source code in src/easyreflectometry/project.py
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
def prepare_fit(
    self,
    experiments: Optional[list] = None,
    *,
    objective: Optional[str] = None,
    skip_invalid: bool = False,
) -> PreparedFit:
    """Prepare a fit of the experiments with a snapshot of :attr:`fit_settings`.

    The run is not executed: call :meth:`PreparedFit.execute`, or run its
    ``core_fitter`` in a worker thread, then hand the results to
    :meth:`record_fit`. Inequality constraints are resolved when it executes.

    A parameter of a fitted model that follows a free parameter of a model
    left out of the fit keeps following it, and that parameter is varied
    too (``prepared.added_roots``); the left-out data does not enter the fit.

    Parameters
    ----------
    experiments : list, optional
        The experiments to fit, in order. By default, the loaded ones
        whose ``include_in_fit`` is set.
    objective : str, optional
        Zero-variance objective overriding the settings' one.
    skip_invalid : bool, optional
        Leave out experiments that have no model in this project (listed
        in ``prepared.skipped``) instead of raising. By default, False.

    Returns
    -------
    PreparedFit
        The prepared run.

    Raises
    ------
    FitScopeError
        Nothing to fit, or an experiment has no model in this project.
    FitPreconditionError
        If a free parameter's bounds are unusable for the minimizer.
    """
    experiments, invalid = self._fit_scope(experiments, skip_invalid, action='fit')
    fitter = self._fitter_for(experiments)
    prepared = fitter.prepare(objective=objective)
    prepared.skipped = [experiment.name for experiment in invalid]
    prepared.experiments = [
        experiment for experiment in experiments for _ in (getattr(experiment, 'available_channels', None) or [None])
    ]
    return prepared

prepare_sample(experiments=None, *, skip_invalid=False)

A fitter ready for Bayesian sampling of the experiments, with :attr:fit_settings.

The sampling is not run: call mcmc_sample() on the result (with no DataGroup), from a worker thread if need be. Each dataset -- each spin channel of a polarized experiment -- is sampled with the resolution it was measured with, exactly as :meth:prepare_fit fits it, so the posterior and the classical fit agree on what the model is compared to. Inequality constraints are resolved when it runs.

Parameters:

Name Type Description Default
experiments list

The experiments to sample, in order. By default, the loaded ones whose include_in_fit is set.

None
skip_invalid bool

Leave out experiments that have no model in this project instead of raising. By default, False.

False

Returns:

Type Description
MultiFitter

The fitter, built with :meth:MultiFitter.for_experiments. Its settings are a snapshot of :attr:fit_settings taken here, as :meth:prepare_fit takes one: an edit made before mcmc_sample() runs applies to the next sampling, not to this one. Its skipped lists the names of the experiments left out by skip_invalid, like PreparedFit.skipped.

Raises:

Type Description
FitScopeError

Nothing to sample, or an experiment has no model in this project.

Source code in src/easyreflectometry/project.py
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
def prepare_sample(
    self,
    experiments: Optional[list] = None,
    *,
    skip_invalid: bool = False,
) -> MultiFitter:
    """A fitter ready for Bayesian sampling of the experiments, with :attr:`fit_settings`.

    The sampling is not run: call ``mcmc_sample()`` on the result (with
    no ``DataGroup``), from a worker thread if need be. Each dataset --
    each spin channel of a polarized experiment -- is sampled with the
    resolution it was measured with, exactly as :meth:`prepare_fit` fits
    it, so the posterior and the classical fit agree on what the model
    is compared to. Inequality constraints are resolved when it runs.

    Parameters
    ----------
    experiments : list, optional
        The experiments to sample, in order. By default, the loaded ones
        whose ``include_in_fit`` is set.
    skip_invalid : bool, optional
        Leave out experiments that have no model in this project instead
        of raising. By default, False.

    Returns
    -------
    MultiFitter
        The fitter, built with :meth:`MultiFitter.for_experiments`. Its
        ``settings`` are a snapshot of :attr:`fit_settings` taken here, as
        :meth:`prepare_fit` takes one: an edit made before ``mcmc_sample()``
        runs applies to the next sampling, not to this one. Its
        ``skipped`` lists the names of the experiments left out by
        ``skip_invalid``, like ``PreparedFit.skipped``.

    Raises
    ------
    FitScopeError
        Nothing to sample, or an experiment has no model in this project.
    """
    experiments, invalid = self._fit_scope(experiments, skip_invalid, action='sample')
    fitter = self._fitter_for(experiments)
    fitter.skipped = [experiment.name for experiment in invalid]
    return fitter

fit_parameters(experiments=None)

The parameters a fit of experiments varies, each once; nothing is prepared.

The free parameters of their models, and the free parameters of other models those follow (see :meth:prepare_fit). Experiments without a model of this project contribute nothing.

Parameters:

Name Type Description Default
experiments list

By default, the loaded ones whose include_in_fit is set.

None
Source code in src/easyreflectometry/project.py
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
def fit_parameters(self, experiments: Optional[list] = None) -> List[Parameter]:
    """The parameters a fit of `experiments` varies, each once; nothing is prepared.

    The free parameters of their models, and the free parameters of other
    models those follow (see :meth:`prepare_fit`). Experiments without a
    model of this project contribute nothing.

    Parameters
    ----------
    experiments : list, optional
        By default, the loaded ones whose ``include_in_fit`` is set.
    """
    if experiments is None:
        experiments = [self._experiments[key] for key in sorted(self._experiments) if self._experiments[key].include_in_fit]
    models = []
    for experiment in experiments:
        index = self._model_index(experiment.model)
        if index is not None and not any(model is self._models[index] for model in models):
            models.append(self._models[index])
    parameters = {id(parameter): parameter for model in models for parameter in model.get_free_parameters()}
    for root in _free_roots(models):
        parameters.setdefault(id(root), root)
    return list(parameters.values())

record_fit(prepared, results=None, status='completed')

Record the outcome of a run prepared by :meth:prepare_fit; see :attr:last_fit.

A completed run's results also reach :attr:fitter's goodness-of-fit properties. A failed or cancelled one is recorded without statistics.

Source code in src/easyreflectometry/project.py
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
def record_fit(self, prepared: PreparedFit, results: Optional[list] = None, status: str = 'completed') -> FitRun:
    """Record the outcome of a run prepared by :meth:`prepare_fit`; see :attr:`last_fit`.

    A completed run's results also reach :attr:`fitter`'s goodness-of-fit
    properties. A failed or cancelled one is recorded without statistics.
    """
    completed = status == 'completed' and bool(results)
    metrics = prepared.finalize(results) if completed else None
    # The run's own settings; the project's may have changed since it was prepared.
    settings = prepared.settings or self._fit_settings
    # Experiments may have been removed since too: the keys are those of now.
    keys = {id(experiment): key for key, experiment in self._experiments.items()}
    inputs = [
        (keys.get(id(experiment)), experiment.name, None if channel is None else channel.value)
        for experiment, channel in zip(prepared.experiments, prepared.channels)
    ]
    self._last_fit = FitRun.from_results(prepared, results, status, settings.minimizer.name, metrics, inputs)
    if completed:
        self.fitter.record_fit_results(results, metrics)
    return self._last_fit

parameter_path(parameter)

Structural path of parameter within this project, e.g. models/0/sample/1/layers/0/thickness.

Paths are stable across save/load (unlike unique names, which are regenerated) and are the way inequality constraints reference parameters. Returns None when the parameter is not reachable from the project's models.

Source code in src/easyreflectometry/project.py
670
671
672
673
674
675
676
677
678
679
def parameter_path(self, parameter) -> Optional[str]:
    """Structural path of `parameter` within this project, e.g.
    ``models/0/sample/1/layers/0/thickness``.

    Paths are stable across save/load (unlike unique names, which are
    regenerated) and are the way inequality constraints reference
    parameters. Returns ``None`` when the parameter is not reachable
    from the project's models.
    """
    return next((path for path, candidate in self._walk_parameters() if candidate is parameter), None)

resolve_parameter_path(path)

Return the parameter at a structural path (see :meth:parameter_path).

Source code in src/easyreflectometry/project.py
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
def resolve_parameter_path(self, path: str):
    """Return the parameter at a structural `path` (see :meth:`parameter_path`)."""
    tokens = [t for t in str(path).split('/') if t != '']
    if not tokens or tokens[0] != 'models':
        raise KeyError(f"Parameter path must start with 'models': '{path}'.")
    obj = self._models
    for token in tokens[1:]:
        if token.lstrip('-').isdigit():
            try:
                obj = obj[int(token)]
            except (IndexError, KeyError, TypeError):
                raise KeyError(f"Parameter path '{path}': index {token} is out of range.") from None
        else:
            if token.startswith('_') or not hasattr(obj, token):
                raise KeyError(f"Parameter path '{path}': unknown attribute '{token}'.")
            obj = getattr(obj, token)
    if not isinstance(obj, DescriptorNumberType):
        raise KeyError(f"Parameter path '{path}' does not point to a parameter.")
    return obj

add_inequality_constraint(spec, validate=True)

Register an inequality constraint; returns it.

With validate the paths are resolved and the units of both sides compared, raising KeyError/ValueError on problems.

Source code in src/easyreflectometry/project.py
708
709
710
711
712
713
714
715
716
717
718
719
def add_inequality_constraint(self, spec: InequalitySpec, validate: bool = True) -> InequalitySpec:
    """Register an inequality constraint; returns it.

    With ``validate`` the paths are resolved and the units of both sides
    compared, raising ``KeyError``/``ValueError`` on problems.
    """
    if not isinstance(spec, InequalitySpec):
        raise TypeError('spec must be an InequalitySpec')
    if validate:
        check_units(spec, self.resolve_parameter_path)
    self._inequality_constraints.append(spec)
    return spec

remove_inequality_constraint(which)

Remove a constraint by index, by name or by identity.

A name removes every spec carrying that name; use the index or the spec object to remove a single one when names are shared.

Source code in src/easyreflectometry/project.py
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
def remove_inequality_constraint(self, which: Union[int, str, InequalitySpec]) -> None:
    """Remove a constraint by index, by name or by identity.

    A name removes **every** spec carrying that name; use the index or
    the spec object to remove a single one when names are shared.
    """
    if isinstance(which, InequalitySpec):
        self._inequality_constraints = [s for s in self._inequality_constraints if s is not which]
        return
    if isinstance(which, str):
        matches = [s for s in self._inequality_constraints if s.name == which]
        if not matches:
            raise KeyError(f"No inequality constraint named '{which}'.")
        for spec in matches:
            self._inequality_constraints.remove(spec)
        return
    del self._inequality_constraints[int(which)]

evaluate_inequality_constraints()

Evaluate every constraint (enabled or not) at the current values.

Source code in src/easyreflectometry/project.py
742
743
744
def evaluate_inequality_constraints(self) -> List[InequalityEvaluation]:
    """Evaluate every constraint (enabled or not) at the current values."""
    return [evaluate_spec(spec, self.resolve_parameter_path) for spec in self._inequality_constraints]

violated_inequality_constraints()

Enabled constraints that the current parameter values violate.

A fit started from an infeasible point begins on the BUMPS penalty plateau; callers should refuse or warn before launching.

Source code in src/easyreflectometry/project.py
746
747
748
749
750
751
752
753
754
755
756
def violated_inequality_constraints(self) -> List[InequalitySpec]:
    """Enabled constraints that the *current* parameter values violate.

    A fit started from an infeasible point begins on the BUMPS penalty
    plateau; callers should refuse or warn before launching.
    """
    violated = []
    for spec in self._inequality_constraints:
        if spec.enabled and not evaluate_spec(spec, self.resolve_parameter_path).satisfied:
            violated.append(spec)
    return violated

build_constraints_factory()

constraints_factory hook for the enabled inequality constraints, or None.

Source code in src/easyreflectometry/project.py
758
759
760
def build_constraints_factory(self):
    """``constraints_factory`` hook for the enabled inequality constraints, or ``None``."""
    return build_constraints_factory(self._inequality_constraints, self.resolve_parameter_path)

remove_experiment(index)

Remove the experiment at index; the later ones move down a key.

Keys stay 0..n-1 and equal to display positions, so a caller that keeps per-experiment state (a selection, visibility) remaps it with the returned table.

Returns:

Type Description
Dict[int, int]

Old key to new key of every remaining experiment.

Raises:

Type Description
IndexError

No experiment at index.

Source code in src/easyreflectometry/project.py
 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
def remove_experiment(self, index: int) -> Dict[int, int]:
    """Remove the experiment at `index`; the later ones move down a key.

    Keys stay ``0..n-1`` and equal to display positions, so a caller that
    keeps per-experiment state (a selection, visibility) remaps it with
    the returned table.

    Returns
    -------
    Dict[int, int]
        Old key to new key of every remaining experiment.

    Raises
    ------
    IndexError
        No experiment at `index`.
    """
    if index not in self._experiments:
        raise IndexError(f'No experiment at index {index}')
    remaining = [key for key in sorted(self._experiments) if key != index]
    remap = {old: new for new, old in enumerate(remaining)}
    self._experiments = {remap[key]: self._experiments[key] for key in remaining}
    current = self._current_experiment_index
    self._current_experiment_index = remap.get(current, min(current, len(remaining) - 1)) if remaining else 0
    self._with_experiments = bool(self._experiments)
    if self._last_fit is not None:
        self._last_fit = self._last_fit.remapped(remap)
    return remap

model_index_for_experiment(index)

Index of the model the experiment at index is bound to, or None when it has none.

Source code in src/easyreflectometry/project.py
1021
1022
1023
def model_index_for_experiment(self, index: int) -> Optional[int]:
    """Index of the model the experiment at `index` is bound to, or None when it has none."""
    return self._model_index_for_experiment(self._experiments[index])

set_model_for_experiment(index, model_index)

Bind the experiment at index to the model at model_index.

Source code in src/easyreflectometry/project.py
1025
1026
1027
def set_model_for_experiment(self, index: int, model_index: int) -> None:
    """Bind the experiment at `index` to the model at `model_index`."""
    self._experiments[index].model = self._models[model_index]

experiments_for_model(model_index)

Keys of the experiments bound to the model at model_index.

Source code in src/easyreflectometry/project.py
1029
1030
1031
1032
def experiments_for_model(self, model_index: int) -> List[int]:
    """Keys of the experiments bound to the model at `model_index`."""
    model = self._models[model_index]
    return [key for key, experiment in sorted(self._experiments.items()) if experiment.model is model]

add_contrast(reference_index, name, substitutions=None)

Add a contrast of the model at reference_index; see :func:easyreflectometry.contrasts.derive_contrast.

Replacement materials join the materials palette. Returns the index of the new model.

Source code in src/easyreflectometry/project.py
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
def add_contrast(
    self,
    reference_index: int,
    name: str,
    substitutions: Optional[Sequence[Union[ReplaceMaterial, ReplaceFormula]]] = None,
) -> int:
    """Add a contrast of the model at `reference_index`; see :func:`easyreflectometry.contrasts.derive_contrast`.

    Replacement materials join the materials palette. Returns the index of the new model.
    """
    reference = self._models[reference_index]
    model = derive_contrast(reference, name=name, substitutions=substitutions)
    model.color = self._models.next_color()
    self._models.append(model)
    model.interface = self._calculator
    self._extend_palette_from_models()
    self._sync_parameter_states()
    self._contrasts.append((model, reference))
    self._invalidate_fitter()
    return len(self._models) - 1

contrast_reference(model_index)

Index of the model the model at model_index was derived from, if it was and still exists.

Source code in src/easyreflectometry/project.py
1057
1058
1059
1060
def contrast_reference(self, model_index: int) -> Optional[int]:
    """Index of the model the model at `model_index` was derived from, if it was and still exists."""
    model = self._models[model_index]
    return self._model_index(next((reference for derived, reference in self._contrasts if derived is model), None))

parameter_models(parameter)

Indices of the models parameter belongs to (several when an object is shared).

Source code in src/easyreflectometry/project.py
1062
1063
1064
def parameter_models(self, parameter: Parameter) -> List[int]:
    """Indices of the models `parameter` belongs to (several when an object is shared)."""
    return self._parameter_owners().get(id(parameter), [])

What tying the model at follower_index to the one at reference_index would do; see :func:easyreflectometry.contrasts.plan_link. Nothing is changed.

Source code in src/easyreflectometry/project.py
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
def plan_link(self, follower_index: int, reference_index: int, materials: Optional[str] = None) -> LinkPlan:
    """What tying the model at `follower_index` to the one at `reference_index` would do; see
    :func:`easyreflectometry.contrasts.plan_link`. Nothing is changed."""
    owners = self._parameter_owners()
    return plan_link(
        self._models[follower_index],
        self._models[reference_index],
        owners=lambda parameter: owners.get(id(parameter), []),
        follower_index=follower_index,
        materials=materials,
    )

Tie the rows of plan marked 'tie', all or none.

Raises:

Type Description
ValueError

A row is still 'undecided', or the project changed since the plan was made so that a row can no longer be tied as planned (it was applied already, a model now shares the parameter, ...). Nothing is tied then.

Source code in src/easyreflectometry/project.py
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
def apply_link(self, plan: LinkPlan) -> LinkRecord:
    """Tie the rows of `plan` marked ``'tie'``, all or none.

    Raises
    ------
    ValueError
        A row is still ``'undecided'``, or the project changed since the plan
        was made so that a row can no longer be tied as planned (it was applied
        already, a model now shares the parameter, ...). Nothing is tied then.
    """
    undecided = [row.path for row in plan.rows if row.action == 'undecided']
    if undecided:
        raise ValueError(f'Decide whether to tie {", ".join(undecided)} before linking.')
    # The project may have changed since the plan was made (a model now shares a
    # parameter, the plan was applied already): plan again and compare.
    follower_index, reference_index = self._model_index(plan.follower), self._model_index(plan.reference)
    if follower_index is None or reference_index is None:
        raise ValueError('The plan refers to a model that is no longer in the project; plan the link again.')
    outdated = plan.outdated(self.plan_link(follower_index, reference_index))
    if outdated:
        raise ValueError(f'The plan is out of date at {", ".join(outdated)}; plan the link again.')
    pairs = []
    try:
        for row in plan.rows:
            if row.action == 'tie':
                state = snapshot(row.follower)
                constrain_equal(row.follower, row.reference)
                pairs.append((row.follower, row.reference, state))
    except Exception:
        for follower, _, state in pairs:
            unconstrain(follower)
            restore(follower, state)
        raise
    record = LinkRecord(plan.follower, plan.reference, pairs)
    self._links.append(record)
    return record

Remove the ties record made. Each follower gets back its value, bounds and fixed state from before the link (restore_state), or keeps its current ones. Returns the indices of the models whose parameters were freed.

Source code in src/easyreflectometry/project.py
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
def unlink(self, record: LinkRecord, restore_state: bool = True) -> List[int]:
    """Remove the ties `record` made. Each follower gets back its value, bounds and
    fixed state from before the link (`restore_state`), or keeps its current ones.
    Returns the indices of the models whose parameters were freed."""
    freed = []
    for follower, reference, state in record.pairs:
        if follows(follower, reference):
            unconstrain(follower)
            if restore_state:
                restore(follower, state)
            freed.append(follower)
    self._links = [link for link in self._links if link is not record]
    owners = self._parameter_owners()
    return sorted({index for parameter in freed for index in owners.get(id(parameter), [])})

detach(parameter, model_index=None)

Make parameter independent (in the model at model_index only, when it is shared).

A tie is removed (with the state from before it, when a link made it). A parameter of an assembly shared with other models gets a copy of that assembly in the model at model_index, with every other parameter tied to the shared one, so only parameter's counterpart is free.

A tie on a shared parameter is one tie: removing it frees the parameter in every model sharing it, so that is done without a model_index.

Raises:

Type Description
ValueError

The parameter is derived; model_index is not one of its models; it is shared and model_index is not given; it is shared and tied and model_index is given; or it belongs to a material the model shares (assign the layer another material instead).

Source code in src/easyreflectometry/project.py
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
def detach(self, parameter: Parameter, model_index: Optional[int] = None) -> None:
    """Make `parameter` independent (in the model at `model_index` only, when it is shared).

    A tie is removed (with the state from before it, when a link made it).
    A parameter of an assembly shared with other models gets a copy of that
    assembly in the model at `model_index`, with every other parameter tied
    to the shared one, so only `parameter`'s counterpart is free.

    A tie on a shared parameter is one tie: removing it frees the parameter
    in every model sharing it, so that is done without a `model_index`.

    Raises
    ------
    ValueError
        The parameter is derived; `model_index` is not one of its models; it
        is shared and `model_index` is not given; it is shared and tied and
        `model_index` is given; or it belongs to a material the model shares
        (assign the layer another material instead).
    """
    owners = self.parameter_models(parameter)
    if model_index is not None and model_index not in owners:
        name = self._models[model_index].name if 0 <= model_index < len(self._models) else model_index
        raise ValueError(f"'{parameter.name}' is not part of '{name}'.")
    if not parameter.independent:
        if not getattr(parameter, USER_CONSTRAINT_FLAG, False):
            raise ValueError(f"'{parameter.name}' is derived from other parameters; it cannot be detached.")
        if len(owners) > 1 and model_index is not None:
            raise ValueError(
                f"'{parameter.name}' is one parameter of {len(owners)} models and follows another one; "
                'removing the tie frees it in all of them, so detach it without a model.'
            )
        self._untie(parameter)
        return
    if len(owners) < 2:
        return
    if model_index is None:
        raise ValueError(f"'{parameter.name}' is shared by several models; say which one to detach it in.")
    model = self._models[model_index]
    positions = [
        position
        for position, assembly in enumerate(model.sample)
        if any(p is parameter for p in assembly.get_all_parameters())
    ]
    if len(positions) != 1:
        raise ValueError(
            f"'{parameter.name}' is part of '{model.name}' at several positions ({positions}); "
            'give them their own assemblies first.'
        )
    position = positions[0]
    assembly = model.sample[position]
    kept = kept_children(assembly, {}, set())
    if any(parameter is p for material in kept.values() for p in material.get_all_parameters()):
        raise ValueError(f"'{parameter.name}' belongs to a shared material; give the layer its own material instead.")
    copy = references.rebuild(assembly, kept)
    twin = dict(paths.walk(copy, []))[next(path for path, p in paths.walk(assembly, []) if p is parameter)]
    tie_corresponding(copy, assembly, excluded={id(twin)})
    model.sample[position] = copy
    model.interface = self._calculator
    self._sync_parameter_states()
    self._invalidate_fitter()

get_index_air()

Get index air.

Source code in src/easyreflectometry/project.py
1224
1225
1226
1227
1228
def get_index_air(self) -> int:
    """Get index air."""
    if 'Air' not in [material.name for material in self._materials]:
        self._materials.add_material(Material(name='Air', sld=0.0, isld=0.0))
    return [material.name for material in self._materials].index('Air')

get_index_si()

Get index si.

Source code in src/easyreflectometry/project.py
1230
1231
1232
1233
1234
def get_index_si(self) -> int:
    """Get index si."""
    if 'Si' not in [material.name for material in self._materials]:
        self._materials.add_material(Material(name='Si', sld=2.07, isld=0.0))
    return [material.name for material in self._materials].index('Si')

get_index_sio2()

Get index sio2.

Source code in src/easyreflectometry/project.py
1236
1237
1238
1239
1240
def get_index_sio2(self) -> int:
    """Get index sio2."""
    if 'SiO2' not in [material.name for material in self._materials]:
        self._materials.add_material(Material(name='SiO2', sld=3.47, isld=0.0))
    return [material.name for material in self._materials].index('SiO2')

get_index_d2o()

Get index d2o.

Source code in src/easyreflectometry/project.py
1242
1243
1244
1245
1246
def get_index_d2o(self) -> int:
    """Get index d2o."""
    if 'D2O' not in [material.name for material in self._materials]:
        self._materials.add_material(Material(name='D2O', sld=6.36, isld=0.0))
    return [material.name for material in self._materials].index('D2O')

load_orso_file(path)

Load an ORSO file, creating a model from its sample.model (when present) and an experiment.

.. deprecated:: Use :meth:load_new_experiment (data) together with :meth:set_sample_from_orso / :func:easyreflectometry.orso_utils.load_orso_model (model) instead. This wrapper now routes through the same DataSet1D + title + resolution path as every other importer.

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the ORSO file.

required
Source code in src/easyreflectometry/project.py
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
def load_orso_file(self, path: Union[Path, str]) -> None:
    """Load an ORSO file, creating a model from its ``sample.model`` (when present) and an experiment.

    .. deprecated::
        Use :meth:`load_new_experiment` (data) together with
        :meth:`set_sample_from_orso` /
        :func:`easyreflectometry.orso_utils.load_orso_model` (model)
        instead. This wrapper now routes through the same
        ``DataSet1D`` + title + resolution path as every other importer.

    Parameters
    ----------
    path : Union[Path, str]
        Path to the ORSO file.
    """
    warnings.warn(
        'Project.load_orso_file is deprecated; use load_new_experiment (data) and '
        'set_sample_from_orso/load_orso_model (model) instead.',
        DeprecationWarning,
        stacklevel=2,
    )
    from easyreflectometry.orso_utils import _load_orso_any
    from easyreflectometry.orso_utils import load_orso_model

    orso_data = _load_orso_any(str(path))
    sample = load_orso_model(orso_data)
    if sample is not None:
        self.models = ModelCollection([Model(sample=sample, name=sample.name)])
    else:
        self.default_model()
    self.load_experiment_for_model_at_index(path, 0)

set_sample_from_orso(sample)

Replace the current project model collection with a single model built from an ORSO-parsed sample.

This is a convenience helper for the ORSO import pipeline where a complete :class:~easyreflectometry.sample.Sample is constructed elsewhere.

Parameters:

Name Type Description Default
sample Sample

Sample to set as the project's (single) model.

required

Returns:

Type Description
None

None.

Source code in src/easyreflectometry/project.py
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
def set_sample_from_orso(self, sample: Sample) -> None:
    """Replace the current project model collection with a single model built from an ORSO-parsed sample.

    This is a convenience helper for the ORSO import pipeline where a complete
    :class:`~easyreflectometry.sample.Sample` is constructed elsewhere.

    Parameters
    ----------
    sample : Sample
        Sample to set as the project's (single) model.

    Returns
    -------
    None
        ``None``.
    """
    model = Model(sample=sample)
    self.models = ModelCollection([model])

add_sample_from_orso(sample)

Add a new model with the given sample to the existing model collection.

The created model is appended to :attr:models, its calculator interface is set to the project's current calculator, and any materials referenced in the sample are added to the project's material collection.

After adding the model, :attr:current_model_index is updated to point to the newly added model.

Parameters:

Name Type Description Default
sample Sample

Sample to add as a new model.

required

Returns:

Type Description
None

None.

Source code in src/easyreflectometry/project.py
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
def add_sample_from_orso(self, sample: Sample) -> None:
    """Add a new model with the given sample to the existing model collection.

    The created model is appended to :attr:`models`, its calculator interface is
    set to the project's current calculator, and any materials referenced in the
    sample are added to the project's material collection.

    After adding the model, :attr:`current_model_index` is updated to point to
    the newly added model.

    Parameters
    ----------
    sample : Sample
        Sample to add as a new model.

    Returns
    -------
    None
        ``None``.
    """
    if sample is None:
        raise ValueError('The ORSO file does not contain a valid sample model definition.')
    self._check_sample_calculable(sample)
    # Take the collection's next colour: a supplied model keeps its own
    # colour, and the Model default would give every loaded sample the
    # same first palette colour.
    model = Model(sample=sample, color=self._models.next_color())
    self.models.add_model(model)
    # Set interface after adding to collection
    model.interface = self._calculator
    # Extract materials from the new model and add to project materials
    self._materials.extend(self._get_materials_from_model(model))
    self._sync_parameter_states()
    # Switch to the newly added model so its data is visible in the UI
    self.current_model_index = len(self._models) - 1

replace_models_from_orso(sample)

Replace all models and materials with a single model from an ORSO sample.

All existing models and their associated materials are removed. A new model is created from sample, assigned to the project's calculator, and the material collection is rebuilt from the new model only.

Parameters:

Name Type Description Default
sample Sample

Sample to set as the project's only model.

required

Returns:

Type Description
None

None.

Source code in src/easyreflectometry/project.py
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
def replace_models_from_orso(self, sample: Sample) -> None:
    """Replace all models and materials with a single model from an ORSO sample.

    All existing models and their associated materials are removed. A new
    model is created from *sample*, assigned to the project's calculator,
    and the material collection is rebuilt from the new model only.

    Parameters
    ----------
    sample : Sample
        Sample to set as the project's only model.

    Returns
    -------
    None
        ``None``.
    """
    if sample is None:
        raise ValueError('The ORSO file does not contain a valid sample model definition.')
    self._check_sample_calculable(sample)
    model = Model(sample=sample)
    if sample.name:
        model.user_data['original_name'] = sample.name  # Store original name for reference
    self.models = ModelCollection([model])
    model.interface = self._calculator
    self._materials = self._get_materials_from_model(model)
    self.current_model_index = 0

load_new_experiment(path, data_group=None)

Load new experiment.

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the experiment data file.

required
data_group

Pre-loaded scipp DataGroup for path (avoids re-parsing the file). By default, None.

None
Source code in src/easyreflectometry/project.py
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
def load_new_experiment(self, path: Union[Path, str], data_group=None) -> None:
    """Load new experiment.

    Parameters
    ----------
    path : Union[Path, str]
        Path to the experiment data file.
    data_group :
        Pre-loaded scipp DataGroup for *path* (avoids re-parsing the
        file). By default, None.
    """
    if data_group is None:
        data_group = load_measurement_file(str(path))
    new_experiment = load_as_dataset(str(path), data_group=data_group)
    new_index = len(self._experiments)

    model_index = 0
    if new_index < len(self.models):
        model_index = new_index

    self._apply_experiment_metadata(path, new_experiment, f'Experiment {new_index}', data_group=data_group)
    self._bind_loaded_dataset(new_experiment, self.models[model_index])
    self._experiments[new_index] = new_experiment
    self._with_experiments = True

count_datasets_in_file(path)

Return the number of datasets contained in the file at path.

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the data file.

required

Returns:

Type Description
int

Number of datasets found; 1 if a non-ORSO file cannot be introspected. A corrupt ORSO file (banner present but unparsable) raises instead of being silently miscounted.

Source code in src/easyreflectometry/project.py
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
def count_datasets_in_file(self, path: Union[Path, str]) -> int:
    """Return the number of datasets contained in the file at *path*.

    Parameters
    ----------
    path : Union[Path, str]
        Path to the data file.

    Returns
    -------
    int
        Number of datasets found; 1 if a non-ORSO file cannot be
        introspected. A corrupt ORSO file (banner present but unparsable)
        raises instead of being silently miscounted.
    """
    try:
        data_group = load_measurement_file(str(path))
        return len(data_group['data'])
    except Exception:
        if is_orso_file(str(path)):
            raise
        return 1

load_all_experiments_from_file(path)

Load all datasets from a file as separate experiments sharing the current model.

For a multi-dataset ORSO file (e.g. a multi-angle measurement), each dataset is registered as an independent experiment. All experiments share the model that is currently selected. Single-dataset files go through :meth:load_new_experiment. A corrupt ORSO file raises (no silent plain-text fallback).

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the data file.

required

Returns:

Type Description
int

Number of experiments that were added.

Source code in src/easyreflectometry/project.py
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
def load_all_experiments_from_file(self, path: Union[Path, str]) -> int:
    """Load all datasets from a file as separate experiments sharing the current model.

    For a multi-dataset ORSO file (e.g. a multi-angle measurement), each dataset is
    registered as an independent experiment.  All experiments share the model that is
    currently selected.  Single-dataset files go through
    :meth:`load_new_experiment`.  A corrupt ORSO file raises (no silent
    plain-text fallback).

    Parameters
    ----------
    path : Union[Path, str]
        Path to the data file.

    Returns
    -------
    int
        Number of experiments that were added.
    """
    data_group = load_measurement_file(str(path))

    data_keys = sorted(data_group['data'].keys())
    if len(data_keys) <= 1:
        self.load_new_experiment(path, data_group=data_group)
        return 1

    # Every dataset is built before any is added, so a failure adds none.
    new_experiments = []
    for offset, data_key in enumerate(data_keys):
        fallback_name = f'Experiment {len(self._experiments) + offset}'
        new_experiment = dataset_from_datagroup(data_group, data_key=data_key)
        new_experiment.name = fallback_name
        self._apply_experiment_metadata(path, new_experiment, fallback_name, data_group=data_group, data_key=data_key)
        new_experiments.append(new_experiment)

    model = self.models[self._current_model_index]
    for new_experiment in new_experiments:
        self._bind_loaded_dataset(new_experiment, model)
        self._experiments[len(self._experiments)] = new_experiment
    self._with_experiments = True
    return len(data_keys)

load_experiment_from_files(paths, model_index=None, name=None)

Load several files measured on one contrast as a single experiment.

A contrast is often measured as several curves -- one per incident angle or wavelength band -- each with its own q-resolution. The files are concatenated (sorted by q) into one experiment whose every point keeps the resolution it was measured with, so the fit smears each point correctly. See :func:easyreflectometry.data.merge_datasets.

Parameters:

Name Type Description Default
paths List[Union[Path, str]]

The files, one dataset each.

required
model_index Optional[int]

Index of the model the experiment belongs to. By default, the current model.

None
name Optional[str]

Name of the experiment. By default, the ORSO title of the first file, or 'Experiment N'.

None

Returns:

Type Description
int

Index of the newly loaded experiment.

Source code in src/easyreflectometry/project.py
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
def load_experiment_from_files(
    self,
    paths: List[Union[Path, str]],
    model_index: Optional[int] = None,
    name: Optional[str] = None,
) -> int:
    """Load several files measured on one contrast as a single experiment.

    A contrast is often measured as several curves -- one per incident
    angle or wavelength band -- each with its own q-resolution. The files
    are concatenated (sorted by q) into one experiment whose every point
    keeps the resolution it was measured with, so the fit smears each
    point correctly. See :func:`easyreflectometry.data.merge_datasets`.

    Parameters
    ----------
    paths : List[Union[Path, str]]
        The files, one dataset each.
    model_index : Optional[int], optional
        Index of the model the experiment belongs to. By default, the
        current model.
    name : Optional[str], optional
        Name of the experiment. By default, the ORSO title of the first
        file, or ``'Experiment N'``.

    Returns
    -------
    int
        Index of the newly loaded experiment.
    """
    if not paths:
        raise ValueError('At least one file is required.')
    if model_index is None:
        model_index = self._current_model_index
    model = self.models[model_index]
    new_index = len(self._experiments)

    datasets = [self._load_single_dataset(path) for path in paths]
    # 'Series' is the DataSet1D default that _apply_experiment_metadata
    # replaces by the ORSO title of the first file, or else the fallback.
    experiment = merge_datasets(
        datasets, name='Series' if name is None else name, fill_resolution=self._fill_resolution_for(model)
    )
    if name is None:
        self._apply_experiment_metadata(paths[0], experiment, f'Experiment {new_index}')

    # The merged resolution is kept as merge_datasets built it from the
    # inputs' own resolutions; only a model no other experiment uses follows it.
    configure_model = not self._model_in_use(model)
    experiment.model = model
    if configure_model:
        self._auto_set_background(experiment)
        model.resolution_function = (
            PercentageFwhm(5.0) if experiment.resolution_function is None else experiment.resolution_function
        )
    self._experiments[new_index] = experiment
    self._with_experiments = True
    return new_index

append_to_experiment_at_index(index, path)

Add another measured curve of the same contrast to an existing experiment.

The file's points are merged into the experiment at index (sorted by q), each keeping its own resolution: the file's measured one, and the experiment's resolution_function as currently assigned. The experiment's name and model are kept.

Parameters:

Name Type Description Default
index int

Index of the experiment to extend.

required
path Union[Path, str]

The file to add, one dataset.

required

Raises:

Type Description
IndexError

No experiment is loaded at index.

ValueError

The experiment is polarized (extend its channels one file at a time instead), or the file holds several datasets.

Source code in src/easyreflectometry/project.py
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
def append_to_experiment_at_index(self, index: int, path: Union[Path, str]) -> None:
    """Add another measured curve of the same contrast to an existing experiment.

    The file's points are merged into the experiment at *index* (sorted by
    q), each keeping its own resolution: the file's measured one, and the
    experiment's ``resolution_function`` as currently assigned. The
    experiment's name and model are kept.

    Parameters
    ----------
    index : int
        Index of the experiment to extend.
    path : Union[Path, str]
        The file to add, one dataset.

    Raises
    ------
    IndexError
        No experiment is loaded at *index*.
    ValueError
        The experiment is polarized (extend its channels one file at a
        time instead), or the file holds several datasets.
    """
    if index not in self._experiments:
        raise IndexError(f'No experiment at index {index}')
    experiment = self._experiments[index]
    if isinstance(experiment, PolarizedDataSet):
        raise ValueError('Cannot append a file to a polarized experiment; its channels are separate datasets.')
    model = experiment.model
    addition = self._load_single_dataset(path)
    merged = merge_datasets([experiment, addition], name=experiment.name, fill_resolution=self._fill_resolution_for(model))
    merged.model = model
    merged.include_in_fit = experiment.include_in_fit
    # merge_datasets kept the experiment's own resolution (measured or
    # explicitly assigned) for its points; the model follows the merged
    # one unless another experiment uses it. Without any, the experiment
    # keeps using the model's as it is.
    if model is not None and merged.resolution_function is not None and not self._model_in_use(model, ignoring=experiment):
        model.resolution_function = merged.resolution_function
    self._experiments[index] = merged

suggest_polarized_channel_assignment(paths)

Suggest a spin-channel assignment for a set of data files.

For each file, the ORSO header polarization is used when present, falling back to filename heuristics ('_uu'/'_up' → pp, '_dd'/'_down' → mm, ...). Files that cannot be identified map to None; a GUI should present the result as an editable file → channel table.

Parameters:

Name Type Description Default
paths List[Union[Path, str]]

Paths of the per-channel data files.

required

Returns:

Type Description
Dict[str, Optional[PolarizationChannel]]

Detected channel (or None) per path.

Source code in src/easyreflectometry/project.py
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
def suggest_polarized_channel_assignment(self, paths: List[Union[Path, str]]) -> Dict[str, Optional[PolarizationChannel]]:
    """Suggest a spin-channel assignment for a set of data files.

    For each file, the ORSO header polarization is used when present, falling
    back to filename heuristics ('_uu'/'_up' → pp, '_dd'/'_down' → mm, ...).
    Files that cannot be identified map to None; a GUI should present the
    result as an editable file → channel table.

    Parameters
    ----------
    paths : List[Union[Path, str]]
        Paths of the per-channel data files.

    Returns
    -------
    Dict[str, Optional[PolarizationChannel]]
        Detected channel (or None) per path.
    """
    return {str(path): detect_polarization_channel(str(path)) for path in paths}

load_polarized_experiment(paths, model_index=None)

Load a polarized experiment from one data file per spin channel.

The channel datasets form a single :class:PolarizedDataSet experiment sharing one model. Two-channel NSF-only experiments simply pass 'pp' and 'mm' entries; spin-flip channels are optional.

Parameters:

Name Type Description Default
paths Dict[Union[PolarizationChannel, str], Union[Path, str]]

Explicit channel → file assignment (e.g. from the GUI import dialog, pre-filled via :meth:suggest_polarized_channel_assignment).

required
model_index Optional[int]

Index of the model the experiment belongs to. By default, the current model.

None

Returns:

Type Description
int

Index of the newly loaded experiment, so a caller can make it current.

Source code in src/easyreflectometry/project.py
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
def load_polarized_experiment(
    self,
    paths: Dict[Union[PolarizationChannel, str], Union[Path, str]],
    model_index: Optional[int] = None,
) -> int:
    """Load a polarized experiment from one data file per spin channel.

    The channel datasets form a single :class:`PolarizedDataSet` experiment
    sharing one model. Two-channel NSF-only experiments simply pass 'pp' and
    'mm' entries; spin-flip channels are optional.

    Parameters
    ----------
    paths : Dict[Union[PolarizationChannel, str], Union[Path, str]]
        Explicit channel → file assignment (e.g. from the GUI import dialog,
        pre-filled via :meth:`suggest_polarized_channel_assignment`).
    model_index : Optional[int], optional
        Index of the model the experiment belongs to. By default, the
        current model.

    Returns
    -------
    int
        Index of the newly loaded experiment, so a caller can make it
        current.
    """
    paths = {PolarizationChannel(channel): path for channel, path in paths.items()}
    channels = {}
    title_data_group = None
    for channel, path in paths.items():
        # Parse each file once: dataset count, data, and title all come
        # from the same DataGroup (previously up to 3-4 parses per file).
        data_group = load_measurement_file(str(path))
        # One file per channel means one dataset per file; a multi-dataset ORSO
        # file has no defined channel-to-dataset assignment here.
        if len(data_group['data']) > 1:
            raise ValueError(
                f"File '{path}' contains multiple datasets; use load_polarized_experiment_from_file "
                f'for a single multi-dataset file, or export the {channel.value} channel to its own file.'
            )
        dataset = load_as_dataset(str(path), data_group=data_group)
        # Keep the source file visible per channel (file → channel provenance).
        dataset.name = f'{channel.value}: {Path(path).name}'
        channels[channel] = dataset
        if title_data_group is None:
            title_data_group = data_group

    first_path = next(iter(paths.values()))
    return self._register_polarized_experiment(channels, model_index, str(first_path), title_data_group)

load_polarized_experiment_from_file(path, model_index=None)

Load a polarized experiment from a single multi-dataset ORSO file.

Multi-dataset packing is the ORSO format's intended way to store spin states: each data_set: block is classified by its own header (instrument_settings.polarization), so per-dataset overrides are honoured. Only the fully-analysed cross-sections pp/pm/mp/mm are mapped; po/mo/op/om/unpolarized/vector are not coerced.

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the multi-dataset ORSO file.

required
model_index Optional[int]

Index of the model the experiment belongs to. By default, the current model.

None

Returns:

Type Description
int

Index of the newly loaded experiment.

Raises:

Type Description
ValueError :

If any dataset lacks a mappable polarization header, or two datasets declare the same channel. Such files cannot be classified unambiguously; use :meth:load_all_experiments_from_file (e.g. for multi-angle files) or per-channel files instead.

Source code in src/easyreflectometry/project.py
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
def load_polarized_experiment_from_file(
    self,
    path: Union[Path, str],
    model_index: Optional[int] = None,
) -> int:
    """Load a polarized experiment from a single multi-dataset ORSO file.

    Multi-dataset packing is the ORSO format's intended way to store spin
    states: each ``data_set:`` block is classified by its **own** header
    (``instrument_settings.polarization``), so per-dataset overrides are
    honoured. Only the fully-analysed cross-sections ``pp/pm/mp/mm`` are
    mapped; ``po/mo/op/om/unpolarized/vector`` are not coerced.

    Parameters
    ----------
    path : Union[Path, str]
        Path to the multi-dataset ORSO file.
    model_index : Optional[int], optional
        Index of the model the experiment belongs to. By default, the
        current model.

    Returns
    -------
    int
        Index of the newly loaded experiment.

    Raises
    ------
    ValueError :
        If any dataset lacks a mappable polarization header, or two
        datasets declare the same channel. Such files cannot be classified
        unambiguously; use :meth:`load_all_experiments_from_file` (e.g. for
        multi-angle files) or per-channel files instead.
    """
    from easyreflectometry.orso_utils import _load_orso_any
    from easyreflectometry.orso_utils import _orso_dataset_key
    from easyreflectometry.orso_utils import load_orso_data

    orso_data = _load_orso_any(str(path))
    classified = detect_polarization_channels_per_dataset(orso_data)

    channels = {}
    for i, (declared, channel) in enumerate(classified):
        label = _orso_dataset_key(orso_data[i], i)
        if channel is None:
            reason = 'declares no mappable spin channel' if declared else 'declares no polarization'
            raise ValueError(
                f"Dataset '{label}' in '{path}' {reason} "
                f'(only pp/pm/mp/mm are mapped; po/mo/op/om/unpolarized are not coerced). '
                f'Use load_all_experiments_from_file for non-polarized multi-dataset files, '
                f'or assign channels explicitly via load_polarized_experiment.'
            )
        if channel in channels:
            raise ValueError(
                f"Duplicate spin channel '{channel.value}' in '{path}': more than one dataset "
                f'declares it. Assign channels explicitly via load_polarized_experiment.'
            )
        channels[channel] = label

    data_group = load_orso_data(orso_data)
    channel_datasets = {}
    for channel, label in channels.items():
        dataset = dataset_from_datagroup(data_group, data_key=f'R_{label}')
        dataset.name = f'{channel.value}: {label}'
        channel_datasets[channel] = dataset

    return self._register_polarized_experiment(channel_datasets, model_index, str(path), data_group)

save_experiment_as_orso(path, index=None)

Write an experiment to an ORSO file (.ort text, or .orb binary).

The experiment's model (when set) is exported as the ORSO sample.model (slab representation); data columns are written as sigma. A polarized experiment becomes one file with one data_set: block per spin channel.

Parameters:

Name Type Description Default
path Union[Path, str]

Destination path; a .orb extension selects the binary format.

required
index Optional[int]

Index of the experiment to save. By default, the current one.

None
Source code in src/easyreflectometry/project.py
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
def save_experiment_as_orso(self, path: Union[Path, str], index: Optional[int] = None) -> None:
    """Write an experiment to an ORSO file (`.ort` text, or `.orb` binary).

    The experiment's model (when set) is exported as the ORSO
    ``sample.model`` (slab representation); data columns are written as
    sigma. A polarized experiment becomes one file with one ``data_set:``
    block per spin channel.

    Parameters
    ----------
    path : Union[Path, str]
        Destination path; a ``.orb`` extension selects the binary format.
    index : Optional[int], optional
        Index of the experiment to save. By default, the current one.
    """
    if index is None:
        index = self._current_experiment_index
    experiment = self._experiments[index]
    save_orso_experiment(experiment, str(path), model=experiment.model)

load_experiment_for_model_at_index(path, index=0)

Load experiment for model at index.

Source code in src/easyreflectometry/project.py
1908
1909
1910
1911
1912
1913
1914
1915
1916
def load_experiment_for_model_at_index(self, path: Union[Path, str], index: Optional[int] = 0) -> None:
    """Load experiment for model at index."""
    data_group = load_measurement_file(str(path))
    experiment = load_as_dataset(str(path), data_group=data_group)

    self._apply_experiment_metadata(path, experiment, f'Experiment {index}', data_group=data_group)
    self._bind_loaded_dataset(experiment, self.models[index], replacing=self._experiments.get(index))
    self._experiments[index] = experiment
    self._with_experiments = True

sld_data_for_model_at_index(index=0)

Sld data for model at index.

Source code in src/easyreflectometry/project.py
1930
1931
1932
1933
1934
1935
1936
1937
1938
def sld_data_for_model_at_index(self, index: int = 0) -> DataSet1D:
    """Sld data for model at index."""
    self._bind_calculator(self.models[index])
    sld = self.models[index].interface().sld_profile(self._models[index].unique_name)
    return DataSet1D(
        name=f'SLD for Model {index}',
        x=sld[0],
        y=sld[1],
    )

model_has_magnetism_at_index(index=0)

Whether the model at index carries magnetism (False when there is no such model).

Source code in src/easyreflectometry/project.py
1940
1941
1942
1943
1944
1945
def model_has_magnetism_at_index(self, index: int = 0) -> bool:
    """Whether the model at index carries magnetism (False when there is no such model)."""
    try:
        return bool(self.models[index].has_magnetism)
    except (IndexError, AttributeError):
        return False

magnetic_sld_data_for_model_at_index(index=0)

Nuclear and magnetic depth profiles of a magnetic model.

Parameters:

Name Type Description Default
index int

Index of the model.

0

Returns:

Type Description
Dict[str, DataSet1D]

Profiles versus depth z, keyed:

  • 'sld': nuclear ρ(z), the same curve as :meth:sld_data_for_model_at_index;
  • 'rho_m': magnetic SLD ρM(z);
  • 'theta_m': in-plane moment angle θM(z) in degrees, restricted to the depths that carry a moment — the angle of a zero-length vector is meaningless, so points below :data:MAGNETIC_MOMENT_FLOOR_FRACTION of the largest ρM are left out instead of drawing an arbitrary angle through vacuum;
  • 'rho_m_perp': magnitude of the magnetic SLD transverse to the field, |ρM⊥|(z);
  • 'spin_up' / 'spin_down': nuclear plus / minus the longitudinal magnetic SLD, ρ(z) ± ρM∥(z), with ρM∥ the component along the field (see :func:~easyreflectometry.special.calculations.field_frame_components). These are the potentials of the two spin states only when the moment is collinear with the field and the applied field is zero; with a transverse moment the eigenmodes split by ±|ρM| instead. The applied field is not included.

plus the floats 'guide_field_angle' and 'applied_field' of the model.

Raises:

Type Description
ValueError

The model has no magnetic layer, so there is no magnetic profile.

NotImplementedError

The active calculator cannot model magnetism.

Source code in src/easyreflectometry/project.py
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
def magnetic_sld_data_for_model_at_index(self, index: int = 0) -> Dict[str, Union[DataSet1D, float]]:
    """Nuclear and magnetic depth profiles of a magnetic model.

    Parameters
    ----------
    index : int
        Index of the model.

    Returns
    -------
    Dict[str, DataSet1D]
        Profiles versus depth z, keyed:

        - ``'sld'``: nuclear ρ(z), the same curve as
          :meth:`sld_data_for_model_at_index`;
        - ``'rho_m'``: magnetic SLD ρM(z);
        - ``'theta_m'``: in-plane moment angle θM(z) in degrees, restricted
          to the depths that carry a moment — the angle of a zero-length
          vector is meaningless, so points below
          :data:`MAGNETIC_MOMENT_FLOOR_FRACTION` of the largest ρM are left
          out instead of drawing an arbitrary angle through vacuum;
        - ``'rho_m_perp'``: magnitude of the magnetic SLD transverse to the
          field, |ρM⊥|(z);
        - ``'spin_up'`` / ``'spin_down'``: nuclear plus / minus the
          longitudinal magnetic SLD, ρ(z) ± ρM∥(z), with ρM∥ the component
          along the field (see
          :func:`~easyreflectometry.special.calculations.field_frame_components`).
          These are the potentials of the two spin states only when the
          moment is collinear with the field and the applied field is zero;
          with a transverse moment the eigenmodes split by ±|ρM| instead.
          The applied field is not included.

        plus the floats ``'guide_field_angle'`` and ``'applied_field'`` of the model.

    Raises
    ------
    ValueError
        The model has no magnetic layer, so there is no magnetic profile.
    NotImplementedError
        The active calculator cannot model magnetism.
    """
    model = self.models[index]
    if not model.has_magnetism:
        raise ValueError(
            f'Model {index} has no magnetic layer; there is no magnetic SLD profile to show. '
            'Attach magnetism to a layer first.'
        )
    self._bind_calculator(model)
    z, sld, rho_m, theta_m = model.interface().magnetic_sld_profile(model.unique_name)
    z = np.asarray(z, dtype=float)
    sld = np.asarray(sld, dtype=float)
    rho_m = np.asarray(rho_m, dtype=float)
    theta_m = np.asarray(theta_m, dtype=float)
    guide_field_angle = float(model.guide_field_angle.value)
    parallel, perpendicular = field_frame_components(rho_m, theta_m, guide_field_angle)
    # Only report the angle where there is a moment to have an angle, and
    # make it continuous along z: 359° followed by 1° is a 2° turn, but a
    # plotted line through the wrapped values sweeps the whole circle.
    magnitude = np.abs(rho_m)
    has_moment = magnitude > MAGNETIC_MOMENT_FLOOR_FRACTION * magnitude.max(initial=0.0)
    theta_display = _unwrapped_angle(theta_m, has_moment)
    return {
        'sld': DataSet1D(name=f'SLD for Model {index}', x=z, y=sld),
        'rho_m': DataSet1D(name=f'Magnetic SLD for Model {index}', x=z, y=rho_m),
        'theta_m': DataSet1D(name=f'Moment angle for Model {index}', x=z[has_moment], y=theta_display[has_moment]),
        'rho_m_perp': DataSet1D(name=f'Transverse magnetic SLD for Model {index}', x=z, y=perpendicular),
        'spin_up': DataSet1D(name=f'Nuclear + longitudinal magnetic SLD for Model {index}', x=z, y=sld + parallel),
        'spin_down': DataSet1D(name=f'Nuclear - longitudinal magnetic SLD for Model {index}', x=z, y=sld - parallel),
        'guide_field_angle': guide_field_angle,
        'applied_field': float(model.applied_field.value),
    }

volume_fraction_data_for_model_at_index(index=0, *, z=None, max_delta_z=None, merge_equivalent=True, groups=None)

Volume fraction (occupancy) depth profiles of the components of a model.

Built from the sample alone, so no calculator binding is needed. See :func:easyreflectometry.sample.volume_fraction.volume_fraction_profile for the depth convention and the caveats.

Parameters:

Name Type Description Default
index int

Index of the model.

0
z np.ndarray

Depths (Å) to evaluate at; z = 0 is the superphase / first layer interface. By default a 500-point grid spanning the sample, the same grid the refnx calculator uses for its SLD profile.

None
max_delta_z float

Maximum spacing of the default grid. Cannot be combined with z.

None
merge_equivalent bool

Merge materials with equal name and SLD into one component. By default, True.

True
groups Mapping[str, Sequence[str]]

Presentation groups forwarded to :meth:~easyreflectometry.sample.VolumeFractionProfile.grouped, e.g. {'Water': ['D2O', 'H2O']}.

None

Returns:

Type Description
Dict[str, DataSet1D]

One profile per component, keyed by its stable component key (or group label) in front-to-back order; the display label is in the dataset's name. The y values sum to one at every x.

Source code in src/easyreflectometry/project.py
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
def volume_fraction_data_for_model_at_index(
    self,
    index: int = 0,
    *,
    z: Optional[np.ndarray] = None,
    max_delta_z: Optional[float] = None,
    merge_equivalent: bool = True,
    groups: Optional[Mapping[str, Sequence[str]]] = None,
) -> Dict[str, DataSet1D]:
    """Volume fraction (occupancy) depth profiles of the components of a model.

    Built from the sample alone, so no calculator binding is needed. See
    :func:`easyreflectometry.sample.volume_fraction.volume_fraction_profile`
    for the depth convention and the caveats.

    Parameters
    ----------
    index : int
        Index of the model.
    z : np.ndarray, optional
        Depths (Å) to evaluate at; ``z = 0`` is the superphase / first
        layer interface. By default a 500-point grid spanning the sample,
        the same grid the refnx calculator uses for its SLD profile.
    max_delta_z : float, optional
        Maximum spacing of the default grid. Cannot be combined with ``z``.
    merge_equivalent : bool, optional
        Merge materials with equal name and SLD into one component. By default, True.
    groups : Mapping[str, Sequence[str]], optional
        Presentation groups forwarded to
        :meth:`~easyreflectometry.sample.VolumeFractionProfile.grouped`,
        e.g. ``{'Water': ['D2O', 'H2O']}``.

    Returns
    -------
    Dict[str, DataSet1D]
        One profile per component, keyed by its stable component key (or
        group label) in front-to-back order; the display label is in the
        dataset's ``name``. The ``y`` values sum to one at every ``x``.
    """
    model = self.models[index]
    profile = volume_fraction_profile(model.sample, z, max_delta_z=max_delta_z, merge_equivalent=merge_equivalent)
    if groups:
        profile = profile.grouped(groups)
    return {
        key: DataSet1D(
            name=f'{profile.labels[key]} volume fraction for Model {index}',
            x=profile.z,
            y=values,
            x_label='z (Å)',
            y_label='Volume fraction',
        )
        for key, values in profile.fractions.items()
    }

sample_data_for_model_at_index(index=0, q_range=None)

The unsmeared reflectivity of the model at index: the sample alone, no resolution.

The zero-width resolution is bound to this one calculation; the model's own resolution, which is calculator state shared with a fit possibly running in another thread, is not touched.

Source code in src/easyreflectometry/project.py
2073
2074
2075
2076
2077
2078
2079
2080
def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D:
    """The unsmeared reflectivity of the model at `index`: the sample alone, no resolution.

    The zero-width resolution is bound to this one calculation; the
    model's own resolution, which is calculator state shared with a fit
    possibly running in another thread, is not touched.
    """
    return self.model_data_for_model_at_index(index, q_range, resolution_function=PercentageFwhm(0))

model_data_for_model_at_index(index=0, q_range=None, channel=None, resolution_function=None)

Model data for model at index.

Parameters:

Name Type Description Default
index int

Index of the model.

0
q_range Optional[np.array]

Points to calculate at; the project q range by default.

None
channel Optional[Union[PolarizationChannel, str]]

Spin cross-section to calculate. None (the default) gives the ordinary, channel-agnostic reflectivity. A channel requires a magnetic model (except 'pp', which falls back to the unpolarized calculation) and a calculator supporting magnetism, otherwise the calculator raises.

None
resolution_function Optional[ResolutionFunction]

Resolution to smear with; the model's by default. Pass a dataset's own resolution to get the curve the fit compares that dataset to (see :meth:model_data_for_experiment_at_index).

None
Source code in src/easyreflectometry/project.py
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
def model_data_for_model_at_index(
    self,
    index: int = 0,
    q_range: Optional[np.array] = None,
    channel: Optional[Union[PolarizationChannel, str]] = None,
    resolution_function: Optional[ResolutionFunction] = None,
) -> DataSet1D:
    """Model data for model at index.

    Parameters
    ----------
    index : int
        Index of the model.
    q_range : Optional[np.array]
        Points to calculate at; the project q range by default.
    channel : Optional[Union[PolarizationChannel, str]]
        Spin cross-section to calculate. `None` (the default) gives the
        ordinary, channel-agnostic reflectivity. A channel requires a
        magnetic model (except 'pp', which falls back to the unpolarized
        calculation) and a calculator supporting magnetism, otherwise the
        calculator raises.
    resolution_function : Optional[ResolutionFunction]
        Resolution to smear with; the model's by default. Pass a dataset's
        own resolution to get the curve the fit compares that dataset to
        (see :meth:`model_data_for_experiment_at_index`).
    """
    if q_range is None:
        q_range = np.linspace(self.q_min, self.q_max, self.q_resolution)
    self._bind_calculator(self.models[index])
    interface = self.models[index].interface()
    unique_name = self._models[index].unique_name
    if channel is None:
        reflectivity = interface.reflectity_profile(q_range, unique_name, resolution_function=resolution_function)
        name = f'Reflectivity for Model {index}'
    else:
        channel = PolarizationChannel(channel)
        reflectivity = interface.reflectivity_profile_channel(
            q_range, unique_name, channel, resolution_function=resolution_function
        )
        name = f'Reflectivity ({channel.value}) for Model {index}'
    return DataSet1D(
        name=name,
        x=q_range,
        y=reflectivity,
    )

model_data_for_experiment_at_index(index=0, channel=None, q_range=None)

The model curve an experiment is compared to: its model, at its q, with its resolution.

Parameters:

Name Type Description Default
index int

Index of the experiment.

0
channel Optional[Union[PolarizationChannel, str]]

Spin channel of a polarized experiment; required for one, not allowed for an unpolarized one.

None
q_range Optional[np.array]

Points to calculate at; the experiment's own q by default.

None

Raises:

Type Description
IndexError

No experiment is loaded at index.

ValueError

The experiment has no model, or channel does not match the experiment's kind.

Source code in src/easyreflectometry/project.py
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
def model_data_for_experiment_at_index(
    self,
    index: int = 0,
    channel: Optional[Union[PolarizationChannel, str]] = None,
    q_range: Optional[np.array] = None,
) -> DataSet1D:
    """The model curve an experiment is compared to: its model, at its q, with its resolution.

    Parameters
    ----------
    index : int
        Index of the experiment.
    channel : Optional[Union[PolarizationChannel, str]]
        Spin channel of a polarized experiment; required for one, not
        allowed for an unpolarized one.
    q_range : Optional[np.array]
        Points to calculate at; the experiment's own q by default.

    Raises
    ------
    IndexError
        No experiment is loaded at `index`.
    ValueError
        The experiment has no model, or `channel` does not match the
        experiment's kind.
    """
    experiment = self.experimental_data_for_model_at_index(index)
    if isinstance(experiment, PolarizedDataSet):
        if channel is None:
            raise ValueError(f'Experiment at index {index} is polarized; a channel is required.')
        dataset = experiment[channel]
    else:
        if channel is not None:
            raise ValueError(f'Experiment at index {index} is not polarized; it has no channel.')
        dataset = experiment
    model = experiment.model
    if model is None:
        raise ValueError(f'Experiment at index {index} has no model.')
    model_index = next(i for i, candidate in enumerate(self.models) if candidate is model)
    return self.model_data_for_model_at_index(
        model_index,
        q_range=np.asarray(dataset.x) if q_range is None else q_range,
        channel=channel,
        resolution_function=getattr(dataset, 'resolution_function', None),
    )

experimental_data_for_model_at_index(index=0, channel=None)

Experimental data for model at index.

Parameters:

Name Type Description Default
index int

Index of the experiment.

0
channel Optional[Union[PolarizationChannel, str]]

Spin channel of a polarized experiment. None (the default) keeps the historical behavior and returns the stored experiment as is: a DataSet1D for an unpolarized experiment, the whole PolarizedDataSet for a polarized one.

None

Returns:

Type Description
Union[DataSet1D, PolarizedDataSet]

The experiment, or the DataSet1D of the requested channel.

Raises:

Type Description
IndexError

No experiment is loaded at index.

KeyError

channel is a valid spin channel but was not measured.

ValueError

channel was given for an unpolarized experiment, or is not one of 'pp', 'pm', 'mp', 'mm'.

Source code in src/easyreflectometry/project.py
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
def experimental_data_for_model_at_index(
    self,
    index: int = 0,
    channel: Optional[Union[PolarizationChannel, str]] = None,
) -> Union[DataSet1D, PolarizedDataSet]:
    """Experimental data for model at index.

    Parameters
    ----------
    index : int
        Index of the experiment.
    channel : Optional[Union[PolarizationChannel, str]]
        Spin channel of a polarized experiment. `None` (the default) keeps
        the historical behavior and returns the stored experiment as is: a
        `DataSet1D` for an unpolarized experiment, the whole
        `PolarizedDataSet` for a polarized one.

    Returns
    -------
    Union[DataSet1D, PolarizedDataSet]
        The experiment, or the `DataSet1D` of the requested channel.

    Raises
    ------
    IndexError
        No experiment is loaded at `index`.
    KeyError
        `channel` is a valid spin channel but was not measured.
    ValueError
        `channel` was given for an unpolarized experiment, or is not one of
        'pp', 'pm', 'mp', 'mm'.
    """
    if index not in self._experiments.keys():
        raise IndexError(f'No experiment data for model at index {index}')

    experiment = self._experiments[index]
    if channel is None:
        return experiment

    if not isinstance(experiment, PolarizedDataSet):
        raise ValueError(
            f"Experiment at index {index} is not polarized; it has no '{channel}' channel. "
            'Call without a channel argument to get its data.'
        )
    try:
        channel = PolarizationChannel(channel)
    except ValueError as exception:
        known = ', '.join(member.value for member in PolarizationChannel)
        raise ValueError(f"Unknown spin channel '{channel}'; expected one of {known}.") from exception
    if channel not in experiment:
        measured = ', '.join(member.value for member in experiment.available_channels)
        raise KeyError(f"Channel '{channel.value}' was not measured in experiment {index} (measured: {measured}).")
    return experiment[channel]

experiment_is_polarized_at_index(index=0)

Whether the experiment at index holds per-channel (polarized) data.

Returns False when no experiment is loaded at index, so consumers can use it as a plain predicate.

Source code in src/easyreflectometry/project.py
2228
2229
2230
2231
2232
2233
2234
def experiment_is_polarized_at_index(self, index: int = 0) -> bool:
    """Whether the experiment at index holds per-channel (polarized) data.

    Returns False when no experiment is loaded at `index`, so consumers can
    use it as a plain predicate.
    """
    return isinstance(self._experiments.get(index), PolarizedDataSet)

experiment_channels_at_index(index=0)

Measured spin channels of the experiment at index ([] when unpolarized).

Source code in src/easyreflectometry/project.py
2236
2237
2238
2239
2240
2241
def experiment_channels_at_index(self, index: int = 0) -> List[PolarizationChannel]:
    """Measured spin channels of the experiment at index ([] when unpolarized)."""
    experiment = self._experiments.get(index)
    if not isinstance(experiment, PolarizedDataSet):
        return []
    return experiment.available_channels

experiment_supports_spin_asymmetry_at_index(index=0)

Whether SA can be formed for the experiment at index.

Spin asymmetry needs both non-spin-flip channels; an NSF-incomplete or spin-flip-only experiment has no SA. The two channel datasets must also be structurally usable (non-empty, matching lengths, a strictly ordered q grid) — an application asks this before offering a spin-asymmetry view, so a dataset that cannot be turned into one must not be advertised and then fail.

Source code in src/easyreflectometry/project.py
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
def experiment_supports_spin_asymmetry_at_index(self, index: int = 0) -> bool:
    """Whether SA can be formed for the experiment at index.

    Spin asymmetry needs both non-spin-flip channels; an NSF-incomplete or
    spin-flip-only experiment has no SA. The two channel datasets must also
    be structurally usable (non-empty, matching lengths, a strictly ordered
    q grid) — an application asks this before offering a spin-asymmetry
    view, so a dataset that cannot be turned into one must not be advertised
    and then fail.
    """
    channels = self.experiment_channels_at_index(index)
    if not _has_both_nsf_channels(channels):
        return False
    experiment = self._experiments[index]
    try:
        for channel in (PolarizationChannel.PP, PolarizationChannel.MM):
            _ordered_channel_arrays(experiment[channel])
    except ValueError as exception:
        logger.warning('Experiment %s cannot produce a spin asymmetry: %s', index, exception)
        return False
    return True

spin_asymmetry_for_experiment_at_index(index=0)

Spin asymmetry SA = (R⁺⁺ − R⁻⁻) / (R⁺⁺ + R⁻⁻) of a polarized experiment.

The measured SA is formed on the q grid of the pp channel. A mm channel measured on a different grid is linearly interpolated onto it — values with the usual weights, variances with the squared weights, which is the propagation rule for independent endpoints (the covariance this introduces between neighbouring SA points is not representable in DataSet1D and is discarded). Interpolation happens only inside the q range both channels cover: np.interp would otherwise clamp to the edge value and turn extrapolated points into fabricated measurements.

Points are dropped when the denominator cannot carry a meaningful asymmetry:

  • it is not positive, or is lost to cancellation between the two channels (see :data:SPIN_ASYMMETRY_CANCELLATION_FRACTION) — this guard needs no uncertainties and is what keeps a background-subtracted tail from throwing ±10³ values onto the axis;
  • it is not above :data:SPIN_ASYMMETRY_SIGNIFICANCE times its own uncertainty, when the channels carry usable uncertainties;
  • the point itself is not usable (non-finite reflectivity, or a negative or non-finite variance).

Parameters:

Name Type Description Default
index int

Index of the experiment.

0

Returns:

Type Description
Dict[str, object]
  • 'measured': DataSet1D of SA versus q. As everywhere in this library, ye holds variances, not standard deviations.
  • 'calculated': DataSet1D of the model SA on the same q points, or None when the model is not magnetic (an unpolarized model has SA ≡ 0, which is not worth drawing).
  • 'masked_points': how many measured points were dropped for any of the reasons above.
  • 'low_significance_points': of those, how many had a denominator below the significance threshold.
  • 'small_denominator_points': of those, how many had a denominator that was non-positive or lost to cancellation.
  • 'invalid_points': of those, how many carried a non-finite reflectivity or an unusable variance.
  • 'out_of_overlap_points': number of pp points dropped because the mm channel does not cover their q.

Raises:

Type Description
IndexError

No experiment is loaded at index.

ValueError

The experiment does not carry both pp and mm channels, or their datasets are not structurally usable.

Source code in src/easyreflectometry/project.py
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
def spin_asymmetry_for_experiment_at_index(self, index: int = 0) -> Dict[str, object]:
    """Spin asymmetry SA = (R⁺⁺ − R⁻⁻) / (R⁺⁺ + R⁻⁻) of a polarized experiment.

    The measured SA is formed on the q grid of the pp channel. A mm channel
    measured on a different grid is linearly interpolated onto it — values
    with the usual weights, variances with the *squared* weights, which is
    the propagation rule for independent endpoints (the covariance this
    introduces between neighbouring SA points is not representable in
    `DataSet1D` and is discarded). Interpolation happens **only inside the q
    range both channels cover**: `np.interp` would otherwise clamp to the
    edge value and turn extrapolated points into fabricated measurements.

    Points are dropped when the denominator cannot carry a meaningful
    asymmetry:

    - it is not positive, or is lost to cancellation between the two
      channels (see :data:`SPIN_ASYMMETRY_CANCELLATION_FRACTION`) — this
      guard needs no uncertainties and is what keeps a background-subtracted
      tail from throwing ±10³ values onto the axis;
    - it is not above :data:`SPIN_ASYMMETRY_SIGNIFICANCE` times its own
      uncertainty, when the channels carry usable uncertainties;
    - the point itself is not usable (non-finite reflectivity, or a negative
      or non-finite variance).

    Parameters
    ----------
    index : int
        Index of the experiment.

    Returns
    -------
    Dict[str, object]
        - ``'measured'``: `DataSet1D` of SA versus q. As everywhere in this
          library, ``ye`` holds **variances**, not standard deviations.
        - ``'calculated'``: `DataSet1D` of the model SA on the same q
          points, or None when the model is not magnetic (an unpolarized
          model has SA ≡ 0, which is not worth drawing).
        - ``'masked_points'``: how many measured points were dropped for any
          of the reasons above.
        - ``'low_significance_points'``: of those, how many had a
          denominator below the significance threshold.
        - ``'small_denominator_points'``: of those, how many had a
          denominator that was non-positive or lost to cancellation.
        - ``'invalid_points'``: of those, how many carried a non-finite
          reflectivity or an unusable variance.
        - ``'out_of_overlap_points'``: number of pp points dropped because
          the mm channel does not cover their q.

    Raises
    ------
    IndexError
        No experiment is loaded at `index`.
    ValueError
        The experiment does not carry both pp and mm channels, or their
        datasets are not structurally usable.
    """
    if index not in self._experiments.keys():
        raise IndexError(f'No experiment data for model at index {index}')
    channels = self.experiment_channels_at_index(index)
    if not _has_both_nsf_channels(channels):
        raise ValueError(f'Experiment {index} does not have both non-spin-flip channels; spin asymmetry needs pp and mm.')

    experiment = self._experiments[index]
    q, r_pp, var_pp = _ordered_channel_arrays(experiment[PolarizationChannel.PP])
    q_mm, r_mm_source, var_mm_source = _ordered_channel_arrays(experiment[PolarizationChannel.MM])

    out_of_overlap = 0
    if q.shape == q_mm.shape and np.allclose(q, q_mm, rtol=1e-9, atol=0.0):
        # The usual case: both channels come from one instrument scan.
        # A tolerance keeps grids that only differ by float round-trips
        # (text files) on this path.
        r_mm, var_mm = r_mm_source, var_mm_source
    else:
        # Different grids: put mm on the pp grid, but only where mm has data
        # — np.interp clamps outside its range, which would silently invent
        # measurements at the edges.
        inside = (q >= q_mm.min()) & (q <= q_mm.max())
        out_of_overlap = int(np.count_nonzero(~inside))
        if out_of_overlap:
            logger.warning(
                'Spin asymmetry of experiment %s: %s of %s pp points lie outside the mm q range '
                '[%.5g, %.5g] and are dropped.',
                index,
                out_of_overlap,
                q.size,
                q_mm.min(),
                q_mm.max(),
            )
        q, r_pp, var_pp = q[inside], r_pp[inside], var_pp[inside]
        r_mm, var_mm = _interpolate_with_variance(q, q_mm, r_mm_source, var_mm_source)

    asymmetry, variance, keep, reasons = _spin_asymmetry(r_pp, r_mm, var_pp, var_mm)
    measured = DataSet1D(
        name=f'Spin asymmetry for Experiment {index}',
        x=q[keep],
        y=asymmetry[keep],
        ye=variance[keep],
        x_label='q (1/angstrom)',
        y_label='Spin asymmetry',
    )

    calculated = None
    model_index = self._model_index_for_experiment(experiment)
    if model_index is not None and self.models[model_index].has_magnetism and measured.x.size > 0:
        calculated_pp = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='pp').y
        calculated_mm = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='mm').y
        calculated_asymmetry, _, _, _ = _spin_asymmetry(calculated_pp, calculated_mm)
        calculated = DataSet1D(
            name=f'Calculated spin asymmetry for Experiment {index}',
            x=measured.x,
            y=calculated_asymmetry,
            x_label='q (1/angstrom)',
            y_label='Spin asymmetry',
        )

    return {
        'measured': measured,
        'calculated': calculated,
        'masked_points': int(np.count_nonzero(~keep)),
        'out_of_overlap_points': out_of_overlap,
        **reasons,
    }

default_model()

Default model.

Source code in src/easyreflectometry/project.py
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
def default_model(self):
    """Default model."""
    self._replace_collection(MaterialCollection(interface=self._calculator), self._materials)

    layers = [
        Layer(
            material=self._materials[0],
            thickness=0.0,
            roughness=0.0,
            name='Vacuum Layer',
            interface=self._calculator,
        ),
        Layer(
            material=self._materials[1],
            thickness=100.0,
            roughness=3.0,
            name='D2O Layer',
            interface=self._calculator,
        ),
        Layer(
            material=self._materials[2],
            thickness=0.0,
            roughness=1.2,
            name='Si Layer',
            interface=self._calculator,
        ),
    ]
    assemblies = [
        Multilayer(layers[0], name='Superphase', interface=self._calculator),
        Multilayer(layers[1], name='D2O', interface=self._calculator),
        Multilayer(layers[2], name='Subphase', interface=self._calculator),
    ]
    sample = Sample(*assemblies, interface=self._calculator)
    model = Model(sample=sample, interface=self._calculator)
    model.is_default = True
    self.models = ModelCollection([model])

is_default_model(index)

Check if the model at the given index is a default model.

Parameters:

Name Type Description Default
index int

Index of the model to check.

required

Returns:

Type Description
bool

True if the model was created as a default placeholder.

Source code in src/easyreflectometry/project.py
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
def is_default_model(self, index: int) -> bool:
    """Check if the model at the given index is a default model.

    Parameters
    ----------
    index : int
        Index of the model to check.

    Returns
    -------
    bool
        True if the model was created as a default placeholder.
    """
    if index < 0 or index >= len(self._models):
        return False

    return self._models[index].is_default

plan_model_removal(index)

What removing the model at index would affect; nothing is changed.

Raises:

Type Description
IndexError :

If the index is out of range.

Source code in src/easyreflectometry/project.py
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
def plan_model_removal(self, index: int) -> 'ModelRemoval':
    """What removing the model at `index` would affect; nothing is changed.

    Raises
    ------
    IndexError :
        If the index is out of range.
    """
    if index < 0 or index >= len(self._models):
        raise IndexError(f'Model index {index} out of range')
    model = self._models[index]
    survivors = [other for other in self._models if other is not model]
    surviving = {id(parameter) for other in survivors for parameter in other.get_all_parameters()}
    # Parameters only this model owns; one shared with a surviving model stays.
    removed = {id(parameter) for parameter in model.get_all_parameters() if id(parameter) not in surviving}
    dependents = [
        parameter
        for other in survivors
        for parameter in other.get_all_parameters()
        if not parameter.independent and any(id(leader) in removed for leader in parameter.dependency_map.values())
    ]
    inequality_constraints = [
        spec
        for spec in self._inequality_constraints
        if any(id(self._parameter_or_none(path)) in removed for path in spec.paths.values())
    ]
    return ModelRemoval(
        experiments=self.experiments_for_model(index),
        dependents=list({id(parameter): parameter for parameter in dependents}.values()),
        inequality_constraints=inequality_constraints,
    )

remove_model_at_index(index, experiments=None)

Remove the model at index, resolving everything that refers to it.

Parameters of other models that follow a parameter only this model owns become independent, keeping their current value (or getting back the one from before the link, when :meth:apply_link made the tie). Inequality constraints on such parameters are removed; the others are re-pathed. Objects the model shares with other models stay with them. See :meth:plan_model_removal.

Parameters:

Name Type Description Default
index int

Index of the model to remove.

required
experiments Union[str, int, None]

What happens to the experiments bound to the model: 'remove' removes them, an int rebinds them to the model at that index (counted before the removal). Required when there are any.

None

Returns:

Type Description
ModelRemoval

What was affected; its remap keys the remaining experiments.

Raises:

Type Description
IndexError :

If the index is out of range.

ValueError :

If trying to remove the last remaining model, or experiments are bound to it and experiments does not say what to do with them. Nothing is changed then.

Source code in src/easyreflectometry/project.py
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
def remove_model_at_index(self, index: int, experiments: Union[str, int, None] = None) -> 'ModelRemoval':
    """Remove the model at `index`, resolving everything that refers to it.

    Parameters of other models that follow a parameter only this model
    owns become independent, keeping their current value (or getting back
    the one from before the link, when :meth:`apply_link` made the tie). Inequality
    constraints on such parameters are removed; the others are re-pathed.
    Objects the model shares with other models stay with them. See
    :meth:`plan_model_removal`.

    Parameters
    ----------
    index : int
        Index of the model to remove.
    experiments : Union[str, int, None], optional
        What happens to the experiments bound to the model: ``'remove'``
        removes them, an int rebinds them to the model at that index
        (counted before the removal). Required when there are any.

    Returns
    -------
    ModelRemoval
        What was affected; its ``remap`` keys the remaining experiments.

    Raises
    ------
    IndexError :
        If the index is out of range.
    ValueError :
        If trying to remove the last remaining model, or experiments are
        bound to it and `experiments` does not say what to do with them.
        Nothing is changed then.
    """
    plan = self.plan_model_removal(index)
    if len(self._models) <= 1:
        raise ValueError('Cannot remove the last model from the project')
    if plan.experiments and experiments != 'remove':
        if not isinstance(experiments, int) or experiments == index or not 0 <= experiments < len(self._models):
            raise ValueError(
                f'Experiments {plan.experiments} use the model; pass experiments="remove" '
                'or the index of another model to bind them to.'
            )

    for parameter in plan.dependents:
        self._untie(parameter)
    removed = self._models[index]
    self._links = [link for link in self._links if removed not in (link.follower, link.reference)]
    self._contrasts = [pair for pair in self._contrasts if removed not in pair]
    remap = {key: key for key in self._experiments}
    if experiments == 'remove':
        for key in reversed(plan.experiments):
            step = self.remove_experiment(key)
            remap = {old: step[new] for old, new in remap.items() if new in step}
    elif plan.experiments:
        for key in plan.experiments:
            self._experiments[key].model = self._models[experiments]
    self._keeping_inequality_paths(lambda: self._models.pop(index))
    self._invalidate_fitter()

    # Adjust current model index if necessary
    if self._current_model_index >= len(self._models):
        self._current_model_index = len(self._models) - 1
    elif self._current_model_index > index:
        self._current_model_index -= 1

    # Reset assembly and layer indices for the new current model
    self._current_assembly_index = 0
    self._current_layer_index = 0
    return ModelRemoval(plan.experiments, plan.dependents, plan.inequality_constraints, remap)

move_model(index, new_index)

Move the model at index to new_index, keeping inequality constraints on their parameters.

Source code in src/easyreflectometry/project.py
2549
2550
2551
2552
2553
2554
2555
2556
def move_model(self, index: int, new_index: int) -> None:
    """Move the model at `index` to `new_index`, keeping inequality constraints on their parameters."""
    if index == new_index:
        return
    current = self._models[self._current_model_index]
    self._keeping_inequality_paths(lambda: self._models.insert(new_index, self._models.pop(index)))
    self._current_model_index = self._model_index(current)
    self._invalidate_fitter()

add_material(material)

Add a material to the project material collection.

:param material: the material to add. :raises ValueError: if the material is already in the collection. The collection is left untouched in that case, so a caller cannot mistake a refused add for a successful one.

Source code in src/easyreflectometry/project.py
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
def add_material(self, material: Material) -> None:
    """Add a material to the project material collection.

    :param material: the material to add.
    :raises ValueError: if the material is already in the collection. The collection is
        left untouched in that case, so a caller cannot mistake a refused add for a
        successful one.
    """
    if material in self._materials:
        raise ValueError(f'Material {material.name} is already in the material collection.')
    self._materials.append(material)

remove_material(index)

Remove the material at index from the project material collection.

:param index: position of the material in the collection. :raises IndexError: if there is no material at index. :raises ValueError: if the material is used by one of the models. Removing it would leave the model referring to a material the project no longer holds, so the collection is left untouched.

Source code in src/easyreflectometry/project.py
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
def remove_material(self, index: int) -> None:
    """Remove the material at *index* from the project material collection.

    :param index: position of the material in the collection.
    :raises IndexError: if there is no material at *index*.
    :raises ValueError: if the material is used by one of the models. Removing it would
        leave the model referring to a material the project no longer holds, so the
        collection is left untouched.
    """
    material = self._materials[index]
    if material in self._get_materials_in_models():
        raise ValueError(f'Material {material.name} is used in models and cannot be removed.')
    self._materials.pop(index)

create()

Create the project directory tree on disk.

:raises FileExistsError: if the project directory already exists. Nothing is written in that case and created stays False, so a caller cannot mistake a collision for a new project.

Source code in src/easyreflectometry/project.py
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
def create(self):
    """Create the project directory tree on disk.

    :raises FileExistsError: if the project directory already exists. Nothing is written in
        that case and `created` stays False, so a caller cannot mistake a collision for a new
        project.
    """
    if os.path.exists(self.path):
        raise FileExistsError(f'Directory {self.path} already exists. Choose a different name or location.')
    os.makedirs(self.path)
    os.makedirs(self.path / 'experiments')
    self._created = True
    self._timestamp_modification()

save_as_json(overwrite=False)

Save the project as json.

The write is atomic: the serialized project is written to a temporary file in the destination directory and then moved into place with os.replace. Any failure while serializing or writing therefore leaves a previously saved project file untouched.

Failures are raised rather than reported on stdout, so that callers (notably the GUI) can tell a successful save from a failed one.

:param overwrite: whether an existing project file may be replaced. :raises FileExistsError: if the project file exists and overwrite is False. :raises ValueError: if the project cannot be serialized, e.g. when a constraint depends on a parameter that is not reachable from the models. :raises OSError: if the file cannot be written or moved into place.

Source code in src/easyreflectometry/project.py
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
def save_as_json(self, overwrite=False):
    """Save the project as json.

    The write is atomic: the serialized project is written to a temporary file in the
    destination directory and then moved into place with `os.replace`. Any failure while
    serializing or writing therefore leaves a previously saved project file untouched.

    Failures are raised rather than reported on stdout, so that callers (notably the GUI)
    can tell a successful save from a failed one.

    :param overwrite: whether an existing project file may be replaced.
    :raises FileExistsError: if the project file exists and `overwrite` is False.
    :raises ValueError: if the project cannot be serialized, e.g. when a constraint depends
        on a parameter that is not reachable from the models.
    :raises OSError: if the file cannot be written or moved into place.
    """
    if self.path_json.exists() and not overwrite:
        raise FileExistsError(f'File already exists {self.path_json}. Pass overwrite=True to replace it.')
    # Serialize before touching the file system, so that a serialization failure
    # leaves no temporary file behind and no doubt about the existing file.
    try:
        project_json = json.dumps(self.as_dict(include_materials_not_in_model=True), indent=4)
    except TypeError as error:
        # json.dumps reports a value it cannot encode (a stray numpy scalar, say) as a
        # TypeError; to the caller that is the same "cannot be serialized" failure.
        raise ValueError(f'The project cannot be serialized: {error}') from error
    self.path_json.parent.mkdir(exist_ok=True, parents=True)
    # The temporary file must share a directory with the destination, otherwise the
    # replace below is not guaranteed to be atomic (and fails across volumes on Windows).
    # The name is not a dot-file so that a leftover is visible while debugging.
    file_descriptor, temporary_name = tempfile.mkstemp(
        dir=self.path_json.parent, prefix=f'{self.path_json.name}.', suffix='.tmp'
    )
    temporary_path = Path(temporary_name)
    try:
        with os.fdopen(file_descriptor, mode='w') as file:
            file.write(project_json)
            file.flush()
            # Rename without fsync can leave an empty file behind after a power loss.
            os.fsync(file.fileno())
        self._apply_project_file_mode(temporary_path)
        os.replace(temporary_path, self.path_json)
    except Exception:
        temporary_path.unlink(missing_ok=True)
        raise
    logger.debug(f'Saved project to {self.path_json}')

load_from_json(path=None)

Load a project file, replacing the current project state.

:param path: the project file; defaults to this project's own path_json. :raises FileNotFoundError: if there is no file at path. The current project is left untouched. :raises ValueError: if the file is not valid JSON or predates the supported file format.

Source code in src/easyreflectometry/project.py
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
def load_from_json(self, path: Optional[Union[Path, str]] = None):
    """Load a project file, replacing the current project state.

    :param path: the project file; defaults to this project's own `path_json`.
    :raises FileNotFoundError: if there is no file at `path`. The current project is left
        untouched.
    :raises ValueError: if the file is not valid JSON or predates the supported file format.
    """
    if path is None:
        path = self.path_json
    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(f'File {path} does not exist')
    with open(path, 'r') as file:
        project_dict = json.load(file)
        self.reset()
        self.from_dict(project_dict)
    self._path_project_parent = path.parents[1]
    self._created = True

as_dict(include_materials_not_in_model=False)

As dict.

Objects used in several places (a material in two layers, an assembly in two models) are written once and referred to elsewhere. The materials palette is written in order under materials. include_materials_not_in_model adds the unused materials once more under their older key, for readers that predate materials.

Source code in src/easyreflectometry/project.py
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
def as_dict(self, include_materials_not_in_model=False):
    """As dict.

    Objects used in several places (a material in two layers, an assembly
    in two models) are written once and referred to elsewhere. The
    materials palette is written in order under ``materials``.
    ``include_materials_not_in_model`` adds the unused materials once more
    under their older key, for readers that predate ``materials``.
    """
    project_dict = {}
    project_dict['file_format'] = self.FILE_FORMAT
    project_dict['info'] = self._info
    project_dict['with_experiments'] = self._with_experiments
    with references.writing():
        if self._models is not None:
            project_dict['models'] = self._models.as_dict()
            project_dict['models']['unique_name'] = self._models.unique_name + '_to_prevent_collisions_on_load'
        project_dict['materials'] = [material.as_dict() for material in self._materials]
    references.prune(project_dict)
    if include_materials_not_in_model:
        self._as_dict_add_materials_not_in_model_dict(project_dict)
    if self._with_experiments:
        self._as_dict_add_experiments(project_dict)
    if self._needs_new_format(project_dict):
        project_dict['file_format'] = self.FILE_FORMAT_SHARED
    project_dict['fit_settings'] = self._fit_settings.to_dict()
    # Kept for readers that predate `fit_settings`.
    project_dict['fitter_minimizer'] = project_dict['fit_settings']['minimizer']
    if self._calculator is not None:
        project_dict['calculator'] = self._calculator.current_interface_name
    if self._colors is not None:
        project_dict['colors'] = self._colors
    if self._inequality_constraints:
        project_dict['inequality_constraints'] = [spec.to_dict() for spec in self._inequality_constraints]
    parameter_constraints = self._user_constraints()
    if parameter_constraints:
        project_dict['parameter_constraints'] = parameter_constraints
    sum_partner_max_backups = self._sum_partner_max_backups()
    if sum_partner_max_backups:
        project_dict['sum_partner_max_backups'] = sum_partner_max_backups
    self._as_dict_add_contrasts(project_dict)
    return project_dict

from_dict(project_dict)

From dict.

Source code in src/easyreflectometry/project.py
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
def from_dict(self, project_dict: dict):
    """From dict."""
    keys = list(project_dict.keys())
    # Validate file format. v1 files were written by the legacy
    # `ObjBase`/`CollectionBase` pipeline; their inner shapes (Layer,
    # Material, MaterialMixture, …) are not compatible with the v2
    # `ModelBase`/`EasyList` deserializer. Older files must be re-created.
    file_format = project_dict.get('file_format')
    if file_format is None:
        raise ValueError(
            'This project file predates file_format=2 and cannot be loaded by '
            'this version of easyreflectometry. The serialization format changed '
            'when the sample/model classes migrated from the legacy ObjBase / '
            'CollectionBase pipeline. Please re-create the project from its '
            'underlying data using the current API.'
        )
    if file_format not in (self.FILE_FORMAT, self.FILE_FORMAT_SHARED):
        raise ValueError(
            f'Unsupported project file_format={file_format!r}; this version of '
            f'easyreflectometry only reads file_format={self.FILE_FORMAT} and {self.FILE_FORMAT_SHARED}. '
            'Please either update easyreflectometry or re-create the project.'
        )
    report: list[str] = []
    self._info = project_dict['info']
    self._with_experiments = project_dict['with_experiments']
    if 'calculator' in keys:
        self._calculator.switch(project_dict['calculator'])
    with references.reading(report):
        if 'models' in keys:
            self.models = ModelCollection.from_dict(project_dict['models'])
        palette = [references.deserialize(entry) for entry in project_dict.get('materials', [])]
    if 'materials' in keys:
        self._replace_collection(palette, self._materials)
    else:
        # A file predating `materials`: the models' materials, then the unused ones.
        self._replace_collection(self._get_materials_in_models(), self._materials)
        if 'materials_not_in_model' in keys:
            self._materials.extend(MaterialCollection.from_dict(project_dict['materials_not_in_model']))
    # Settings are replaced wholesale, never merged with the previous project's.
    # A file predating `fit_settings` only stored `fitter_minimizer`.
    legacy = {'minimizer': project_dict.get('fitter_minimizer')}
    self._fit_settings, settings_report = FitSettings.from_dict(project_dict.get('fit_settings', legacy))
    report.extend(settings_report)
    self._invalidate_fitter()
    self._last_fit = None  # the previous contents' run, not this project's
    if 'experiments' in keys:
        self._experiments = self._from_dict_extract_experiments(project_dict, report)
    else:
        self._experiments = {}
    self.load_report = report

    # Resolve any pending parameter dependencies parked by the core
    # deserializer. Only cores that serialize nested dependencies produce
    # them; on the others this is a no-op safety net and `parameter_constraints`
    # below carries the user constraints instead.
    resolve_all_parameter_dependencies(self)
    self._restore_user_constraints(project_dict.get('parameter_constraints', []))
    self._restore_sum_partner_max_backups(project_dict.get('sum_partner_max_backups', {}))
    self._warn_on_unreadable_dependencies(project_dict.get('models'))
    # Inequality constraints are declarative (paths), nothing to resolve yet:
    # they are bound to parameters when a fit starts.
    self._inequality_constraints = [InequalitySpec.from_dict(raw) for raw in project_dict.get('inequality_constraints', [])]
    self._from_dict_restore_contrasts(project_dict, self.load_report)

Summary

Source code in src/easyreflectometry/summary/summary.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
class Summary:
    def __init__(self, project: Project):
        """Init function."""
        self._project = project

    def compile_html_summary(self, figures: bool = False, interactive: bool = True) -> str:
        """Compile html summary.

        Parameters
        ----------
        figures
            Whether to render the figures section.
        interactive
            When ``True`` (the default) the figures are rendered as interactive
            plotly charts suitable for an HTML report. Set to ``False`` to fall
            back to static images, e.g. when the html is handed to the PDF
            converter, which cannot run the embedded JavaScript.
        """
        html = HTML_TEMPLATE

        html = html.replace('project_information_section', self._project_information_section())

        html = html.replace('sample_section', self._sample_section())

        experiments_section = self._experiments_section()
        if experiments_section == '':  # no experiments
            experiments_section = '<td>No experiments</td>'
        html = html.replace('experiments_section', experiments_section)

        html = html.replace('refinement_section', self._refinement_section())

        if figures:
            html = html.replace('figures_section', self._figures_section(interactive=interactive))
        else:
            html = html.replace('figures_section', '')

        return html

    def save_html_summary(self, filename: str) -> None:
        """Save html summary."""
        html = self.compile_html_summary(figures=True, interactive=True)
        with open(filename, 'w', encoding='utf-8') as f:
            f.write(html)

    def save_pdf_summary(self, filename: str) -> None:
        """Save pdf summary."""
        # The PDF converter (xhtml2pdf) cannot execute the JavaScript that powers
        # the interactive plotly charts, so embed static images instead.
        html = self.compile_html_summary(figures=True, interactive=False)

        with open(filename, 'w+b') as result_file:
            with _silence_pdf_converter():
                pisa_status = pisa.CreatePDF(
                    html,
                    dest=result_file,
                )

            if pisa_status.err:
                print('An error occured when generating PDF summary!')

    def save_sld_plot(self, filename: str) -> None:
        """Save sld plot."""
        fig = plt.figure()
        ax = fig.add_subplot(1, 1, 1)

        sld = self._project.sld_data_for_model_at_index(0)
        ax.plot(sld.x, sld.y, color='blue')

        ax.set_xlabel('z (Å)')
        ax.set_ylabel('SLD (Å⁻²)')
        fig.legend(['SLD'])
        fig.savefig(filename, dpi=600)
        plt.close()

    def _measured_series(self) -> list:
        """Measured data of experiment 0 as ``(label, dataset, color, channel)`` entries.

        One entry per measured spin channel for a polarized experiment (each
        with its own channel color and channel key), a single channel-less
        entry for an ordinary experiment, and an empty list when no experiment
        is loaded.
        """
        try:
            experiment = self._project.experimental_data_for_model_at_index(0)
        except IndexError:
            return []

        channels = self._project.experiment_channels_at_index(0)
        if not channels:
            return [('Experiment', experiment, 'red', None)]
        return [
            (f'Experiment ({channel.value})', experiment[channel], _CHANNEL_COLORS[channel.value], channel)
            for channel in channels
        ]

    def _model_curve(self, channel=None):
        """Calculated curve for experiment 0, per spin channel when asked.

        Returns None when the requested channel cannot be calculated (e.g. a
        spin-flip channel on a non-magnetic model) — an incorrect overlay is
        worse than none.
        """
        try:
            return self._project.model_data_for_model_at_index(0, channel=channel)
        except (ValueError, NotImplementedError) as exception:
            logging.getLogger(__name__).warning(
                'No calculated curve for channel %s: %s', getattr(channel, 'value', channel), exception
            )
            return None

    def save_fit_experiment_plot(self, filename: str) -> None:
        """Save fit experiment plot."""
        fig = plt.figure()
        ax = fig.add_subplot(1, 1, 1)
        legends = []

        measured_series = self._measured_series()
        # One calculated curve per measured channel; the ordinary single curve
        # when the experiment is unpolarized or nothing is loaded.
        model_channels = [entry[3] for entry in measured_series] or [None]
        for channel in model_channels:
            model = self._model_curve(channel)
            if model is None:
                continue
            color = 'blue' if channel is None else _CHANNEL_COLORS[channel.value]
            ax.plot(model.x, np.log10(model.y), color=color)
            legends.append('Model' if channel is None else f'Model ({channel.value})')

        for label, dataset, color, _channel in measured_series:
            ax.plot(dataset.x, np.log10(dataset.y), color=color)
            legends.append(label)

        ax.set_xlabel('Q (Å⁻¹)')
        ax.set_ylabel('Reflectivity')
        fig.legend(legends)
        fig.savefig(filename, dpi=600)
        plt.close()

    def _project_information_section(self) -> str:
        """Project information section."""
        html_project = HTML_PROJECT_INFORMATION_TEMPLATE

        name = self._project._info['name']
        short_description = self._project._info['short_description']
        html_project = html_project.replace('project_title', f'{name}')
        html_project = html_project.replace('project_description', f'{short_description}')
        html_project = html_project.replace('num_experiments', f'{len(self._project.experiments)}')
        return html_project

    def _sample_section(self) -> str:
        """Sample section."""
        html_parameters = []

        html_parameter = HTML_PARAMETER_HEADER_TEMPLATE
        html_parameter = html_parameter.replace('parameter_name', 'Name')
        html_parameter = html_parameter.replace('parameter_value', 'Value')
        html_parameter = html_parameter.replace('parameter_unit', 'Unit')
        html_parameter = html_parameter.replace('parameter_error', 'Error')
        html_parameters.append(html_parameter)

        for name, parameter in _named_parameters(self._project):
            value = parameter.value
            unit = parameter.unit
            error = parameter.error

            html_parameter = HTML_PARAMETER_TEMPLATE
            html_parameter = html_parameter.replace('parameter_name', f'{name}')
            html_parameter = html_parameter.replace('parameter_value', _format_value(value, 3))
            html_parameter = html_parameter.replace('parameter_unit', f'{unit}')
            # An unfitted parameter has no uncertainty; a literal '0.0' would
            # read as a perfectly determined value, so leave the cell empty.
            error_str = _format_value(error, 2) if error else ''
            html_parameter = html_parameter.replace('parameter_error', error_str)
            html_parameters.append(html_parameter)

        html_parameters_str = '\n'.join(html_parameters)

        return html_parameters_str

    def _experiments_section(self) -> str:
        """Experiments section."""
        html_experiments = []

        for idx, experiment in self._project.experiments.items():
            # A polarized experiment holds one dataset per spin channel; report
            # one row per channel, as the plots already draw one curve each.
            channels = getattr(experiment, 'available_channels', None)
            if channels is None:
                rows = [(experiment.name, experiment)]
            else:
                rows = [(f'{experiment.name} ({channel.value})', experiment[channel]) for channel in channels]
            for row_name, dataset in rows:
                html_experiments.append(self._experiment_row(row_name, dataset, experiment.model))

        html_experiments_str = '\n'.join(html_experiments)

        return html_experiments_str

    def _experiment_row(self, experiment_name, dataset, model) -> str:
        """One row of the experiments table for a single measured dataset."""
        num_data_points = len(dataset.x)
        # The dataset's own resolution when it has one, else its model's.
        resolution = getattr(dataset, 'resolution_function', None) or getattr(model, 'resolution_function', None)
        resolution_function = 'n/a' if resolution is None else resolution.as_dict()['smearing']
        if resolution_function == 'PercentageFwhm':
            precentage = resolution.as_dict()['constant']
            resolution_function = f'{resolution_function} {precentage}%'
        range_min = min(dataset.y)
        range_max = max(dataset.y)
        range_units = 'Å⁻¹'
        html_experiment = HTML_DATA_COLLECTION_TEMPLATE
        html_experiment = html_experiment.replace('experiment_name', _truncate_name(experiment_name))
        html_experiment = html_experiment.replace('range_min', _format_value(range_min, 2))
        html_experiment = html_experiment.replace('range_max', _format_value(range_max, 2))
        html_experiment = html_experiment.replace('range_units', f'{range_units}')
        html_experiment = html_experiment.replace('num_data_points', f'{num_data_points}')
        html_experiment = html_experiment.replace('resolution_function', f'{resolution_function}')
        return html_experiment

    def _refinement_section(self) -> str:
        """Refinement section: the last recorded fit, then the project's parameter inventory."""
        html_refinement = HTML_REFINEMENT_TEMPLATE

        # The fit's own facts come from its record, not from the project as it is now.
        run = self._project.last_fit
        if run is None:
            minimizer_name, minimizer_package = self._project.minimizer.name, self._project.minimizer.package
            fit_free_params = 'N/A'
        else:
            member = AvailableMinimizers.__members__.get(run.minimizer)
            minimizer_name, minimizer_package = run.minimizer, member.package if member else None
            fit_free_params = f'{run.n_free_parameters}' if run.status == 'completed' else 'N/A'

        parameters = self._project.parameters

        # Dependent parameters (user constraints, derived values such as the
        # total thickness) are neither free nor fixed: they never enter a fit.
        num_free_params = sum(1 for parameter in parameters if parameter.independent and parameter.free)
        num_fixed_params = sum(1 for parameter in parameters if parameter.independent and not parameter.free)
        num_constraints = sum(1 for parameter in parameters if not parameter.independent)
        num_params = num_free_params + num_fixed_params + num_constraints

        goodness_of_fit = self._compute_goodness_of_fit()

        html_refinement = html_refinement.replace(
            'calculation_engine',
            _engine_link(self._project._calculator.current_interface_name),
        )
        html_refinement = html_refinement.replace('minimization_engine', _engine_link(minimizer_name, minimizer_package))
        html_refinement = html_refinement.replace('goodness_of_fit', goodness_of_fit)
        html_refinement = html_refinement.replace('num_fit_free_params', fit_free_params)
        html_refinement = html_refinement.replace('num_total_params', f'{num_params}')
        html_refinement = html_refinement.replace('num_free_params', f'{num_free_params}')
        html_refinement = html_refinement.replace('num_fixed_params', f'{num_fixed_params}')
        html_refinement = html_refinement.replace('num_constriants', f'{num_constraints}')
        return html_refinement

    def _compute_goodness_of_fit(self) -> str:
        """The pooled reduced chi² of the last recorded fit, or 'N/A'."""
        run = self._project.last_fit
        reduced = run.pooled.get('objective_reduced_chi2') if run is not None else None
        return 'N/A' if reduced is None else f'{reduced:.4g}'

    def _figures_section(self, interactive: bool = True) -> str:
        """Figures section.

        When *interactive* is ``True`` the figures are rendered as interactive
        plotly charts embedded directly in the html. Otherwise static images are
        written to disk and referenced (used for the PDF report).
        """
        if interactive:
            return self._interactive_figures_section()

        html_figures = HTML_FIGURES_TEMPLATE
        path_sld = self._project.path / 'sld_plot.jpg'
        path_fit_experiment = self._project.path / 'fit_experiment_plot.jpg'

        self.save_sld_plot(path_sld)
        self.save_fit_experiment_plot(path_fit_experiment)

        html_figures = html_figures.replace('path_sld_plot', str(path_sld))
        html_figures = html_figures.replace('path_fit_experiment_plot', str(path_fit_experiment))
        return html_figures

    def _interactive_figures_section(self) -> str:
        """Build the interactive (plotly) figures section for the html report."""
        import plotly.io as pio

        fig_sld = self._sld_plotly_figure()
        fig_fit_experiment = self._fit_experiment_plotly_figure()

        # Embed plotly.js inline once (with the first figure) so the saved report
        # is self-contained and renders without an internet connection.
        sld_div = pio.to_html(
            fig_sld,
            include_plotlyjs=True,
            full_html=False,
            default_width='640px',
            default_height='480px',
        )
        fit_experiment_div = pio.to_html(
            fig_fit_experiment,
            include_plotlyjs=False,
            full_html=False,
            default_width='640px',
            default_height='480px',
        )

        html_figures = HTML_INTERACTIVE_FIGURES_TEMPLATE
        html_figures = html_figures.replace('sld_plot_div', sld_div)
        html_figures = html_figures.replace('fit_experiment_plot_div', fit_experiment_div)
        return html_figures

    def _sld_plotly_figure(self):
        """Interactive SLD profile figure."""
        import plotly.graph_objects as go

        sld = self._project.sld_data_for_model_at_index(0)

        fig = go.Figure()
        fig.add_trace(
            go.Scatter(
                x=np.asarray(sld.x),
                y=np.asarray(sld.y),
                mode='lines',
                name='SLD',
                line={'color': 'blue'},
            )
        )
        fig.update_layout(
            xaxis_title='z (Å)',
            yaxis_title='SLD (Å⁻²)',
            template='simple_white',
            margin={'l': 70, 'r': 20, 't': 30, 'b': 50},
            legend={'x': 0.99, 'xanchor': 'right', 'y': 0.99, 'yanchor': 'top'},
        )
        return fig

    def _fit_experiment_plotly_figure(self):
        """Interactive reflectivity (model vs. experiment) figure."""
        import plotly.graph_objects as go

        fig = go.Figure()

        measured_series = self._measured_series()
        # One calculated curve per measured channel; the ordinary single curve
        # when the experiment is unpolarized or nothing is loaded.
        model_channels = [entry[3] for entry in measured_series] or [None]
        for channel in model_channels:
            model = self._model_curve(channel)
            if model is None:
                continue
            color = 'blue' if channel is None else _CHANNEL_COLORS[channel.value]
            fig.add_trace(
                go.Scatter(
                    x=np.asarray(model.x),
                    y=np.asarray(model.y),
                    mode='lines',
                    name='Model' if channel is None else f'Model ({channel.value})',
                    line={'color': color},
                )
            )

        for label, dataset, color, _channel in measured_series:
            fig.add_trace(
                go.Scatter(
                    x=np.asarray(dataset.x),
                    y=np.asarray(dataset.y),
                    mode='markers',
                    name=label,
                    marker={'color': color, 'size': 4},
                )
            )

        fig.update_layout(
            xaxis_title='Q (Å⁻¹)',
            yaxis_title='Reflectivity',
            yaxis_type='log',
            template='simple_white',
            margin={'l': 70, 'r': 20, 't': 30, 'b': 50},
            legend={'x': 0.99, 'xanchor': 'right', 'y': 0.99, 'yanchor': 'top'},
        )
        return fig

__init__(project)

Init function.

Source code in src/easyreflectometry/summary/summary.py
139
140
141
def __init__(self, project: Project):
    """Init function."""
    self._project = project

compile_html_summary(figures=False, interactive=True)

Compile html summary.

Parameters:

Name Type Description Default
figures bool

Whether to render the figures section.

False
interactive bool

When True (the default) the figures are rendered as interactive plotly charts suitable for an HTML report. Set to False to fall back to static images, e.g. when the html is handed to the PDF converter, which cannot run the embedded JavaScript.

True
Source code in src/easyreflectometry/summary/summary.py
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
def compile_html_summary(self, figures: bool = False, interactive: bool = True) -> str:
    """Compile html summary.

    Parameters
    ----------
    figures
        Whether to render the figures section.
    interactive
        When ``True`` (the default) the figures are rendered as interactive
        plotly charts suitable for an HTML report. Set to ``False`` to fall
        back to static images, e.g. when the html is handed to the PDF
        converter, which cannot run the embedded JavaScript.
    """
    html = HTML_TEMPLATE

    html = html.replace('project_information_section', self._project_information_section())

    html = html.replace('sample_section', self._sample_section())

    experiments_section = self._experiments_section()
    if experiments_section == '':  # no experiments
        experiments_section = '<td>No experiments</td>'
    html = html.replace('experiments_section', experiments_section)

    html = html.replace('refinement_section', self._refinement_section())

    if figures:
        html = html.replace('figures_section', self._figures_section(interactive=interactive))
    else:
        html = html.replace('figures_section', '')

    return html

save_html_summary(filename)

Save html summary.

Source code in src/easyreflectometry/summary/summary.py
176
177
178
179
180
def save_html_summary(self, filename: str) -> None:
    """Save html summary."""
    html = self.compile_html_summary(figures=True, interactive=True)
    with open(filename, 'w', encoding='utf-8') as f:
        f.write(html)

save_pdf_summary(filename)

Save pdf summary.

Source code in src/easyreflectometry/summary/summary.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def save_pdf_summary(self, filename: str) -> None:
    """Save pdf summary."""
    # The PDF converter (xhtml2pdf) cannot execute the JavaScript that powers
    # the interactive plotly charts, so embed static images instead.
    html = self.compile_html_summary(figures=True, interactive=False)

    with open(filename, 'w+b') as result_file:
        with _silence_pdf_converter():
            pisa_status = pisa.CreatePDF(
                html,
                dest=result_file,
            )

        if pisa_status.err:
            print('An error occured when generating PDF summary!')

save_sld_plot(filename)

Save sld plot.

Source code in src/easyreflectometry/summary/summary.py
198
199
200
201
202
203
204
205
206
207
208
209
210
def save_sld_plot(self, filename: str) -> None:
    """Save sld plot."""
    fig = plt.figure()
    ax = fig.add_subplot(1, 1, 1)

    sld = self._project.sld_data_for_model_at_index(0)
    ax.plot(sld.x, sld.y, color='blue')

    ax.set_xlabel('z (Å)')
    ax.set_ylabel('SLD (Å⁻²)')
    fig.legend(['SLD'])
    fig.savefig(filename, dpi=600)
    plt.close()

save_fit_experiment_plot(filename)

Save fit experiment plot.

Source code in src/easyreflectometry/summary/summary.py
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
def save_fit_experiment_plot(self, filename: str) -> None:
    """Save fit experiment plot."""
    fig = plt.figure()
    ax = fig.add_subplot(1, 1, 1)
    legends = []

    measured_series = self._measured_series()
    # One calculated curve per measured channel; the ordinary single curve
    # when the experiment is unpolarized or nothing is loaded.
    model_channels = [entry[3] for entry in measured_series] or [None]
    for channel in model_channels:
        model = self._model_curve(channel)
        if model is None:
            continue
        color = 'blue' if channel is None else _CHANNEL_COLORS[channel.value]
        ax.plot(model.x, np.log10(model.y), color=color)
        legends.append('Model' if channel is None else f'Model ({channel.value})')

    for label, dataset, color, _channel in measured_series:
        ax.plot(dataset.x, np.log10(dataset.y), color=color)
        legends.append(label)

    ax.set_xlabel('Q (Å⁻¹)')
    ax.set_ylabel('Reflectivity')
    fig.legend(legends)
    fig.savefig(filename, dpi=600)
    plt.close()

_silence_pdf_converter()

Silence xhtml2pdf's verbose output during PDF conversion.

xhtml2pdf emits a large amount of log.debug output (image tags, file objects, column widths, parsed schemes, ...) and a few stray print statements while rendering. When the host application has configured logging at DEBUG level this floods stdout. For the duration of the conversion we raise the xhtml2pdf logger level so its debug records are dropped, and redirect stdout to swallow the stray prints. Both are restored afterwards.

Source code in src/easyreflectometry/summary/summary.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@contextlib.contextmanager
def _silence_pdf_converter():
    """Silence xhtml2pdf's verbose output during PDF conversion.

    xhtml2pdf emits a large amount of ``log.debug`` output (image tags, file
    objects, column widths, parsed schemes, ...) and a few stray ``print``
    statements while rendering. When the host application has configured logging
    at DEBUG level this floods stdout. For the duration of the conversion we
    raise the ``xhtml2pdf`` logger level so its debug records are dropped, and
    redirect stdout to swallow the stray prints. Both are restored afterwards.
    """
    xhtml2pdf_logger = logging.getLogger('xhtml2pdf')
    previous_level = xhtml2pdf_logger.level
    xhtml2pdf_logger.setLevel(logging.WARNING)
    try:
        with contextlib.redirect_stdout(io.StringIO()):
            yield
    finally:
        xhtml2pdf_logger.setLevel(previous_level)

Return an HTML hyperlink for an engine, including its version.

Falls back to plain text when no URL is known for the engine.

Source code in src/easyreflectometry/summary/summary.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def _engine_link(name: str, package: str | None = None) -> str:
    """Return an HTML hyperlink for an engine, including its version.

    Falls back to plain text when no URL is known for the engine.
    """
    url = _ENGINE_URLS.get(name) or _ENGINE_URLS.get(package or '')
    display = escape(name)
    if package:
        try:
            ver = version(package)
        except PackageNotFoundError:
            ver = None
        if ver:
            display = f'{display} (v{ver})'

    if url:
        return f'<a href="{url}">{display}</a>'
    return display

_format_value(value, sig_figs)

Format a numeric value for summary display.

sig_figs significant figures; fall back to 1-decimal exponential when the formatted string is too long. Zero is shown as '0.0'.

Source code in src/easyreflectometry/summary/summary.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def _format_value(value: float, sig_figs: int) -> str:
    """Format a numeric value for summary display.

    *sig_figs* significant figures; fall back to 1-decimal exponential when
    the formatted string is too long.  Zero is shown as '0.0'.
    """
    if value == 0.0:
        return '0.0'
    s = f'{value:.{sig_figs}g}'
    if len(s) <= sig_figs + 4:
        return s
    return f'{value:.1e}'

_named_parameters(project)

(display name, parameter) for every parameter of every model, each once: the model's name, the name of the object holding the parameter, the parameter's.

Source code in src/easyreflectometry/summary/summary.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def _named_parameters(project: Project):
    """``(display name, parameter)`` for every parameter of every model, each once:
    the model's name, the name of the object holding the parameter, the parameter's."""
    seen: set[int] = set()

    def walk(obj, model_name: str, owner_name: str):
        for _, child in paths.children(obj):
            if isinstance(child, DescriptorNumber):
                if isinstance(child, Parameter) and id(child) not in seen:
                    seen.add(id(child))
                    # A model's own parameter (scale) has the model as its owner: name it once.
                    yield ' '.join(dict.fromkeys([model_name, owner_name, child.name])), child
            elif id(child) not in seen:
                seen.add(id(child))
                yield from walk(child, model_name, getattr(child, 'name', owner_name))

    for model in project.models:
        yield from walk(model, model.name, model.name)

_truncate_name(name, max_len=_NAME_MAX_LEN)

Return an HTML snippet with a truncated name and tooltip for the full text.

Browsers use the title attribute; QML reads the href via TextEdit.hoveredLink and shows a native ToolTip.

Source code in src/easyreflectometry/summary/summary.py
124
125
126
127
128
129
130
131
132
133
134
135
def _truncate_name(name: str, max_len: int = _NAME_MAX_LEN) -> str:
    """Return an HTML snippet with a truncated name and tooltip for the full text.

    Browsers use the ``title`` attribute; QML reads the ``href`` via
    ``TextEdit.hoveredLink`` and shows a native ToolTip.
    """
    safe = escape(name)
    if len(name) <= max_len:
        return safe
    short = escape(name[:max_len].rstrip())
    encoded = quote(name, safe='')
    return f'<a style="color:inherit;text-decoration:none;" href="{_TOOLTIP_SCHEME}:{encoded}" title="{safe}">{short}…</a>'

paths

Structural paths of parameters, e.g. models/0/sample/1/layers/0/thickness.

Paths are stable across save/load (unlike unique names) and are how a project addresses parameters in saved constraints. Within one model the relative paths (sample/1/layers/0/thickness) also match the parameters of two models with the same layout.

children(obj)

(token, child) pairs to descend into from obj.

Source code in src/easyreflectometry/sample/paths.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def children(obj: Any) -> list[tuple[str, Any]]:
    """``(token, child)`` pairs to descend into from `obj`."""
    from easyreflectometry.sample.base_core import BaseCore
    from easyreflectometry.sample.collections.base_collection import BaseCollection

    if isinstance(obj, (BaseCollection, list, tuple)) or (isinstance(obj, Sequence) and not isinstance(obj, str)):
        return [(str(index), item) for index, item in enumerate(obj)]
    if isinstance(obj, BaseCore) or hasattr(obj, 'get_all_parameters'):
        candidates = []
        for attr_name in dir(type(obj)):
            if attr_name.startswith('_') or attr_name in SKIPPED_PROPERTIES:
                continue
            class_attr = getattr(type(obj), attr_name, None)
            if not isinstance(class_attr, property):
                continue
            try:
                value = getattr(obj, attr_name)
            except Exception as exception:
                logger.debug("Skipping property '%s' on %r: %s", attr_name, obj, exception)
                continue
            if isinstance(value, (DescriptorNumber, BaseCore, BaseCollection, list, tuple)):
                candidates.append((attr_name, value))
        return candidates
    return []

walk(root, tokens, visited=None, every_path=False)

Yield (path, parameter) for every parameter under root, paths starting with tokens.

By default each object is descended into once (per visited, shared between calls when given), so an object reachable from several places is reported at its first path only. With every_path, it is reported at each of them; only a cycle back to an ancestor is cut.

Source code in src/easyreflectometry/sample/paths.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def walk(
    root: Any, tokens: list[str], visited: Optional[set[int]] = None, every_path: bool = False
) -> Iterator[tuple[str, Any]]:
    """Yield ``(path, parameter)`` for every parameter under `root`, paths starting with `tokens`.

    By default each object is descended into once (per `visited`, shared
    between calls when given), so an object reachable from several places is
    reported at its first path only. With `every_path`, it is reported at each
    of them; only a cycle back to an ancestor is cut.
    """
    visited = set() if visited is None else visited
    if isinstance(root, DescriptorNumber):
        yield '/'.join(tokens), root
        return
    if id(root) in visited:
        return
    visited.add(id(root))
    for token, child in children(root):
        yield from walk(child, tokens + [token], visited, every_path)
    if every_path:
        visited.discard(id(root))