quantized-lab 0.8.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 (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,258 @@
1
+ """Optional bumps fit engine adapter (GOTO #10).
2
+
3
+ Pure calc layer. `bumps <https://github.com/bumps/bumps>`_ (BSD-3) is an
4
+ OPTIONAL dependency, imported inside functions and guarded — the MATLAB-parity
5
+ fitter (``calc.fitting.curve_fit``, golden-locked) stays the default engine
6
+ everywhere; bumps adds ``amoeba`` / ``lm`` / ``de`` (synchronous, Hessian
7
+ uncertainties) and ``dream`` (posterior sampling — long-running, driven
8
+ through the poll-model job runner ``quantized.jobs`` by the route layer).
9
+
10
+ There is deliberately NO golden parity here: bumps has no ``quantized_matlab``
11
+ counterpart, so its tests are reference-value / invariant tests, never
12
+ ``@pytest.mark.golden`` (see ``tests/test_calc_fit_bumps.py``).
13
+
14
+ A registry model name (``calc.fit_models.FIT_MODELS`` — which also holds
15
+ equation-builder custom models once registered) or a raw ``f(x, p)`` callable
16
+ is wrapped into a ``bumps.curve.Curve`` via a synthesized keyword signature
17
+ (bumps introspects parameter names; internal names are ``p1..pn`` to avoid
18
+ collisions with ``Curve`` attributes, and results map back positionally).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import inspect
24
+ import math
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+ from numpy.typing import ArrayLike, NDArray
30
+
31
+ from .fit_models import FIT_MODELS, evaluate
32
+
33
+ __all__ = ["BUMPS_ENGINES", "bumps_available", "fit_bumps"]
34
+
35
+ ModelFn = Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
36
+ ProgressFn = Callable[[float], None]
37
+ AbortFn = Callable[[], bool]
38
+
39
+ #: Supported bumps engine ids (bumps.fitters FITTERS ids).
40
+ BUMPS_ENGINES = ("amoeba", "lm", "de", "dream")
41
+
42
+ _INSTALL_HINT = (
43
+ "bumps is not installed - the optional fit engine needs "
44
+ "'pip install quantized[bumps]' (or: uv sync --extra bumps)"
45
+ )
46
+
47
+
48
+ def bumps_available() -> bool:
49
+ """True when the optional bumps dependency is importable."""
50
+ try:
51
+ import bumps # noqa: F401
52
+ except ImportError:
53
+ return False
54
+ return True
55
+
56
+
57
+ def _import_bumps() -> tuple[Any, Any, Any, Any]:
58
+ """Guarded import -> (Curve, FitProblem, FITTERS, FitDriver)."""
59
+ try:
60
+ from bumps.curve import Curve
61
+ from bumps.fitproblem import FitProblem
62
+ from bumps.fitters import FITTERS, FitDriver
63
+ except ImportError as exc:
64
+ raise ValueError(_INSTALL_HINT) from exc
65
+ return Curve, FitProblem, FITTERS, FitDriver
66
+
67
+
68
+ def _wrap_model(model_fcn: ModelFn, n_params: int) -> tuple[Callable[..., Any], list[str]]:
69
+ """Wrap ``f(x, p_array)`` for bumps' signature introspection.
70
+
71
+ bumps ``Curve`` reads parameter names from the function signature and
72
+ calls ``fn(x, **params)``; the wrapper carries a synthesized signature
73
+ with safe internal names ``p1..pn`` (guaranteed not to collide with
74
+ ``Curve`` attributes such as ``x``/``y``/``name``/``state``).
75
+ """
76
+ names = [f"p{i + 1}" for i in range(n_params)]
77
+
78
+ def fn(x: Any, **kw: float) -> Any:
79
+ p = np.array([kw[nm] for nm in names], dtype=float)
80
+ return model_fcn(np.asarray(x, dtype=float), p)
81
+
82
+ pos = inspect.Parameter.POSITIONAL_OR_KEYWORD
83
+ sig = inspect.Signature(
84
+ [inspect.Parameter("x", pos)] + [inspect.Parameter(nm, pos) for nm in names]
85
+ )
86
+ setattr(fn, "__signature__", sig) # noqa: B010 — plain attr assign confuses mypy
87
+ return fn, names
88
+
89
+
90
+ class _ProgressMonitor:
91
+ """bumps Monitor -> plain ``progress_callback(fraction)`` bridge.
92
+
93
+ ``total_steps`` is the predicted generation count (burn + draw
94
+ generations); the reported fraction is clamped below 1.0 — the caller
95
+ (job runner) owns the terminal 100%.
96
+ """
97
+
98
+ def __init__(self, total_steps: int, callback: ProgressFn) -> None:
99
+ self._total = max(1, total_steps)
100
+ self._callback = callback
101
+
102
+ def config_history(self, history: Any) -> None:
103
+ history.requires(step=1)
104
+
105
+ def __call__(self, history: Any) -> None:
106
+ step = int(history.step[0])
107
+ self._callback(min(0.99, step / self._total))
108
+
109
+
110
+ def _resolve_model(
111
+ model: str | ModelFn, param_names: list[str] | None, n_params: int
112
+ ) -> tuple[ModelFn, list[str]]:
113
+ """Registry name or callable -> (f(x, p) callable, display param names)."""
114
+ if callable(model):
115
+ names = list(param_names) if param_names else [f"p{i + 1}" for i in range(n_params)]
116
+ return model, names
117
+
118
+ if model not in FIT_MODELS:
119
+ raise ValueError(f"unknown fit model: {model}")
120
+ spec = FIT_MODELS[model]
121
+ if n_params != int(spec["nParams"]):
122
+ raise ValueError(
123
+ f"model '{model}' takes {spec['nParams']} parameters, got {n_params} initial values"
124
+ )
125
+
126
+ def fcn(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
127
+ return evaluate(model, x, p)
128
+
129
+ names = list(param_names) if param_names else list(spec["paramNames"])
130
+ return fcn, names
131
+
132
+
133
+ def fit_bumps(
134
+ x: ArrayLike,
135
+ y: ArrayLike,
136
+ dy: ArrayLike | None = None,
137
+ *,
138
+ model: str | ModelFn,
139
+ p0: list[float],
140
+ lower: list[float] | None = None,
141
+ upper: list[float] | None = None,
142
+ param_names: list[str] | None = None,
143
+ engine: str = "amoeba",
144
+ samples: int = 10_000,
145
+ burn: int = 100,
146
+ pop: int = 10,
147
+ return_samples: bool = False,
148
+ progress_callback: ProgressFn | None = None,
149
+ abort_check: AbortFn | None = None,
150
+ ) -> dict[str, Any]:
151
+ """Fit (x, y[, dy]) with a bumps engine; returns a plain result dict.
152
+
153
+ ``model`` is a ``calc.fit_models`` registry name (covers built-in AND
154
+ saved equation-builder models) or a raw ``f(x, p)`` callable. ``engine``
155
+ is one of ``BUMPS_ENGINES``: amoeba / lm / de report Hessian-derived
156
+ uncertainties (``uncertainty_kind='hessian'``); dream samples the
157
+ posterior (``uncertainty_kind='posterior'``) and additionally returns
158
+ per-parameter medians and central 68% intervals (plus the raw draw
159
+ matrix when ``return_samples`` — corner-plot food).
160
+
161
+ ``samples`` / ``burn`` / ``pop`` tune dream only. ``progress_callback``
162
+ (fraction in [0, 1)) and ``abort_check`` (True -> stop sampling early)
163
+ let the job runner drive progress and cancellation; an exception raised
164
+ by ``progress_callback`` (e.g. ``jobs.JobCancelled``) propagates out.
165
+
166
+ Raises ``ValueError`` for a missing bumps install, unknown model/engine,
167
+ or malformed inputs — the route layer maps that to HTTP 422.
168
+ """
169
+ Curve, FitProblem, FITTERS, FitDriver = _import_bumps()
170
+
171
+ if engine not in BUMPS_ENGINES:
172
+ raise ValueError(f"unknown bumps engine: {engine} (choose from {', '.join(BUMPS_ENGINES)})")
173
+ xv = np.asarray(x, dtype=float).ravel()
174
+ yv = np.asarray(y, dtype=float).ravel()
175
+ if xv.size != yv.size:
176
+ raise ValueError(f"x and y must have equal length (got {xv.size} and {yv.size})")
177
+ n_params = len(p0)
178
+ if n_params == 0:
179
+ raise ValueError("p0 must contain at least one initial parameter value")
180
+ if xv.size <= n_params:
181
+ raise ValueError(f"need more points ({xv.size}) than parameters ({n_params})")
182
+ if not all(math.isfinite(v) for v in p0):
183
+ raise ValueError("p0 values must be finite")
184
+ dyv: NDArray[np.float64] | None = None
185
+ if dy is not None:
186
+ dyv = np.asarray(dy, dtype=float).ravel()
187
+ if dyv.size != yv.size:
188
+ raise ValueError(f"dy must match y length (got {dyv.size} and {yv.size})")
189
+ if not np.all(dyv > 0):
190
+ raise ValueError("dy values must all be positive")
191
+ lb = list(lower) if lower is not None else [-math.inf] * n_params
192
+ ub = list(upper) if upper is not None else [math.inf] * n_params
193
+ if len(lb) != n_params or len(ub) != n_params:
194
+ raise ValueError("lower/upper bounds must match the number of parameters")
195
+
196
+ model_fcn, display_names = _resolve_model(model, param_names, n_params)
197
+ fn, internal = _wrap_model(model_fcn, n_params)
198
+
199
+ init = dict(zip(internal, [float(v) for v in p0], strict=True))
200
+ curve = Curve(fn, xv, yv, dyv, name="", **init)
201
+ for nm, lo, hi in zip(internal, lb, ub, strict=True):
202
+ curve.pars[nm].range(float(lo), float(hi))
203
+ problem = FitProblem(curve)
204
+
205
+ fitclass = next(f for f in FITTERS if f.id == engine)
206
+ options: dict[str, Any] = {}
207
+ monitors: list[Any] = []
208
+ if engine == "dream":
209
+ options = {"samples": int(samples), "burn": int(burn), "pop": int(pop)}
210
+ if progress_callback is not None:
211
+ pop_size = int(math.ceil(pop * n_params))
212
+ total = int(burn) + -(-int(samples) // max(1, pop_size))
213
+ monitors.append(_ProgressMonitor(total, progress_callback))
214
+ driver = FitDriver(
215
+ fitclass=fitclass, problem=problem, monitors=monitors, abort_test=abort_check, **options
216
+ )
217
+ driver.clip() # start inside the bounds
218
+ x_best, _fx = driver.fit()
219
+ if x_best is None: # aborted before the first iteration completed
220
+ x_best = problem.getp()
221
+ problem.setp(x_best)
222
+
223
+ # Map fitted values back to input parameter order via problem labels.
224
+ labels = [str(s) for s in problem.labels()]
225
+ order = [labels.index(nm) for nm in internal]
226
+ xb = np.asarray(x_best, dtype=float)
227
+ popt = [float(xb[i]) for i in order]
228
+ try:
229
+ dx = np.asarray(driver.stderr(), dtype=float)
230
+ uncertainties = [float(dx[i]) for i in order]
231
+ except Exception: # noqa: BLE001 — e.g. aborted dream with a near-empty state
232
+ uncertainties = [float("nan")] * n_params
233
+
234
+ y_fit = np.asarray(model_fcn(xv, np.asarray(popt, dtype=float)), dtype=float)
235
+ result: dict[str, Any] = {
236
+ "engine": engine,
237
+ "popt": popt,
238
+ "uncertainties": uncertainties,
239
+ "chisq": float(problem.chisq()),
240
+ "uncertainty_kind": "posterior" if engine == "dream" else "hessian",
241
+ "paramNames": display_names,
242
+ "yFit": y_fit,
243
+ }
244
+
245
+ if engine == "dream":
246
+ draw = driver.fitter.state.draw()
247
+ pts = np.asarray(draw.points, dtype=float)[:, order]
248
+ lo68, med, hi68 = (
249
+ np.asarray(np.percentile(pts, q, axis=0), dtype=float) for q in (16.0, 50.0, 84.0)
250
+ )
251
+ result["posterior"] = {
252
+ "medians": [float(v) for v in med],
253
+ "interval68": [[float(a), float(b)] for a, b in zip(lo68, hi68, strict=True)],
254
+ "n_draws": int(pts.shape[0]),
255
+ }
256
+ if return_samples:
257
+ result["samples"] = pts
258
+ return result
@@ -0,0 +1,114 @@
1
+ """Constraint expansion for linked/constrained curve-fit parameters.
2
+
3
+ Port of ``fitting.applyConstraints``. Pure: free-parameter values + per-parameter
4
+ constraint expressions → the full parameter vector. Constraint expressions are
5
+ evaluated with the safe equation parser (``calc.fit_equation.parse_equation``, no
6
+ ``eval``). A parameter is *free* when its constraint string is empty, else it is
7
+ *computed* from the free parameters.
8
+
9
+ Within an expression, free parameters may be referenced as ``p1..pK`` (the 1-based
10
+ position among ALL parameters, rewritten to free-local indices here) or by name.
11
+
12
+ Faithful-to-MATLAB note: ``parse_equation`` indexes parameters by order of
13
+ appearance, and ``applyConstraints`` rewrites references with two sequential regex
14
+ passes (named, then positional). Both behaviours are replicated exactly, so the
15
+ result matches MATLAB even in the corner cases its rewrite does not fully
16
+ normalise (mixed/reindexed references).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ import numpy as np
24
+ from numpy.typing import ArrayLike, NDArray
25
+
26
+ from .fit_equation import parse_equation
27
+
28
+ __all__ = ["apply_constraints"]
29
+
30
+
31
+ def _rewrite_constraint_expr(expr: str, all_names: list[str], free_idx: list[int]) -> str:
32
+ """Rewrite param references to free-local ``p1..pK`` indices (port of
33
+ rewriteConstraintExpr): named refs first (longest first), then positional
34
+ ``p<global>``. Sequential, like MATLAB — do not reorder."""
35
+ m = len(all_names)
36
+ g2l = {gi: li + 1 for li, gi in enumerate(free_idx)} # global → local (1-based)
37
+
38
+ # 1) named references, longest names first (stable for ties, as MATLAB sort)
39
+ for gi in sorted(range(m), key=lambda i: len(all_names[i]), reverse=True):
40
+ if gi not in g2l or not all_names[gi]:
41
+ continue
42
+ pat = r"(?<![A-Za-z0-9_])" + re.escape(all_names[gi]) + r"(?![A-Za-z0-9_])"
43
+ expr = re.sub(pat, f"p{g2l[gi]}", expr)
44
+
45
+ # 2) positional p<global 1-based> → p<local> (after names, ascending global)
46
+ for gi in range(m):
47
+ if gi not in g2l:
48
+ continue
49
+ pat = r"(?<![A-Za-z0-9_])p" + str(gi + 1) + r"(?![0-9])"
50
+ expr = re.sub(pat, f"p{g2l[gi]}", expr)
51
+
52
+ return expr
53
+
54
+
55
+ def apply_constraints(
56
+ p_free: ArrayLike,
57
+ constraints: list[str],
58
+ all_param_names: list[str],
59
+ ) -> tuple[NDArray[np.float64], list[int]]:
60
+ """Expand free parameters to the full vector using constraint expressions.
61
+
62
+ Port of ``fitting.applyConstraints``. ``constraints[i]`` is ``""`` for a free
63
+ parameter or an expression for a computed one. Returns ``(p_full, free_idx)``
64
+ where ``free_idx`` are the 0-based positions of the free parameters (MATLAB
65
+ returns 1-based). Raises ``ValueError`` on size mismatch, when a constraint
66
+ references another constrained parameter, or on parse/eval failure.
67
+ """
68
+ pf = np.asarray(p_free, dtype=float).ravel()
69
+ m = len(constraints)
70
+ if len(all_param_names) != m:
71
+ raise ValueError("constraints and all_param_names must have the same length")
72
+
73
+ is_constrained = [bool(c.strip()) for c in constraints]
74
+ free_idx = [i for i in range(m) if not is_constrained[i]]
75
+ k = len(free_idx)
76
+ if k == 0 and pf.size:
77
+ raise ValueError(
78
+ f"all {m} parameters are constrained — p_free must be empty when nothing is free"
79
+ )
80
+ if pf.size != k:
81
+ raise ValueError(f"p_free has {pf.size} elements but {k} free parameters found")
82
+
83
+ p_full = np.full(m, np.nan)
84
+ for li, gi in enumerate(free_idx):
85
+ p_full[gi] = pf[li]
86
+
87
+ constrained_names = [all_param_names[i] for i in range(m) if is_constrained[i]]
88
+ for kk in range(m):
89
+ expr = constraints[kk].strip()
90
+ if not expr:
91
+ continue
92
+ rewritten = _rewrite_constraint_expr(expr, all_param_names, free_idx)
93
+
94
+ # A residual constrained-name reference means a constraint depends on a
95
+ # non-free parameter — unsupported (would be circular).
96
+ for nm in constrained_names:
97
+ if not nm:
98
+ continue
99
+ if re.search(r"(?<![A-Za-z0-9_])" + re.escape(nm) + r"(?![A-Za-z0-9_])", rewritten):
100
+ raise ValueError(
101
+ f'constraint for "{all_param_names[kk]}" references "{nm}" which is '
102
+ f"itself constrained; only free parameters may appear in constraints"
103
+ )
104
+
105
+ try:
106
+ fcn, _ = parse_equation(rewritten)
107
+ val = fcn(0.0, pf)
108
+ except Exception as exc:
109
+ raise ValueError(
110
+ f'failed to evaluate constraint for "{all_param_names[kk]}" ("{expr}"): {exc}'
111
+ ) from exc
112
+ p_full[kk] = float(np.asarray(val, dtype=float).ravel()[0])
113
+
114
+ return p_full, free_idx
@@ -0,0 +1,264 @@
1
+ """Safe equation-string parser for custom fit models. Port of fitting.parseEquation.
2
+
3
+ Pure calc layer. Tokenizes an expression (e.g. ``a*exp(-x/b)+c``), converts to RPN
4
+ via the shunting-yard algorithm, and evaluates by **interpreting the RPN stack** —
5
+ NO eval/exec/str2func (per the no-eval rule; this is safer than the MATLAB source
6
+ which compiled via str2func). Parameters are the free identifiers (not x / known
7
+ functions / constants), returned in order of first appearance.
8
+
9
+ ``equation_model`` / ``default_guesses`` bridge a parsed equation into the
10
+ standard bounded-NLLS fit path (``calc.fitting.curve_fit``) for the custom
11
+ fit-model builder (GOTO #1).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable, Sequence
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+ from numpy.typing import ArrayLike, NDArray
21
+ from scipy.special import erf, erfc
22
+
23
+ __all__ = ["default_guesses", "equation_model", "parse_equation"]
24
+
25
+
26
+ def _coth(a: NDArray[np.float64]) -> NDArray[np.float64]:
27
+ return np.asarray(1.0 / np.tanh(a), dtype=float)
28
+
29
+
30
+ def _matlab_round(a: NDArray[np.float64]) -> NDArray[np.float64]:
31
+ return np.asarray(np.sign(a) * np.floor(np.abs(a) + 0.5), dtype=float)
32
+
33
+
34
+ _FUNCS: dict[str, Callable[[Any], Any]] = {
35
+ "exp": np.exp, "log": np.log, "log10": np.log10, "sqrt": np.sqrt, "abs": np.abs,
36
+ "sin": np.sin, "cos": np.cos, "tan": np.tan, "asin": np.arcsin, "acos": np.arccos,
37
+ "atan": np.arctan, "sinh": np.sinh, "cosh": np.cosh, "tanh": np.tanh, "coth": _coth,
38
+ "erf": erf, "erfc": erfc, "sign": np.sign, "floor": np.floor, "ceil": np.ceil,
39
+ "round": _matlab_round,
40
+ }
41
+ _CONSTS = {"pi": float(np.pi), "e": float(np.e)}
42
+ _PREC = {"+": 1, "-": 1, "*": 2, "/": 2, "^": 3}
43
+
44
+
45
+ def _tokenize(s: str) -> tuple[list[dict[str, Any]], list[str]]:
46
+ tokens: list[dict[str, Any]] = []
47
+ params: list[str] = []
48
+ pos, n = 0, len(s)
49
+ prev = "start"
50
+ while pos < n:
51
+ ch = s[pos]
52
+ if ch in " \t":
53
+ pos += 1
54
+ continue
55
+ if ch.isdigit() or (ch == "." and pos + 1 < n and s[pos + 1].isdigit()):
56
+ start = pos
57
+ while pos < n and (s[pos].isdigit() or s[pos] == "."):
58
+ pos += 1
59
+ if pos < n and s[pos] in "eE":
60
+ pos += 1
61
+ if pos < n and s[pos] in "+-":
62
+ pos += 1
63
+ while pos < n and s[pos].isdigit():
64
+ pos += 1
65
+ tokens.append({"type": "number", "value": float(s[start:pos])})
66
+ prev = "value"
67
+ continue
68
+ if ch.isalpha() or ch == "_":
69
+ start = pos
70
+ while pos < n and (s[pos].isalnum() or s[pos] == "_"):
71
+ pos += 1
72
+ name = s[start:pos]
73
+ if name in _FUNCS:
74
+ tokens.append({"type": "function", "value": name})
75
+ prev = "function"
76
+ continue
77
+ # A non-function identifier directly followed by "(" is a call of
78
+ # an unknown symbol (there is no implicit multiplication in this
79
+ # grammar) -- reject it with a clear message instead of letting it
80
+ # fail the arity check with a generic one.
81
+ look = pos
82
+ while look < n and s[look] in " \t":
83
+ look += 1
84
+ if look < n and s[look] == "(":
85
+ if name in _CONSTS or name == "x":
86
+ raise ValueError(f'"{name}" cannot be called as a function')
87
+ raise ValueError(
88
+ f'Unknown function "{name}". Known functions: '
89
+ + ", ".join(sorted(_FUNCS))
90
+ )
91
+ if name in _CONSTS:
92
+ tokens.append({"type": "number", "value": _CONSTS[name]})
93
+ prev = "value"
94
+ elif name == "x":
95
+ tokens.append({"type": "x", "value": "x"})
96
+ prev = "value"
97
+ else:
98
+ if name not in params:
99
+ params.append(name)
100
+ tokens.append({"type": "param", "value": params.index(name)})
101
+ prev = "value"
102
+ continue
103
+ if ch in "+-*/^()":
104
+ if ch == "(":
105
+ tokens.append({"type": "lparen", "value": "("})
106
+ prev = "lparen"
107
+ elif ch == ")":
108
+ tokens.append({"type": "rparen", "value": ")"})
109
+ prev = "value"
110
+ elif ch == "-" and prev in ("start", "lparen", "operator"):
111
+ tokens.append({"type": "number", "value": 0.0})
112
+ tokens.append({"type": "operator", "value": "-"})
113
+ prev = "operator"
114
+ elif ch == "+" and prev in ("start", "lparen", "operator"):
115
+ prev = "operator"
116
+ else:
117
+ tokens.append({"type": "operator", "value": ch})
118
+ prev = "operator"
119
+ pos += 1
120
+ continue
121
+ raise ValueError(f'Unexpected character "{ch}" at position {pos}')
122
+ return tokens, params
123
+
124
+
125
+ def _should_pop(stack_op: str, new_op: str) -> bool:
126
+ if stack_op not in _PREC:
127
+ return False
128
+ sp, np_ = _PREC[stack_op], _PREC[new_op]
129
+ return sp > np_ if new_op == "^" else sp >= np_
130
+
131
+
132
+ def _to_rpn(tokens: list[dict[str, Any]]) -> list[dict[str, Any]]:
133
+ rpn: list[dict[str, Any]] = []
134
+ op_stack: list[dict[str, Any]] = []
135
+ for tok in tokens:
136
+ ttype = tok["type"]
137
+ if ttype in ("number", "x", "param"):
138
+ rpn.append(tok)
139
+ elif ttype == "function":
140
+ op_stack.append(tok)
141
+ elif ttype == "operator":
142
+ while (op_stack and op_stack[-1]["type"] == "operator"
143
+ and _should_pop(op_stack[-1]["value"], tok["value"])):
144
+ rpn.append(op_stack.pop())
145
+ op_stack.append(tok)
146
+ elif ttype == "lparen":
147
+ op_stack.append(tok)
148
+ elif ttype == "rparen":
149
+ while op_stack and op_stack[-1]["type"] != "lparen":
150
+ rpn.append(op_stack.pop())
151
+ if not op_stack:
152
+ raise ValueError("Mismatched parentheses.")
153
+ op_stack.pop() # discard '('
154
+ if op_stack and op_stack[-1]["type"] == "function":
155
+ rpn.append(op_stack.pop())
156
+ while op_stack:
157
+ if op_stack[-1]["type"] == "lparen":
158
+ raise ValueError("Mismatched parentheses.")
159
+ rpn.append(op_stack.pop())
160
+ return rpn
161
+
162
+
163
+ def _check_arity(rpn: list[dict[str, Any]]) -> None:
164
+ """Reject RPN that would under/overflow the eval stack (e.g. ``a +`` or
165
+ ``a b``). MATLAB's parseEquation compiled via str2func, so malformed input
166
+ errored there at compile time; erroring at parse time matches that intent
167
+ (the interpreter would otherwise crash or silently drop operands)."""
168
+ depth = 0
169
+ for tok in rpn:
170
+ ttype = tok["type"]
171
+ if ttype in ("number", "x", "param"):
172
+ depth += 1
173
+ elif ttype == "operator":
174
+ if depth < 2:
175
+ raise ValueError(
176
+ f'operator "{tok["value"]}" is missing an operand'
177
+ )
178
+ depth -= 1
179
+ elif ttype == "function":
180
+ if depth < 1:
181
+ raise ValueError(f'function "{tok["value"]}" is missing its argument')
182
+ if depth != 1:
183
+ raise ValueError("malformed expression (check operators and parentheses)")
184
+
185
+
186
+ def _eval_rpn(rpn: list[dict[str, Any]], x: NDArray[np.float64], p: NDArray[np.float64]) -> Any:
187
+ stack: list[Any] = []
188
+ for tok in rpn:
189
+ ttype = tok["type"]
190
+ if ttype == "number":
191
+ stack.append(tok["value"])
192
+ elif ttype == "x":
193
+ stack.append(x)
194
+ elif ttype == "param":
195
+ stack.append(p[tok["value"]])
196
+ elif ttype == "operator":
197
+ b = stack.pop()
198
+ a = stack.pop()
199
+ op = tok["value"]
200
+ if op == "+":
201
+ stack.append(a + b)
202
+ elif op == "-":
203
+ stack.append(a - b)
204
+ elif op == "*":
205
+ stack.append(a * b)
206
+ elif op == "/":
207
+ stack.append(a / b)
208
+ else: # ^
209
+ stack.append(a**b)
210
+ elif ttype == "function":
211
+ stack.append(_FUNCS[tok["value"]](stack.pop()))
212
+ return stack[0]
213
+
214
+
215
+ def parse_equation(
216
+ eqn_str: str,
217
+ ) -> tuple[Callable[[ArrayLike, ArrayLike], NDArray[np.float64]], list[str]]:
218
+ """Parse ``eqn_str`` into ``(fcn, param_names)``. Port of fitting.parseEquation.
219
+
220
+ ``fcn(x, p)`` evaluates the expression (``p`` indexed in ``param_names`` order).
221
+ Strips a leading ``y =`` / ``f(x) =``. Safe: RPN is interpreted, never eval'd.
222
+ """
223
+ import re
224
+
225
+ expr = re.sub(r"^\s*(y|f\(x\))\s*=\s*", "", eqn_str.strip())
226
+ if not expr:
227
+ raise ValueError("Equation string is empty.")
228
+ tokens, param_names = _tokenize(expr)
229
+ rpn = _to_rpn(tokens)
230
+ _check_arity(rpn)
231
+
232
+ def fcn(x: ArrayLike, p: ArrayLike) -> NDArray[np.float64]:
233
+ xv = np.asarray(x, dtype=float).ravel()
234
+ pv = np.asarray(p, dtype=float).ravel()
235
+ y = _eval_rpn(rpn, xv, pv)
236
+ out = np.broadcast_to(np.asarray(y, dtype=float), xv.shape)
237
+ return np.asarray(out, dtype=float).copy()
238
+
239
+ return fcn, param_names
240
+
241
+
242
+ def equation_model(
243
+ eqn_str: str,
244
+ ) -> tuple[Callable[[ArrayLike, ArrayLike], NDArray[np.float64]], list[str]]:
245
+ """Parse ``eqn_str`` and vet it as a *fit model*: ``(model_fcn, param_names)``.
246
+
247
+ On top of ``parse_equation`` this rejects parameter names that start with
248
+ an underscore (MATLAB identifiers cannot, and it shuts the door on dunder
249
+ junk like ``__import__`` ever naming a parameter). The returned callable
250
+ plugs straight into ``calc.fitting.curve_fit`` as ``model_fcn``.
251
+ """
252
+ fcn, param_names = parse_equation(eqn_str)
253
+ for name in param_names:
254
+ if name.startswith("_"):
255
+ raise ValueError(
256
+ f'invalid parameter name "{name}": parameters must start with a letter'
257
+ )
258
+ return fcn, param_names
259
+
260
+
261
+ def default_guesses(param_names: Sequence[str]) -> list[float]:
262
+ """Default starting guesses for an equation model: 1.0 per parameter
263
+ (the conventional neutral start when nothing is known about scale)."""
264
+ return [1.0] * len(param_names)