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

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

    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')
            if getattr(parameter, 'enabled', True):
                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)
        # 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).
        new_materials = [material for material in self._get_materials_in_models() if material not in self._materials]
        self._materials.extend(new_materials)
        for model in self._models:
            model.interface = self._calculator
        self._sync_parameter_states()

    @property
    def fitter(self) -> MultiFitter:
        """Fitter function."""
        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.easy_science_multi_fitter.switch_minimizer(self._minimizer_selection)
                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)
        return self._fitter

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

    @staticmethod
    def _child_candidates(obj) -> list:
        """``(token, child)`` pairs to descend into from `obj`."""
        from collections.abc import Sequence

        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 _PATH_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, (DescriptorNumberType, BaseCore, BaseCollection, list, tuple)):
                    candidates.append((attr_name, value))
            return candidates
        return []

    def _walk_parameters(self):
        """Yield ``(structural path, parameter)`` for every parameter under the models.

        Each object is descended into once, so parent back-references cannot
        recurse forever.
        """
        visited: set[int] = set()

        def _walk(obj, tokens: List[str]):
            if isinstance(obj, DescriptorNumberType):
                yield '/'.join(tokens), obj
                return
            if id(obj) in visited:
                return
            visited.add(id(obj))
            for token, child in self._child_candidates(obj):
                yield from _walk(child, tokens + [token])

        yield from _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()

        # The cached fitter holds fit functions bound to the previous backend.
        self._fitter = None
        self._fitter_model_index = None

    @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:
        """Minimizer function."""
        if self._fitter is not None:
            return self._fitter.easy_science_multi_fitter.minimizer.enum
        return self._minimizer_selection

    @minimizer.setter
    def minimizer(self, minimizer: AvailableMinimizers) -> None:
        """Minimizer function."""
        old_name = getattr(self._minimizer_selection, 'name', str(self._minimizer_selection))
        new_name = getattr(minimizer, 'name', str(minimizer))
        logger.info(
            'Minimizer changed from %s to %s (fitter active: %s)',
            old_name,
            new_name,
            self._fitter is not None,
        )
        self._minimizer_selection = minimizer
        if self._fitter is not None:
            self._fitter.easy_science_multi_fitter.switch_minimizer(minimizer)

    @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

    @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.')
        # 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.')
        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 _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 function on *model* based on variance data in *experiment*.

        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.

        Parameters
        ----------
        experiment : DataSet1D
            The experiment whose variance data drives the choice.
        model : Model
            The model whose resolution function is set.
        """
        # nan-robust gate: nan is truthy for np.any, but a nan-carrying xe must
        # not build a Pointwise (np.interp would propagate the nan everywhere).
        if experiment.xe is not None and np.any(np.nan_to_num(experiment.xe) > 0):
            model.resolution_function = Pointwise(q_data_points=[experiment.x, experiment.y, experiment.xe])
        else:
            model.resolution_function = PercentageFwhm(5.0)

    @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 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)
        new_experiment.model = self.models[model_index]
        self._auto_set_background(new_experiment)
        self._experiments[new_index] = new_experiment
        self._with_experiments = True
        self._apply_resolution_function(new_experiment, self.models[model_index])

    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

        model_index = self._current_model_index
        for data_key in data_keys:
            new_index = len(self._experiments)

            new_experiment = dataset_from_datagroup(data_group, data_key=data_key)
            new_experiment.name = f'Experiment {new_index}'
            self._apply_experiment_metadata(
                path,
                new_experiment,
                f'Experiment {new_index}',
                data_group=data_group,
                data_key=data_key,
            )
            new_experiment.model = self.models[model_index]
            self._auto_set_background(new_experiment)
            self._experiments[new_index] = new_experiment
            self._apply_resolution_function(new_experiment, self.models[model_index])

        self._with_experiments = True
        return len(data_keys)

    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.

        Background and resolution follow the first (in canonical order)
        channel; per-channel resolution functions are not supported (one per
        experiment).

        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
        )

        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)
        experiment.model = self.models[index]
        self._auto_set_background(experiment)
        self._experiments[index] = experiment
        self._with_experiments = True
        self._apply_resolution_function(experiment, self._models[index])

    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, DataSet1D]:
        """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;
            - ``'spin_up'`` / ``'spin_down'``: the potentials each spin state
              sees, ρ(z) ± ρM(z)·cos(θM(z) − A), where A is the guide-field
              angle :data:`GUIDE_FIELD_ANGLE`.

        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)
        # Component of the moment along the guide field: what the neutron spin
        # states add to / subtract from the nuclear potential.
        projection = rho_m * np.cos(np.radians(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]),
            'spin_up': DataSet1D(name=f'Spin-up potential for Model {index}', x=z, y=sld + projection),
            'spin_down': DataSet1D(name=f'Spin-down potential for Model {index}', x=z, y=sld - projection),
        }

    def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D:
        """Sample data for model at index."""
        original_resolution_function = self.models[index].resolution_function
        self.models[index].resolution_function = PercentageFwhm(0)
        reflectivity_data = self.model_data_for_model_at_index(index, q_range)
        self.models[index].resolution_function = original_resolution_function

        return reflectivity_data

    def model_data_for_model_at_index(
        self,
        index: int = 0,
        q_range: Optional[np.array] = None,
        channel: Optional[Union[PolarizationChannel, str]] = 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.
        """
        if q_range is None:
            q_range = np.linspace(self.q_min, self.q_max, self.q_resolution)
        self._bind_calculator(self.models[index])
        if channel is None:
            reflectivity = self.models[index].interface().reflectity_profile(q_range, self._models[index].unique_name)
            name = f'Reflectivity for Model {index}'
        else:
            channel = PolarizationChannel(channel)
            reflectivity = (
                self.models[index].interface().reflectivity_profile_channel(q_range, self._models[index].unique_name, channel)
            )
            name = f'Reflectivity ({channel.value}) for Model {index}'
        return DataSet1D(
            name=name,
            x=q_range,
            y=reflectivity,
        )

    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."""
        model = getattr(experiment, 'model', None)
        if model is None:
            return None
        for model_index, candidate in enumerate(self._models):
            if candidate is model:
                return model_index
        return 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 remove_model_at_index(self, index: int) -> None:
        """Remove the model at the given index.

        Removes the model from the model collection, removes the experiment at the
        same index (if any), and reindexes experiments above the removed index so
        model/experiment indices stay aligned.

        Adjusts the current model index if necessary.

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

        Raises
        ------
        IndexError :
            If the index is out of range.
        ValueError :
            If trying to remove the last remaining model.
        """
        if index < 0 or index >= len(self._models):
            raise IndexError(f'Model index {index} out of range')

        if len(self._models) <= 1:
            raise ValueError('Cannot remove the last model from the project')

        # Remove the model from the collection
        self._models.pop(index)

        # Remove experiment mapped to the removed model index.
        if index in self._experiments:
            self._experiments.pop(index)

        # Reindex experiments above the removed model index to keep mapping aligned.
        reindexed_experiments: dict[int, DataSet1D] = {}
        for exp_index, experiment in sorted(self._experiments.items()):
            if exp_index > index:
                reindexed_experiments[exp_index - 1] = experiment
            else:
                reindexed_experiments[exp_index] = experiment
        self._experiments = reindexed_experiments

        # 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

    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

    def as_dict(self, include_materials_not_in_model=False):
        """As dict."""
        project_dict = {}
        project_dict['file_format'] = self.FILE_FORMAT
        project_dict['info'] = self._info
        project_dict['with_experiments'] = self._with_experiments
        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'
        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.fitter is not None:
            project_dict['fitter_minimizer'] = self.fitter.easy_science_multi_fitter.minimizer.name
        elif self._minimizer_selection is not None:
            project_dict['fitter_minimizer'] = self._minimizer_selection.name
        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
        return project_dict

    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 _as_dict_add_experiments(self, project_dict: dict):
        """As dict add experiments."""
        project_dict['experiments'] = {}
        project_dict['experiments_models'] = {}
        project_dict['experiments_names'] = {}

        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),
            ]
            if experiment.xe is not None:
                project_dict['experiments'][key].append(list(experiment.xe))
            project_dict['experiments_models'][key] = experiment.model.name
            project_dict['experiments_names'][key] = experiment.name

    @staticmethod
    def _as_dict_add_polarized_experiment(project_dict: dict, key: int, experiment: PolarizedDataSet) -> None:
        """Serialize a `PolarizedDataSet`: one (name, x, y, ye, xe) 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),
                    list(experiment[channel].xe),
                ]
                for channel in experiment.available_channels
            },
        }
        if experiment.model is not None:
            project_dict['experiments_models'][key] = experiment.model.name
            project_dict['experiments_names'][key] = experiment.name

    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 != self.FILE_FORMAT:
            raise ValueError(
                f'Unsupported project file_format={file_format!r}; this version of '
                f'easyreflectometry only reads file_format={self.FILE_FORMAT}. Please '
                'either update easyreflectometry or re-create the project.'
            )
        self._info = project_dict['info']
        self._with_experiments = project_dict['with_experiments']
        if 'calculator' in keys:
            self._calculator.switch(project_dict['calculator'])
        if 'models' in keys:
            self.models = ModelCollection.from_dict(project_dict['models'])
        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']))
        if 'fitter_minimizer' in keys:
            self.minimizer = AvailableMinimizers[project_dict['fitter_minimizer']]
        else:
            self._fitter = None
        if 'experiments' in keys:
            self._experiments = self._from_dict_extract_experiments(project_dict)
        else:
            self._experiments = {}

        # 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', [])]

    @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) -> Dict[int, Union[DataSet1D, PolarizedDataSet]]:
        """From dict extract experiments."""
        experiments = {}
        for key, raw in project_dict['experiments'].items():
            if isinstance(raw, dict) and raw.get('polarized'):
                experiments[int(key)] = self._polarized_experiment_from_dict(key, raw, project_dict)
                continue
            experiments[int(key)] = DataSet1D(
                name=project_dict['experiments_names'][key],
                x=raw[0],
                y=raw[1],
                ye=raw[2],
                xe=raw[3] if len(raw) > 3 else None,
                model=self._models[project_dict['experiments_models'][key]],
                auto_background=False,
            )
        return experiments

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

    def _get_materials_in_models(self) -> MaterialCollection:
        """Get materials in models."""
        materials_in_model = MaterialCollection(populate_if_none=False)
        for model in self._models:
            for assembly in model.sample:
                for layer in assembly.layers:
                    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

Fitter function.

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

Minimizer function.

experiments property writable

Experiments function.

path_json property

Path json.

__init__()

Init function.

Source code in src/easyreflectometry/project.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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._minimizer_selection: AvailableMinimizers = DEFAULT_MINIMIZER
    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] = []

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

reset()

Reset function.

Source code in src/easyreflectometry/project.py
141
142
143
144
145
146
147
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
335
336
337
def set_path_project_parent(self, path: Union[Path, str]):
    """Set path project parent."""
    self._path_project_parent = Path(path)

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
424
425
426
427
428
429
430
431
432
433
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
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
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
462
463
464
465
466
467
468
469
470
471
472
473
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
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
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
496
497
498
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
500
501
502
503
504
505
506
507
508
509
510
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
512
513
514
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)

get_index_air()

Get index air.

Source code in src/easyreflectometry/project.py
759
760
761
762
763
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
765
766
767
768
769
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
771
772
773
774
775
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
777
778
779
780
781
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
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
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
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
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
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
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.')
    # 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
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
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.')
    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
 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
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)
    new_experiment.model = self.models[model_index]
    self._auto_set_background(new_experiment)
    self._experiments[new_index] = new_experiment
    self._with_experiments = True
    self._apply_resolution_function(new_experiment, self.models[model_index])

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
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
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
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
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

    model_index = self._current_model_index
    for data_key in data_keys:
        new_index = len(self._experiments)

        new_experiment = dataset_from_datagroup(data_group, data_key=data_key)
        new_experiment.name = f'Experiment {new_index}'
        self._apply_experiment_metadata(
            path,
            new_experiment,
            f'Experiment {new_index}',
            data_group=data_group,
            data_key=data_key,
        )
        new_experiment.model = self.models[model_index]
        self._auto_set_background(new_experiment)
        self._experiments[new_index] = new_experiment
        self._apply_resolution_function(new_experiment, self.models[model_index])

    self._with_experiments = True
    return len(data_keys)

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
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
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
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
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
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
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
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
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
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
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)
    experiment.model = self.models[index]
    self._auto_set_background(experiment)
    self._experiments[index] = experiment
    self._with_experiments = True
    self._apply_resolution_function(experiment, self._models[index])

sld_data_for_model_at_index(index=0)

Sld data for model at index.

Source code in src/easyreflectometry/project.py
1300
1301
1302
1303
1304
1305
1306
1307
1308
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
1310
1311
1312
1313
1314
1315
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;
  • 'spin_up' / 'spin_down': the potentials each spin state sees, ρ(z) ± ρM(z)·cos(θM(z) − A), where A is the guide-field angle :data:GUIDE_FIELD_ANGLE.

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
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
def magnetic_sld_data_for_model_at_index(self, index: int = 0) -> Dict[str, DataSet1D]:
    """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;
        - ``'spin_up'`` / ``'spin_down'``: the potentials each spin state
          sees, ρ(z) ± ρM(z)·cos(θM(z) − A), where A is the guide-field
          angle :data:`GUIDE_FIELD_ANGLE`.

    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)
    # Component of the moment along the guide field: what the neutron spin
    # states add to / subtract from the nuclear potential.
    projection = rho_m * np.cos(np.radians(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]),
        'spin_up': DataSet1D(name=f'Spin-up potential for Model {index}', x=z, y=sld + projection),
        'spin_down': DataSet1D(name=f'Spin-down potential for Model {index}', x=z, y=sld - projection),
    }

sample_data_for_model_at_index(index=0, q_range=None)

Sample data for model at index.

Source code in src/easyreflectometry/project.py
1378
1379
1380
1381
1382
1383
1384
1385
def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D:
    """Sample data for model at index."""
    original_resolution_function = self.models[index].resolution_function
    self.models[index].resolution_function = PercentageFwhm(0)
    reflectivity_data = self.model_data_for_model_at_index(index, q_range)
    self.models[index].resolution_function = original_resolution_function

    return reflectivity_data

model_data_for_model_at_index(index=0, q_range=None, channel=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
Source code in src/easyreflectometry/project.py
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
def model_data_for_model_at_index(
    self,
    index: int = 0,
    q_range: Optional[np.array] = None,
    channel: Optional[Union[PolarizationChannel, str]] = 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.
    """
    if q_range is None:
        q_range = np.linspace(self.q_min, self.q_max, self.q_resolution)
    self._bind_calculator(self.models[index])
    if channel is None:
        reflectivity = self.models[index].interface().reflectity_profile(q_range, self._models[index].unique_name)
        name = f'Reflectivity for Model {index}'
    else:
        channel = PolarizationChannel(channel)
        reflectivity = (
            self.models[index].interface().reflectivity_profile_channel(q_range, self._models[index].unique_name, channel)
        )
        name = f'Reflectivity ({channel.value}) for Model {index}'
    return DataSet1D(
        name=name,
        x=q_range,
        y=reflectivity,
    )

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
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
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
1480
1481
1482
1483
1484
1485
1486
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
1488
1489
1490
1491
1492
1493
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
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
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
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
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
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 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
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
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

remove_model_at_index(index)

Remove the model at the given index.

Removes the model from the model collection, removes the experiment at the same index (if any), and reindexes experiments above the removed index so model/experiment indices stay aligned.

Adjusts the current model index if necessary.

Parameters:

Name Type Description Default
index int

Index of the model to remove.

required

Raises:

Type Description
IndexError :

If the index is out of range.

ValueError :

If trying to remove the last remaining model.

Source code in src/easyreflectometry/project.py
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
def remove_model_at_index(self, index: int) -> None:
    """Remove the model at the given index.

    Removes the model from the model collection, removes the experiment at the
    same index (if any), and reindexes experiments above the removed index so
    model/experiment indices stay aligned.

    Adjusts the current model index if necessary.

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

    Raises
    ------
    IndexError :
        If the index is out of range.
    ValueError :
        If trying to remove the last remaining model.
    """
    if index < 0 or index >= len(self._models):
        raise IndexError(f'Model index {index} out of range')

    if len(self._models) <= 1:
        raise ValueError('Cannot remove the last model from the project')

    # Remove the model from the collection
    self._models.pop(index)

    # Remove experiment mapped to the removed model index.
    if index in self._experiments:
        self._experiments.pop(index)

    # Reindex experiments above the removed model index to keep mapping aligned.
    reindexed_experiments: dict[int, DataSet1D] = {}
    for exp_index, experiment in sorted(self._experiments.items()):
        if exp_index > index:
            reindexed_experiments[exp_index - 1] = experiment
        else:
            reindexed_experiments[exp_index] = experiment
    self._experiments = reindexed_experiments

    # 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

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
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
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
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
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
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
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
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
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
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
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.

Source code in src/easyreflectometry/project.py
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
def as_dict(self, include_materials_not_in_model=False):
    """As dict."""
    project_dict = {}
    project_dict['file_format'] = self.FILE_FORMAT
    project_dict['info'] = self._info
    project_dict['with_experiments'] = self._with_experiments
    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'
    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.fitter is not None:
        project_dict['fitter_minimizer'] = self.fitter.easy_science_multi_fitter.minimizer.name
    elif self._minimizer_selection is not None:
        project_dict['fitter_minimizer'] = self._minimizer_selection.name
    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
    return project_dict

from_dict(project_dict)

From dict.

Source code in src/easyreflectometry/project.py
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
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 != self.FILE_FORMAT:
        raise ValueError(
            f'Unsupported project file_format={file_format!r}; this version of '
            f'easyreflectometry only reads file_format={self.FILE_FORMAT}. Please '
            'either update easyreflectometry or re-create the project.'
        )
    self._info = project_dict['info']
    self._with_experiments = project_dict['with_experiments']
    if 'calculator' in keys:
        self._calculator.switch(project_dict['calculator'])
    if 'models' in keys:
        self.models = ModelCollection.from_dict(project_dict['models'])
    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']))
    if 'fitter_minimizer' in keys:
        self.minimizer = AvailableMinimizers[project_dict['fitter_minimizer']]
    else:
        self._fitter = None
    if 'experiments' in keys:
        self._experiments = self._from_dict_extract_experiments(project_dict)
    else:
        self._experiments = {}

    # 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', [])]

Summary

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

        # Get parameters directly from the model instead of using project.parameters
        model = self._project._models[self._project.current_model_index]
        parameters = model.get_all_parameters()

        for parameter in parameters:
            path = global_object.map.find_path(model.unique_name, parameter.unique_name)
            if 0 < len(path):
                name = f'{global_object.map.get_item_by_key(path[-2]).name} {global_object.map.get_item_by_key(path[-1]).name}'
            else:
                name = parameter.name
            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)
        resolution_function = model.resolution_function.as_dict()['smearing']
        if resolution_function == 'PercentageFwhm':
            precentage = model.resolution_function.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."""
        html_refinement = HTML_REFINEMENT_TEMPLATE

        # Get parameters directly from the model
        model = self._project._models[self._project.current_model_index]
        parameters = model.get_all_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(self._project.minimizer.name, self._project.minimizer.package),
        )
        html_refinement = html_refinement.replace('goodness_of_fit', goodness_of_fit)
        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:
        """Return reduced chi² as a formatted string, or 'N/A' if no fit has been run."""
        last_fit_results = getattr(self._project, '_last_fit_results', None)
        if not last_fit_results:
            return 'N/A'
        try:
            if len(last_fit_results) == 1:
                gof = float(last_fit_results[0].reduced_chi2)
            else:
                total_chi2 = sum(float(r.chi2) for r in last_fit_results)
                total_points = sum(len(r.x) for r in last_fit_results)
                n_pars = last_fit_results[0].n_pars
                dof = total_points - n_pars
                gof = total_chi2 / dof if dof > 0 else 0.0
            return f'{gof:.4g}'
        except (AttributeError, TypeError, ValueError, ZeroDivisionError):
            return 'N/A'

    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
116
117
118
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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
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
153
154
155
156
157
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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
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
175
176
177
178
179
180
181
182
183
184
185
186
187
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
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
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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
87
88
89
90
91
92
93
94
95
96
97
98
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}'

_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
101
102
103
104
105
106
107
108
109
110
111
112
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>'