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.
Files changed (49) hide show
  1. sassafras/__init__.py +5 -0
  2. sassafras/__main__.py +3 -0
  3. sassafras/_guess_worker.py +39 -0
  4. sassafras/backend.py +607 -0
  5. sassafras/fit.py +303 -0
  6. sassafras/guess.py +475 -0
  7. sassafras/library.py +544 -0
  8. sassafras/ml/SAS-55M-20k_LICENSE.txt +21 -0
  9. sassafras/ml/model.pt +0 -0
  10. sassafras/ml/sans_ml/__init__.py +0 -0
  11. sassafras/ml/sans_ml/catalogue.py +107 -0
  12. sassafras/ml/sans_ml/encode.py +51 -0
  13. sassafras/ml/sans_ml/net.py +59 -0
  14. sassafras/ml/sans_ml/predict.py +89 -0
  15. sassafras/ml/sans_ml/priors.py +514 -0
  16. sassafras/ml/sans_ml/simulate.py +270 -0
  17. sassafras/multi.py +147 -0
  18. sassafras/notebook.py +502 -0
  19. sassafras/server.py +244 -0
  20. sassafras/static/app.css +563 -0
  21. sassafras/static/app.js +1095 -0
  22. sassafras/static/copy.js +213 -0
  23. sassafras/static/favicon-chalked.png +0 -0
  24. sassafras/static/favicon-cooked.png +0 -0
  25. sassafras/static/favicon-girlypop.png +0 -0
  26. sassafras/static/favicon-washed.png +0 -0
  27. sassafras/static/favicon.png +0 -0
  28. sassafras/static/fitting.js +224 -0
  29. sassafras/static/guess.js +100 -0
  30. sassafras/static/index.html +152 -0
  31. sassafras/static/logo-chalked-dark.png +0 -0
  32. sassafras/static/logo-chalked-light.png +0 -0
  33. sassafras/static/logo-cooked-dark.png +0 -0
  34. sassafras/static/logo-cooked-light.png +0 -0
  35. sassafras/static/logo-dark.png +0 -0
  36. sassafras/static/logo-girlypop-dark.png +0 -0
  37. sassafras/static/logo-girlypop-light.png +0 -0
  38. sassafras/static/logo-light.png +0 -0
  39. sassafras/static/logo-washed-dark.png +0 -0
  40. sassafras/static/logo-washed-light.png +0 -0
  41. sassafras/static/multi.js +571 -0
  42. sassafras/static/notebook.js +53 -0
  43. sassafras-0.5.0.dist-info/METADATA +522 -0
  44. sassafras-0.5.0.dist-info/RECORD +49 -0
  45. sassafras-0.5.0.dist-info/WHEEL +5 -0
  46. sassafras-0.5.0.dist-info/entry_points.txt +2 -0
  47. sassafras-0.5.0.dist-info/licenses/LICENSE +28 -0
  48. sassafras-0.5.0.dist-info/licenses/sassafras/ml/SAS-55M-20k_LICENSE.txt +21 -0
  49. sassafras-0.5.0.dist-info/top_level.txt +1 -0
sassafras/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Sassafras: Small Angle Scattering Structure- and Form-factor RApid aSsessment.
2
+
3
+ Rapid SANS/SAXS data viewer with live sasmodels overlays (chi-by-eye, no optimiser).
4
+ """
5
+ __version__ = "0.5.0"
sassafras/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -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)}