sassafras 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sassafras/__init__.py +5 -0
- sassafras/__main__.py +3 -0
- sassafras/_guess_worker.py +39 -0
- sassafras/backend.py +607 -0
- sassafras/fit.py +303 -0
- sassafras/guess.py +475 -0
- sassafras/library.py +544 -0
- sassafras/ml/SAS-55M-20k_LICENSE.txt +21 -0
- sassafras/ml/model.pt +0 -0
- sassafras/ml/sans_ml/__init__.py +0 -0
- sassafras/ml/sans_ml/catalogue.py +107 -0
- sassafras/ml/sans_ml/encode.py +51 -0
- sassafras/ml/sans_ml/net.py +59 -0
- sassafras/ml/sans_ml/predict.py +89 -0
- sassafras/ml/sans_ml/priors.py +514 -0
- sassafras/ml/sans_ml/simulate.py +270 -0
- sassafras/multi.py +147 -0
- sassafras/notebook.py +502 -0
- sassafras/server.py +244 -0
- sassafras/static/app.css +563 -0
- sassafras/static/app.js +1095 -0
- sassafras/static/copy.js +213 -0
- sassafras/static/favicon-chalked.png +0 -0
- sassafras/static/favicon-cooked.png +0 -0
- sassafras/static/favicon-girlypop.png +0 -0
- sassafras/static/favicon-washed.png +0 -0
- sassafras/static/favicon.png +0 -0
- sassafras/static/fitting.js +224 -0
- sassafras/static/guess.js +100 -0
- sassafras/static/index.html +152 -0
- sassafras/static/logo-chalked-dark.png +0 -0
- sassafras/static/logo-chalked-light.png +0 -0
- sassafras/static/logo-cooked-dark.png +0 -0
- sassafras/static/logo-cooked-light.png +0 -0
- sassafras/static/logo-dark.png +0 -0
- sassafras/static/logo-girlypop-dark.png +0 -0
- sassafras/static/logo-girlypop-light.png +0 -0
- sassafras/static/logo-light.png +0 -0
- sassafras/static/logo-washed-dark.png +0 -0
- sassafras/static/logo-washed-light.png +0 -0
- sassafras/static/multi.js +571 -0
- sassafras/static/notebook.js +53 -0
- sassafras-0.5.0.dist-info/METADATA +522 -0
- sassafras-0.5.0.dist-info/RECORD +49 -0
- sassafras-0.5.0.dist-info/WHEEL +5 -0
- sassafras-0.5.0.dist-info/entry_points.txt +2 -0
- sassafras-0.5.0.dist-info/licenses/LICENSE +28 -0
- sassafras-0.5.0.dist-info/licenses/sassafras/ml/SAS-55M-20k_LICENSE.txt +21 -0
- sassafras-0.5.0.dist-info/top_level.txt +1 -0
sassafras/__init__.py
ADDED
sassafras/__main__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Runs the ml-sans network in a child process for the Guess button.
|
|
2
|
+
|
|
3
|
+
PyTorch is imported only here, never in the sassafras server process: on macOS,
|
|
4
|
+
torch and numpy/sasmodels can each bring their own OpenMP runtime, and loading
|
|
5
|
+
both aborts the whole process. A crash here costs one Guess, not the server.
|
|
6
|
+
|
|
7
|
+
Reads a JSON request on stdin, writes a JSON reply on stdout.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
req = json.load(sys.stdin)
|
|
15
|
+
sys.path.insert(0, req["folder"])
|
|
16
|
+
try:
|
|
17
|
+
import numpy as np
|
|
18
|
+
from sans_ml.predict import Suggester
|
|
19
|
+
except Exception as exc:
|
|
20
|
+
json.dump({"error": f"Could not import the ML model code: {type(exc).__name__}: {exc}"}, sys.stdout)
|
|
21
|
+
return
|
|
22
|
+
try:
|
|
23
|
+
sug = Suggester(req["model_path"])
|
|
24
|
+
except Exception as exc:
|
|
25
|
+
json.dump({"error": f"Could not load {req['model_path']}: {type(exc).__name__}: {exc}"}, sys.stdout)
|
|
26
|
+
return
|
|
27
|
+
di = None if req.get("di") is None else np.asarray(req["di"], float)
|
|
28
|
+
try:
|
|
29
|
+
ranked = sug.suggest(np.asarray(req["q"], float), np.asarray(req["i"], float), di, top=int(req["top"]))
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
json.dump({"error": f"The ML model can't use this curve: {exc}"}, sys.stdout)
|
|
32
|
+
return
|
|
33
|
+
out = [{"model": r["model"], "probability": float(r["probability"]),
|
|
34
|
+
"forms": list(r["forms"]), "structure": r["structure"]} for r in ranked]
|
|
35
|
+
json.dump({"ranked": out}, sys.stdout)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
sassafras/backend.py
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""Data loading and model evaluation for sassafras.
|
|
2
|
+
|
|
3
|
+
Everything numerical lives here, separate from the HTTP layer, so it can be
|
|
4
|
+
used and tested directly from Python:
|
|
5
|
+
|
|
6
|
+
>>> from sassafras.backend import Session
|
|
7
|
+
>>> s = Session()
|
|
8
|
+
>>> ds = s.load_path("sample.xml")
|
|
9
|
+
>>> out = s.evaluate({"model": "sphere", "params": {"radius": 60}})
|
|
10
|
+
|
|
11
|
+
Model curves come from sasmodels' ``DirectModel`` (the same calculator that
|
|
12
|
+
SasView and the sasmodels BUMPS wrapper use), so the instrument resolution
|
|
13
|
+
carried by a data file (pinhole dQ or slit dxl/dxw) is applied when a dataset
|
|
14
|
+
is active. Without data the model is evaluated on a plain log-spaced Q grid.
|
|
15
|
+
No optimiser is used anywhere: the only "fitting" is a closed-form weighted
|
|
16
|
+
linear solve for scale and background, triggered explicitly by the user.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import tempfile
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import warnings
|
|
27
|
+
from collections import OrderedDict
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
# Native C kernels by default. OpenCL on laptops is a frequent source of
|
|
32
|
+
# surprises (single precision, driver prompts); set SAS_OPENCL yourself to
|
|
33
|
+
# override this before launching.
|
|
34
|
+
os.environ.setdefault("SAS_OPENCL", "none")
|
|
35
|
+
|
|
36
|
+
import numpy as np # noqa: E402
|
|
37
|
+
|
|
38
|
+
PD_TYPES = ("gaussian", "lognormal", "schulz", "rectangle", "uniform", "boltzmann")
|
|
39
|
+
PD_DEFAULT_N = 35
|
|
40
|
+
PD_DEFAULT_NSIGMA = 3.0
|
|
41
|
+
MAX_KERNELS = 12 # compiled models kept in memory
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UserError(ValueError):
|
|
45
|
+
"""An error whose message is meant for the person at the screen."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------
|
|
49
|
+
# Units
|
|
50
|
+
# --------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def _norm_unit(unit: str | None) -> str:
|
|
53
|
+
return re.sub(r"[\s{}()^]", "", (unit or "")).lower().replace("angstrom", "a").replace("ang", "a").replace("å", "a")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def q_factor_to_inv_angstrom(unit: str | None) -> float:
|
|
57
|
+
"""Multiplier that converts Q in ``unit`` to 1/Å (sasmodels' unit)."""
|
|
58
|
+
u = _norm_unit(unit)
|
|
59
|
+
if u in ("", "a-1", "1/a", "inva", "a^-1"):
|
|
60
|
+
return 1.0
|
|
61
|
+
if u in ("nm-1", "1/nm", "invnm"):
|
|
62
|
+
return 0.1
|
|
63
|
+
if u in ("m-1", "1/m"):
|
|
64
|
+
return 1e-10
|
|
65
|
+
if u in ("cm-1", "1/cm") :
|
|
66
|
+
return 1e-8
|
|
67
|
+
return 1.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------
|
|
71
|
+
# Datasets
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Dataset:
|
|
76
|
+
id: str
|
|
77
|
+
label: str
|
|
78
|
+
source: str # path on disk, or "upload:<name>"
|
|
79
|
+
q: np.ndarray
|
|
80
|
+
i: np.ndarray
|
|
81
|
+
di: np.ndarray | None
|
|
82
|
+
dq: np.ndarray | None
|
|
83
|
+
dxl: np.ndarray | None = None
|
|
84
|
+
dxw: np.ndarray | None = None
|
|
85
|
+
q_unit_in_file: str = ""
|
|
86
|
+
i_unit: str = ""
|
|
87
|
+
notes: list[str] = field(default_factory=list)
|
|
88
|
+
entry: int = 0 # index of this entry in the file, as sasdata's Loader returns it
|
|
89
|
+
altered: bool = False # True if Q was rescaled or points were dropped on load
|
|
90
|
+
loaded_at: float = field(default_factory=time.time)
|
|
91
|
+
|
|
92
|
+
def to_sasmodels_data(self):
|
|
93
|
+
"""A sasmodels-compatible Data1D carrying this dataset's resolution."""
|
|
94
|
+
from sasmodels.data import Data1D
|
|
95
|
+
data = Data1D(x=self.q, y=self.i, dx=self.dq, dy=self.di)
|
|
96
|
+
if self.dxl is not None:
|
|
97
|
+
data.dxl = self.dxl
|
|
98
|
+
if self.dxw is not None:
|
|
99
|
+
data.dxw = self.dxw
|
|
100
|
+
data.filename = self.label
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
def background_estimate(self, n: int = 3) -> float:
|
|
104
|
+
"""Initial background guess: mean I of the n highest-Q points."""
|
|
105
|
+
return float(np.mean(self.i[-min(n, self.i.size):]))
|
|
106
|
+
|
|
107
|
+
def summary(self) -> dict:
|
|
108
|
+
resolution = "none"
|
|
109
|
+
if self.dxl is not None:
|
|
110
|
+
resolution = "slit"
|
|
111
|
+
elif self.dq is not None:
|
|
112
|
+
resolution = "pinhole dQ"
|
|
113
|
+
return {
|
|
114
|
+
"id": self.id, "label": self.label, "source": self.source,
|
|
115
|
+
"reloadable": not self.source.startswith("upload:"),
|
|
116
|
+
"n": int(self.q.size), "qmin": float(self.q.min()), "qmax": float(self.q.max()),
|
|
117
|
+
"has_di": self.di is not None, "resolution": resolution,
|
|
118
|
+
"q_unit_in_file": self.q_unit_in_file, "i_unit": self.i_unit,
|
|
119
|
+
"notes": self.notes,
|
|
120
|
+
"bkg_estimate": self.background_estimate(),
|
|
121
|
+
"q": self.q.tolist(), "i": self.i.tolist(),
|
|
122
|
+
"di": None if self.di is None else self.di.tolist(),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _clean_array(values, n):
|
|
127
|
+
if values is None:
|
|
128
|
+
return None
|
|
129
|
+
arr = np.asarray(values, dtype=float).ravel()
|
|
130
|
+
if arr.size != n or not np.any(arr > 0):
|
|
131
|
+
return None
|
|
132
|
+
return arr
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def datasets_from_loader_output(items, source: str, label_hint: str) -> tuple[list[dict], list[str]]:
|
|
136
|
+
"""Convert sasdata loader objects into plain dicts of 1/Å arrays.
|
|
137
|
+
|
|
138
|
+
Returns (records, messages). 2D data are skipped with a message.
|
|
139
|
+
"""
|
|
140
|
+
records, messages = [], []
|
|
141
|
+
one_d = [d for d in items if hasattr(d, "x") and not hasattr(d, "qx_data")]
|
|
142
|
+
skipped = len(items) - len(one_d)
|
|
143
|
+
if skipped:
|
|
144
|
+
messages.append(f"{label_hint}: skipped {skipped} 2D data set(s); reduce to 1D (I(Q)) first.")
|
|
145
|
+
for k, d in enumerate(one_d):
|
|
146
|
+
raw_index = next(j for j, item in enumerate(items) if item is d)
|
|
147
|
+
q = np.asarray(d.x, dtype=float).ravel()
|
|
148
|
+
y = np.asarray(d.y, dtype=float).ravel()
|
|
149
|
+
n = q.size
|
|
150
|
+
di = _clean_array(getattr(d, "dy", None), n)
|
|
151
|
+
dq = _clean_array(getattr(d, "dx", None), n)
|
|
152
|
+
dxl = _clean_array(getattr(d, "dxl", None), n)
|
|
153
|
+
dxw = _clean_array(getattr(d, "dxw", None), n)
|
|
154
|
+
notes = []
|
|
155
|
+
q_unit = getattr(d, "_xunit", "") or ""
|
|
156
|
+
factor = q_factor_to_inv_angstrom(q_unit)
|
|
157
|
+
if factor != 1.0:
|
|
158
|
+
q = q * factor
|
|
159
|
+
dq = None if dq is None else dq * factor
|
|
160
|
+
dxl = None if dxl is None else dxl * factor
|
|
161
|
+
dxw = None if dxw is None else dxw * factor
|
|
162
|
+
notes.append(f"Q converted from {q_unit} to 1/Å")
|
|
163
|
+
keep = np.isfinite(q) & np.isfinite(y) & (q > 0)
|
|
164
|
+
if di is not None:
|
|
165
|
+
keep &= np.isfinite(di) & (di > 0)
|
|
166
|
+
if not np.all(keep):
|
|
167
|
+
notes.append(f"dropped {int((~keep).sum())} point(s) with Q≤0, non-finite values or dI≤0")
|
|
168
|
+
if keep.sum() < 2:
|
|
169
|
+
messages.append(f"{label_hint}: entry {k + 1} has fewer than two usable points; skipped.")
|
|
170
|
+
continue
|
|
171
|
+
order = np.argsort(q[keep])
|
|
172
|
+
pick = lambda a: None if a is None else a[keep][order] # noqa: E731
|
|
173
|
+
title = (getattr(d, "title", "") or "").strip()
|
|
174
|
+
label = label_hint if len(one_d) == 1 else f"{label_hint} [{title or k + 1}]"
|
|
175
|
+
if di is None:
|
|
176
|
+
notes.append("no dI in file; χ² is not available for this dataset")
|
|
177
|
+
records.append(dict(
|
|
178
|
+
label=label, source=source, q=pick(q), i=pick(y), di=pick(di), dq=pick(dq),
|
|
179
|
+
dxl=pick(dxl), dxw=pick(dxw), q_unit_in_file=q_unit,
|
|
180
|
+
i_unit=getattr(d, "_yunit", "") or "", notes=notes,
|
|
181
|
+
entry=raw_index, altered=(factor != 1.0) or not bool(np.all(keep))))
|
|
182
|
+
return records, messages
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def load_file(path: str, label: str | None = None) -> tuple[list[dict], list[str]]:
|
|
186
|
+
"""Load any file sasdata can read (canSAS XML, NXcanSAS HDF5, ASCII/CSV,
|
|
187
|
+
IGOR .abs/.cor, .pdh, ...). Returns (records, messages)."""
|
|
188
|
+
from sasdata.dataloader.loader import Loader
|
|
189
|
+
p = Path(path).expanduser().resolve() # absolute, so exports work from any folder
|
|
190
|
+
if not p.is_file():
|
|
191
|
+
raise UserError(f"File not found: {p}")
|
|
192
|
+
with warnings.catch_warnings():
|
|
193
|
+
warnings.simplefilter("ignore")
|
|
194
|
+
try:
|
|
195
|
+
items = Loader().load(str(p))
|
|
196
|
+
except Exception as exc: # sasdata raises a variety of types
|
|
197
|
+
raise UserError(f"sasdata could not read {p.name}: {exc}") from None
|
|
198
|
+
if not isinstance(items, list):
|
|
199
|
+
items = [items]
|
|
200
|
+
if not items:
|
|
201
|
+
raise UserError(f"sasdata found no data in {p.name}.")
|
|
202
|
+
return datasets_from_loader_output(items, str(p), label or p.name)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def supported_extensions() -> list[str]:
|
|
206
|
+
try:
|
|
207
|
+
from sasdata.dataloader.loader import Loader
|
|
208
|
+
exts = set()
|
|
209
|
+
for wc in Loader().get_wildcards():
|
|
210
|
+
exts.update(re.findall(r"\*(\.[A-Za-z0-9]+)", wc))
|
|
211
|
+
return sorted(exts)
|
|
212
|
+
except Exception:
|
|
213
|
+
return [".xml", ".h5", ".hdf5", ".nxs", ".txt", ".dat", ".csv", ".abs", ".cor", ".pdh", ".ses"]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --------------------------------------------------------------------------
|
|
217
|
+
# Model catalogue
|
|
218
|
+
# --------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
CATEGORY_LABELS = {
|
|
221
|
+
"shape:sphere": "Spheres", "shape:cylinder": "Cylinders",
|
|
222
|
+
"shape:ellipsoid": "Ellipsoids", "shape:parallelepiped": "Parallelepipeds",
|
|
223
|
+
"shape:lamellae": "Lamellae", "shape:polyhedron": "Polyhedra",
|
|
224
|
+
"shape:paracrystal": "Paracrystals", "shape-independent": "Shape-independent",
|
|
225
|
+
"structure-factor": "Structure factor only",
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Form factors that get no S(Q): they already contain inter-particle interference
|
|
230
|
+
# (paracrystals, lamellar stacks, binary_hard_sphere, RPA, peaks/correlation
|
|
231
|
+
# functions) or describe no finite particle (power laws, line). sasmodels can
|
|
232
|
+
# build "x@hardsphere" for them, but it is not physically meaningful. Same list
|
|
233
|
+
# as ml-sans (sans_ml.catalogue.NO_STRUCTURE_FACTOR), so its suggestions load.
|
|
234
|
+
NO_STRUCTURE_FACTOR = {
|
|
235
|
+
"bcc_paracrystal", "fcc_paracrystal", "sc_paracrystal",
|
|
236
|
+
"lamellar_stack_caille", "lamellar_hg_stack_caille", "lamellar_stack_paracrystal",
|
|
237
|
+
"binary_hard_sphere", "rpa", "be_polyelectrolyte", "teubner_strey", "spinodal",
|
|
238
|
+
"gaussian_peak", "peak_lorentz", "broad_peak", "porod", "power_law", "line",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _first_sentence(text: str, limit: int = 220) -> str:
|
|
243
|
+
text = " ".join((text or "").split())
|
|
244
|
+
m = re.match(r"(.{20,}?[.!?])(\s|$)", text)
|
|
245
|
+
out = m.group(1) if m else text
|
|
246
|
+
return out if len(out) <= limit else out[: limit - 1] + "…"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
MAX_COMPONENTS = 4 # form factors in one mixture (the first may carry an S(Q))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class Catalogue:
|
|
253
|
+
def __init__(self):
|
|
254
|
+
from sasmodels import core
|
|
255
|
+
self._core = core
|
|
256
|
+
self.infos = {}
|
|
257
|
+
for name in core.list_models("all"):
|
|
258
|
+
try:
|
|
259
|
+
self.infos[name] = core.load_model_info(name)
|
|
260
|
+
except Exception:
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
def listing(self) -> dict:
|
|
264
|
+
form_factors, structure_factors = [], []
|
|
265
|
+
for name, info in sorted(self.infos.items()):
|
|
266
|
+
entry = {
|
|
267
|
+
"name": name,
|
|
268
|
+
"title": info.title or name,
|
|
269
|
+
"category": CATEGORY_LABELS.get(info.category, info.category or "Other"),
|
|
270
|
+
"accepts_sq": (not info.structure_factor) and name not in NO_STRUCTURE_FACTOR,
|
|
271
|
+
}
|
|
272
|
+
if info.structure_factor:
|
|
273
|
+
structure_factors.append(entry)
|
|
274
|
+
if "magnetic" in name.lower() or name.startswith("micromagnetic"):
|
|
275
|
+
continue
|
|
276
|
+
form_factors.append(entry)
|
|
277
|
+
return {"form_factors": form_factors, "structure_factors": structure_factors}
|
|
278
|
+
|
|
279
|
+
def full_name(self, model: str, structure_factor: str | None, components=None) -> str:
|
|
280
|
+
"""sasmodels name: ``model[@sf][+form2[+form3[+form4]]]``. The structure factor
|
|
281
|
+
applies to the first form only (``@`` binds tighter than ``+``); extra forms
|
|
282
|
+
get sasmodels' B_, C_, D_ parameter prefixes and the first one A_."""
|
|
283
|
+
if model not in self.infos:
|
|
284
|
+
raise UserError(f"Unknown sasmodels model: {model!r}")
|
|
285
|
+
name = model
|
|
286
|
+
if structure_factor:
|
|
287
|
+
sf = self.infos.get(structure_factor)
|
|
288
|
+
if sf is None or not sf.structure_factor:
|
|
289
|
+
raise UserError(f"Unknown structure factor: {structure_factor!r}")
|
|
290
|
+
if self.infos[model].structure_factor:
|
|
291
|
+
raise UserError("Choose a form factor, not a structure factor, before adding S(Q).")
|
|
292
|
+
name = f"{model}@{structure_factor}"
|
|
293
|
+
extra = [c for c in (components or []) if c]
|
|
294
|
+
if len(extra) > MAX_COMPONENTS - 1:
|
|
295
|
+
raise UserError(f"At most {MAX_COMPONENTS} form factors can be combined.")
|
|
296
|
+
for c in extra:
|
|
297
|
+
if c not in self.infos or self.infos[c].structure_factor:
|
|
298
|
+
raise UserError(f"Unknown form factor for a mixture: {c!r}")
|
|
299
|
+
return "+".join([name] + extra)
|
|
300
|
+
|
|
301
|
+
def model_info(self, full_name: str):
|
|
302
|
+
try:
|
|
303
|
+
return self._core.load_model_info(full_name)
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
raise UserError(f"sasmodels cannot build {full_name}: {exc}") from None
|
|
306
|
+
|
|
307
|
+
def describe(self, full_name: str, controls: dict | None = None) -> dict:
|
|
308
|
+
"""Parameter list for the UI, honouring control parameters such as
|
|
309
|
+
the shell count ``n`` of multi-shell models."""
|
|
310
|
+
info = self.model_info(full_name)
|
|
311
|
+
table = info.parameters
|
|
312
|
+
ctrl = {}
|
|
313
|
+
control_names = set()
|
|
314
|
+
for p in table.kernel_parameters:
|
|
315
|
+
if p.length_control:
|
|
316
|
+
control_names.add(p.length_control)
|
|
317
|
+
for name in control_names:
|
|
318
|
+
default = next((p.default for p in table.kernel_parameters if p.name == name), 1)
|
|
319
|
+
value = (controls or {}).get(name, default)
|
|
320
|
+
try:
|
|
321
|
+
ctrl[name] = int(round(float(value)))
|
|
322
|
+
except (TypeError, ValueError):
|
|
323
|
+
ctrl[name] = int(default)
|
|
324
|
+
user = table.user_parameters(ctrl, is2d=False)
|
|
325
|
+
params = []
|
|
326
|
+
for p in user:
|
|
327
|
+
if p.name in ("up_frac_i", "up_frac_f", "up_theta", "up_phi") or p.name.endswith(("_M0", "_mtheta", "_mphi")):
|
|
328
|
+
continue
|
|
329
|
+
lo, hi = p.limits
|
|
330
|
+
choices = list(getattr(p, "choices", None) or [])
|
|
331
|
+
kind = "choice" if choices else ("control" if p.name in control_names else "float")
|
|
332
|
+
units = "" if p.units in (None, "None") else p.units
|
|
333
|
+
params.append({
|
|
334
|
+
"name": p.name,
|
|
335
|
+
"default": float(ctrl.get(p.name, p.default)) if kind != "float" else float(p.default),
|
|
336
|
+
"min": None if not math.isfinite(lo) else float(lo),
|
|
337
|
+
"max": None if not math.isfinite(hi) else float(hi),
|
|
338
|
+
"units": units,
|
|
339
|
+
"description": p.description or "",
|
|
340
|
+
"type": p.type or "",
|
|
341
|
+
"kind": kind,
|
|
342
|
+
"choices": choices,
|
|
343
|
+
"polydisperse": bool(p.polydisperse) and kind == "float",
|
|
344
|
+
})
|
|
345
|
+
# scale and background first, then the rest in sasmodels order
|
|
346
|
+
params.sort(key=lambda d: {"scale": 0, "background": 1}.get(d["name"], 2))
|
|
347
|
+
return {
|
|
348
|
+
"model": full_name,
|
|
349
|
+
"title": info.title or full_name,
|
|
350
|
+
"summary": _first_sentence(info.description or ""),
|
|
351
|
+
"parameters": params,
|
|
352
|
+
"controls": ctrl,
|
|
353
|
+
"pd_types": list(PD_TYPES),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# --------------------------------------------------------------------------
|
|
358
|
+
# Evaluation session
|
|
359
|
+
# --------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
class Session:
|
|
362
|
+
"""Holds loaded datasets and compiled kernels for one GUI instance."""
|
|
363
|
+
|
|
364
|
+
def __init__(self):
|
|
365
|
+
self.lock = threading.RLock()
|
|
366
|
+
self.catalogue = Catalogue()
|
|
367
|
+
self.datasets: "OrderedDict[str, Dataset]" = OrderedDict()
|
|
368
|
+
self._kernels: "OrderedDict[str, object]" = OrderedDict()
|
|
369
|
+
self._calculators: dict = {}
|
|
370
|
+
self._counter = 0
|
|
371
|
+
|
|
372
|
+
# ---- datasets -------------------------------------------------------
|
|
373
|
+
def _add_records(self, records):
|
|
374
|
+
added = []
|
|
375
|
+
with self.lock:
|
|
376
|
+
for rec in records:
|
|
377
|
+
self._counter += 1
|
|
378
|
+
ds = Dataset(id=f"d{self._counter}", **rec)
|
|
379
|
+
self.datasets[ds.id] = ds
|
|
380
|
+
added.append(ds)
|
|
381
|
+
return added
|
|
382
|
+
|
|
383
|
+
def load_path(self, path: str):
|
|
384
|
+
p = Path(path).expanduser()
|
|
385
|
+
if p.is_dir():
|
|
386
|
+
exts = set(supported_extensions())
|
|
387
|
+
files = sorted(f for f in p.iterdir() if f.is_file() and f.suffix.lower() in exts)
|
|
388
|
+
if not files:
|
|
389
|
+
raise UserError(f"No files with a sasdata extension in {p}")
|
|
390
|
+
if len(files) > 50:
|
|
391
|
+
raise UserError(f"{p} holds {len(files)} candidate files; load a narrower selection.")
|
|
392
|
+
added, messages = [], []
|
|
393
|
+
for f in files:
|
|
394
|
+
try:
|
|
395
|
+
recs, msgs = load_file(str(f))
|
|
396
|
+
added += self._add_records(recs)
|
|
397
|
+
messages += msgs
|
|
398
|
+
except UserError as exc:
|
|
399
|
+
messages.append(str(exc))
|
|
400
|
+
return added, messages
|
|
401
|
+
recs, msgs = load_file(str(p))
|
|
402
|
+
return self._add_records(recs), msgs
|
|
403
|
+
|
|
404
|
+
def load_bytes(self, filename: str, payload: bytes):
|
|
405
|
+
name = Path(filename or "upload.dat").name
|
|
406
|
+
suffix = Path(name).suffix or ".dat"
|
|
407
|
+
with tempfile.TemporaryDirectory(prefix="sassafras_") as tmp:
|
|
408
|
+
tmp_path = Path(tmp) / f"upload{suffix}"
|
|
409
|
+
tmp_path.write_bytes(payload)
|
|
410
|
+
recs, msgs = load_file(str(tmp_path), label=name)
|
|
411
|
+
for r in recs:
|
|
412
|
+
r["source"] = f"upload:{name}"
|
|
413
|
+
return self._add_records(recs), msgs
|
|
414
|
+
|
|
415
|
+
def reload(self, ds_id: str):
|
|
416
|
+
with self.lock:
|
|
417
|
+
ds = self._get(ds_id)
|
|
418
|
+
if ds.source.startswith("upload:"):
|
|
419
|
+
raise UserError("Uploaded files cannot be re-read from disk; load by path to enable reload.")
|
|
420
|
+
recs, msgs = load_file(ds.source)
|
|
421
|
+
if not recs:
|
|
422
|
+
raise UserError("Reloaded file contained no 1D data.")
|
|
423
|
+
rec = recs[0]
|
|
424
|
+
rec["label"] = ds.label
|
|
425
|
+
new = Dataset(id=ds.id, **rec)
|
|
426
|
+
self.datasets[ds.id] = new
|
|
427
|
+
self._calculators = {k: v for k, v in self._calculators.items() if k[0] != ds.id}
|
|
428
|
+
return new, msgs
|
|
429
|
+
|
|
430
|
+
def remove(self, ds_id: str):
|
|
431
|
+
with self.lock:
|
|
432
|
+
self._get(ds_id)
|
|
433
|
+
del self.datasets[ds_id]
|
|
434
|
+
self._calculators = {k: v for k, v in self._calculators.items() if k[0] != ds_id}
|
|
435
|
+
|
|
436
|
+
def _get(self, ds_id) -> Dataset:
|
|
437
|
+
ds = self.datasets.get(ds_id)
|
|
438
|
+
if ds is None:
|
|
439
|
+
raise UserError("That dataset is no longer loaded.")
|
|
440
|
+
return ds
|
|
441
|
+
|
|
442
|
+
# ---- models ---------------------------------------------------------
|
|
443
|
+
def kernel(self, full_name: str):
|
|
444
|
+
from sasmodels import core
|
|
445
|
+
with self.lock:
|
|
446
|
+
if full_name in self._kernels:
|
|
447
|
+
self._kernels.move_to_end(full_name)
|
|
448
|
+
return self._kernels[full_name]
|
|
449
|
+
try:
|
|
450
|
+
kernel = core.load_model(full_name, dtype="double")
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
raise UserError(
|
|
453
|
+
f"Could not compile {full_name}: {exc}. sasmodels needs a working C "
|
|
454
|
+
"compiler (or OpenCL) to build model kernels.") from None
|
|
455
|
+
self._kernels[full_name] = kernel
|
|
456
|
+
while len(self._kernels) > MAX_KERNELS:
|
|
457
|
+
old, _ = self._kernels.popitem(last=False)
|
|
458
|
+
self._calculators = {k: v for k, v in self._calculators.items() if k[1] != old}
|
|
459
|
+
return kernel
|
|
460
|
+
|
|
461
|
+
def describe(self, model: str, structure_factor: str | None, controls=None, components=None):
|
|
462
|
+
full = self.catalogue.full_name(model, structure_factor, components)
|
|
463
|
+
desc = self.catalogue.describe(full, controls)
|
|
464
|
+
t0 = time.perf_counter()
|
|
465
|
+
self.kernel(full) # compile now so slider moves are fast
|
|
466
|
+
desc["compile_ms"] = (time.perf_counter() - t0) * 1000
|
|
467
|
+
return desc
|
|
468
|
+
|
|
469
|
+
def _calculator(self, full_name: str, ds: Dataset | None, grid: tuple | None):
|
|
470
|
+
from sasmodels.direct_model import DirectModel
|
|
471
|
+
from sasmodels.data import empty_data1D
|
|
472
|
+
key = (ds.id if ds else None, full_name, grid if ds is None else None)
|
|
473
|
+
calc = self._calculators.get(key)
|
|
474
|
+
if calc is None:
|
|
475
|
+
kernel = self.kernel(full_name)
|
|
476
|
+
if ds is not None:
|
|
477
|
+
data = ds.to_sasmodels_data()
|
|
478
|
+
else:
|
|
479
|
+
qmin, qmax, n = grid
|
|
480
|
+
data = empty_data1D(np.geomspace(qmin, qmax, n))
|
|
481
|
+
calc = DirectModel(data, kernel)
|
|
482
|
+
self._calculators[key] = calc
|
|
483
|
+
return calc
|
|
484
|
+
|
|
485
|
+
@staticmethod
|
|
486
|
+
def _parameters(params: dict, pd: dict | None) -> dict:
|
|
487
|
+
out = {}
|
|
488
|
+
for name, value in (params or {}).items():
|
|
489
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
490
|
+
raise UserError(f"{name}: expected a number.")
|
|
491
|
+
if not math.isfinite(float(value)):
|
|
492
|
+
raise UserError(f"{name}: expected a finite number.")
|
|
493
|
+
out[str(name)] = float(value)
|
|
494
|
+
for name, spec in (pd or {}).items():
|
|
495
|
+
width = float(spec.get("width", 0) or 0)
|
|
496
|
+
if not math.isfinite(width) or width < 0:
|
|
497
|
+
raise UserError(f"{name}: polydispersity must be ≥ 0.")
|
|
498
|
+
if width <= 0:
|
|
499
|
+
continue
|
|
500
|
+
kind = spec.get("type", "gaussian")
|
|
501
|
+
if kind not in PD_TYPES:
|
|
502
|
+
raise UserError(f"{name}: unknown distribution {kind!r}.")
|
|
503
|
+
out[f"{name}_pd"] = width
|
|
504
|
+
out[f"{name}_pd_type"] = kind
|
|
505
|
+
out[f"{name}_pd_n"] = int(spec.get("n", PD_DEFAULT_N))
|
|
506
|
+
out[f"{name}_pd_nsigma"] = float(spec.get("nsigma", PD_DEFAULT_NSIGMA))
|
|
507
|
+
return out
|
|
508
|
+
|
|
509
|
+
@staticmethod
|
|
510
|
+
def _grid(request) -> tuple:
|
|
511
|
+
g = request.get("grid") or {}
|
|
512
|
+
qmin = float(g.get("qmin", 1e-3))
|
|
513
|
+
qmax = float(g.get("qmax", 0.5))
|
|
514
|
+
n = int(g.get("n", 300))
|
|
515
|
+
if not (0 < qmin < qmax and math.isfinite(qmax)):
|
|
516
|
+
raise UserError("Q grid: need 0 < Qmin < Qmax.")
|
|
517
|
+
if not 10 <= n <= 5000:
|
|
518
|
+
raise UserError("Q grid: use 10 to 5000 points.")
|
|
519
|
+
return (qmin, qmax, n)
|
|
520
|
+
|
|
521
|
+
def evaluate(self, request: dict) -> dict:
|
|
522
|
+
"""Evaluate the model.
|
|
523
|
+
|
|
524
|
+
request keys: model, structure_factor, params, pd, dataset (id or None),
|
|
525
|
+
grid {qmin,qmax,n} (used when no dataset), window [qmin,qmax] for χ².
|
|
526
|
+
"""
|
|
527
|
+
full = self.catalogue.full_name(request.get("model", ""), request.get("structure_factor") or None,
|
|
528
|
+
request.get("components"))
|
|
529
|
+
ds = self._get(request["dataset"]) if request.get("dataset") else None
|
|
530
|
+
grid = None if ds else self._grid(request)
|
|
531
|
+
pars = self._parameters(request.get("params"), request.get("pd"))
|
|
532
|
+
with self.lock:
|
|
533
|
+
calc = self._calculator(full, ds, grid)
|
|
534
|
+
t0 = time.perf_counter()
|
|
535
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
536
|
+
warnings.simplefilter("always")
|
|
537
|
+
try:
|
|
538
|
+
theory = np.asarray(calc(**pars), dtype=float)
|
|
539
|
+
except Exception as exc:
|
|
540
|
+
raise UserError(f"Model evaluation failed: {type(exc).__name__}: {exc}") from None
|
|
541
|
+
elapsed = (time.perf_counter() - t0) * 1000
|
|
542
|
+
q = ds.q if ds else np.geomspace(*grid[:2], grid[2])
|
|
543
|
+
result = {
|
|
544
|
+
"model": full, "q": q.tolist(),
|
|
545
|
+
"theory": [v if math.isfinite(v) else None for v in theory.tolist()],
|
|
546
|
+
"elapsed_ms": elapsed,
|
|
547
|
+
"warnings": sorted({str(w.message) for w in caught}),
|
|
548
|
+
"dataset": ds.id if ds else None,
|
|
549
|
+
}
|
|
550
|
+
if not np.all(np.isfinite(theory)):
|
|
551
|
+
result["warnings"].append("The model returned non-finite values at some Q; those points are not drawn.")
|
|
552
|
+
if ds is not None:
|
|
553
|
+
result.update(self._statistics(ds, theory, request.get("window")))
|
|
554
|
+
return result
|
|
555
|
+
|
|
556
|
+
@staticmethod
|
|
557
|
+
def _window_mask(ds: Dataset, window) -> np.ndarray:
|
|
558
|
+
mask = np.ones(ds.q.size, dtype=bool)
|
|
559
|
+
if window:
|
|
560
|
+
lo, hi = window
|
|
561
|
+
if lo is not None:
|
|
562
|
+
mask &= ds.q >= float(lo)
|
|
563
|
+
if hi is not None:
|
|
564
|
+
mask &= ds.q <= float(hi)
|
|
565
|
+
return mask
|
|
566
|
+
|
|
567
|
+
def _statistics(self, ds: Dataset, theory: np.ndarray, window) -> dict:
|
|
568
|
+
if ds.di is None:
|
|
569
|
+
return {"residuals": None, "chisq": None, "chisq_n": 0}
|
|
570
|
+
resid = (ds.i - theory) / ds.di
|
|
571
|
+
mask = self._window_mask(ds, window) & np.isfinite(resid)
|
|
572
|
+
n = int(mask.sum())
|
|
573
|
+
chisq = float(np.sum(resid[mask] ** 2) / n) if n else None
|
|
574
|
+
return {
|
|
575
|
+
"residuals": [float(r) if math.isfinite(r) else None for r in resid],
|
|
576
|
+
"chisq": chisq, "chisq_n": n,
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
def match_scale(self, request: dict) -> dict:
|
|
580
|
+
"""Closed-form weighted least squares for scale (and optionally
|
|
581
|
+
background) with the model shape held fixed. Not an iterative fit."""
|
|
582
|
+
ds = self._get(request.get("dataset")) if request.get("dataset") else None
|
|
583
|
+
if ds is None:
|
|
584
|
+
raise UserError("Load and select a dataset first.")
|
|
585
|
+
params = dict(request.get("params") or {})
|
|
586
|
+
scale0 = float(params.get("scale", 1.0))
|
|
587
|
+
bkg0 = float(params.get("background", 0.0))
|
|
588
|
+
if scale0 == 0:
|
|
589
|
+
params["scale"] = scale0 = 1.0
|
|
590
|
+
shape_req = dict(request, params={**params, "background": 0.0})
|
|
591
|
+
shape = np.asarray([np.nan if v is None else v for v in self.evaluate(shape_req)["theory"]]) / scale0
|
|
592
|
+
mask = self._window_mask(ds, request.get("window")) & np.isfinite(shape)
|
|
593
|
+
w = 1.0 / ds.di[mask] if ds.di is not None else 1.0 / np.maximum(np.abs(ds.i[mask]), 1e-30)
|
|
594
|
+
y, p = ds.i[mask], shape[mask]
|
|
595
|
+
if mask.sum() < 2 or not np.any(p != 0):
|
|
596
|
+
raise UserError("Not enough usable points in the Q window to match.")
|
|
597
|
+
fit_bkg = bool(request.get("background", True))
|
|
598
|
+
if fit_bkg:
|
|
599
|
+
A = np.c_[p, np.ones_like(p)] * w[:, None]
|
|
600
|
+
(s, b), *_ = np.linalg.lstsq(A, y * w, rcond=None)
|
|
601
|
+
if s < 0: # negative scale is unphysical: fall back to bkg-only
|
|
602
|
+
s, b = 0.0, float(np.sum(w * w * y) / np.sum(w * w))
|
|
603
|
+
else:
|
|
604
|
+
yb = y - bkg0
|
|
605
|
+
s = float(np.sum(w * w * p * yb) / np.sum(w * w * p * p))
|
|
606
|
+
s, b = max(s, 0.0), bkg0
|
|
607
|
+
return {"scale": float(s), "background": float(b)}
|