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,379 @@
1
+ """Thin fitting routes: list models, auto-guess, run a bounded NLLS fit.
2
+
3
+ No algorithms here — the math is in ``calc.fitting``/``calc.fit_models``/
4
+ ``calc.fit_autoguess``. The model is chosen by name and resolved through the
5
+ registry (no eval); the curve_fit ``model_fcn`` is a closure over ``evaluate``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ from fastapi import APIRouter, HTTPException
16
+ from numpy.typing import NDArray
17
+ from pydantic import BaseModel
18
+
19
+ from quantized.calc.fit_autoguess import auto_guess
20
+ from quantized.calc.fit_bootstrap import bootstrap_fit, fit_posterior
21
+ from quantized.calc.fit_equation import default_guesses, equation_model
22
+ from quantized.calc.fit_findxy import find_x, find_y
23
+ from quantized.calc.fit_models import FIT_MODELS, evaluate
24
+ from quantized.calc.fit_scan import scan_models
25
+ from quantized.calc.fitting import curve_fit
26
+ from quantized.routes._payload import to_jsonable
27
+
28
+ ModelFn = Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
29
+
30
+ router = APIRouter(prefix="/api/fitting", tags=["fitting"])
31
+
32
+
33
+ class GuessRequest(BaseModel):
34
+ model: str
35
+ x: list[float]
36
+ y: list[float]
37
+
38
+
39
+ class FitRequest(BaseModel):
40
+ model: str
41
+ x: list[float]
42
+ y: list[float]
43
+ p0: list[float] | None = None
44
+ lower: list[float] | None = None
45
+ upper: list[float] | None = None
46
+ weights: list[float] | None = None
47
+ fixed: list[bool] | None = None
48
+ calc_errors: bool = True
49
+
50
+
51
+ def _require_model(name: str) -> None:
52
+ if name not in FIT_MODELS:
53
+ raise HTTPException(status_code=422, detail=f"unknown model: {name}")
54
+
55
+
56
+ @router.get("/models")
57
+ def list_models() -> dict[str, Any]:
58
+ """Registry of fit models with their parameter names and defaults."""
59
+ models = [
60
+ {
61
+ "name": name,
62
+ "category": spec["category"],
63
+ "paramNames": spec["paramNames"],
64
+ "nParams": spec["nParams"],
65
+ "p0": spec["p0"],
66
+ "lb": spec["lb"],
67
+ "ub": spec["ub"],
68
+ }
69
+ for name, spec in FIT_MODELS.items()
70
+ ]
71
+ return {"models": to_jsonable(models)}
72
+
73
+
74
+ @router.post("/autoguess")
75
+ def autoguess(req: GuessRequest) -> dict[str, Any]:
76
+ """Initial-parameter guess for ``model`` given (x, y)."""
77
+ _require_model(req.model)
78
+ try:
79
+ p0 = auto_guess(req.model, req.x, req.y)
80
+ except (ValueError, KeyError, IndexError) as exc:
81
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
82
+ return {"p0": to_jsonable(p0)}
83
+
84
+
85
+ @router.post("/fit")
86
+ def fit(req: FitRequest) -> dict[str, Any]:
87
+ """Bounded nonlinear least-squares fit of a named model to (x, y)."""
88
+ _require_model(req.model)
89
+
90
+ def model_fcn(
91
+ xx: NDArray[np.float64], pp: NDArray[np.float64]
92
+ ) -> NDArray[np.float64]:
93
+ return evaluate(req.model, xx, pp)
94
+
95
+ try:
96
+ p0 = req.p0 if req.p0 is not None else auto_guess(req.model, req.x, req.y)
97
+ result = curve_fit(
98
+ req.x,
99
+ req.y,
100
+ model_fcn,
101
+ p0,
102
+ lower=req.lower,
103
+ upper=req.upper,
104
+ weights=req.weights,
105
+ fixed=req.fixed,
106
+ calc_errors=req.calc_errors,
107
+ )
108
+ except (ValueError, KeyError, IndexError) as exc:
109
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
110
+ return to_jsonable(result) # type: ignore[no-any-return]
111
+
112
+
113
+ class BootstrapRequest(BaseModel):
114
+ model: str
115
+ x: list[float]
116
+ y: list[float]
117
+ p0: list[float]
118
+ n_boot: int = 500
119
+ method: str = "residual"
120
+ seed: int = 0
121
+ alpha: float = 0.05
122
+ lower: list[float] | None = None
123
+ upper: list[float] | None = None
124
+ # Opt-in (gap #29): the full bootstrap replicate matrix, for corner-plot
125
+ # rendering. Default False keeps the ordinary response small.
126
+ return_samples: bool = False
127
+
128
+
129
+ @router.post("/bootstrap")
130
+ def bootstrap(req: BootstrapRequest) -> dict[str, Any]:
131
+ """Bootstrap parameter CIs for a registry-model fit (calc.fit_bootstrap)."""
132
+ _require_model(req.model)
133
+
134
+ def model_fcn(xa: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
135
+ return np.asarray(evaluate(req.model, xa, p), dtype=float)
136
+
137
+ try:
138
+ return to_jsonable( # type: ignore[no-any-return]
139
+ bootstrap_fit(
140
+ np.asarray(req.x, dtype=float),
141
+ np.asarray(req.y, dtype=float),
142
+ model_fcn,
143
+ req.p0,
144
+ n_boot=req.n_boot,
145
+ method=req.method,
146
+ seed=req.seed,
147
+ alpha=req.alpha,
148
+ lower=req.lower,
149
+ upper=req.upper,
150
+ return_samples=req.return_samples,
151
+ )
152
+ )
153
+ except (ValueError, IndexError) as exc:
154
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
155
+
156
+
157
+ # ── Custom equation models (GOTO #1) ────────────────────────────────────────
158
+ # The equation text is the model; calc.fit_equation parses it with the no-eval
159
+ # RPN interpreter (the ONLY equation-evaluation path) and the fit runs through
160
+ # the same curve_fit engine as registry models, so the result shape (params/
161
+ # errors/R2/chiSqRed/RMSE/AIC/yFit) is identical.
162
+
163
+
164
+ class EquationValidateRequest(BaseModel):
165
+ equation: str
166
+
167
+
168
+ class EquationFitRequest(BaseModel):
169
+ equation: str
170
+ x: list[float]
171
+ y: list[float]
172
+ guesses: list[float] | None = None
173
+ # Bounds may hold null entries = unbounded on that side (JSON cannot
174
+ # carry Infinity); mapped to -inf/+inf before curve_fit.
175
+ lower: list[float | None] | None = None
176
+ upper: list[float | None] | None = None
177
+ weights: list[float] | None = None
178
+ fixed: list[bool] | None = None
179
+ calc_errors: bool = True
180
+
181
+
182
+ @router.post("/equation/validate")
183
+ def equation_validate(req: EquationValidateRequest) -> dict[str, Any]:
184
+ """Validate a custom fit equation; 200 with ok/params[]/error (live UI)."""
185
+ try:
186
+ _, names = equation_model(req.equation)
187
+ except (ValueError, IndexError) as exc:
188
+ return {"ok": False, "params": [], "error": str(exc)}
189
+ return {"ok": True, "params": names}
190
+
191
+
192
+ @router.post("/equation/fit")
193
+ def equation_fit(req: EquationFitRequest) -> dict[str, Any]:
194
+ """Fit a custom equation model to (x, y); same result shape as /fit."""
195
+ try:
196
+ fcn, names = equation_model(req.equation)
197
+ if not names:
198
+ raise ValueError("equation has no free parameters to fit")
199
+ p0 = req.guesses if req.guesses is not None else default_guesses(names)
200
+ if len(p0) != len(names):
201
+ raise ValueError(f"expected {len(names)} guesses, got {len(p0)}")
202
+ lower = (
203
+ [-math.inf if v is None else v for v in req.lower]
204
+ if req.lower is not None
205
+ else None
206
+ )
207
+ upper = (
208
+ [math.inf if v is None else v for v in req.upper]
209
+ if req.upper is not None
210
+ else None
211
+ )
212
+ result = curve_fit(
213
+ req.x,
214
+ req.y,
215
+ fcn,
216
+ p0,
217
+ lower=lower,
218
+ upper=upper,
219
+ weights=req.weights,
220
+ fixed=req.fixed,
221
+ calc_errors=req.calc_errors,
222
+ )
223
+ except (ValueError, KeyError, IndexError) as exc:
224
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
225
+ out: dict[str, Any] = to_jsonable(result)
226
+ out["paramNames"] = names
227
+ return out
228
+
229
+
230
+ # ── AICc model quick-scan (GOTO #6) ─────────────────────────────────────────
231
+
232
+
233
+ class ScanEquationCandidate(BaseModel):
234
+ name: str
235
+ equation: str
236
+ guesses: list[float] | None = None
237
+
238
+
239
+ class ScanRequest(BaseModel):
240
+ x: list[float]
241
+ y: list[float]
242
+ # Optional per-point 1-sigma errors -> fit weights 1/dy^2.
243
+ dy: list[float] | None = None
244
+ # None -> the default candidate set: every registry model with
245
+ # nParams < n/3 (calc.fit_scan.default_candidates explains the cut).
246
+ models: list[str] | None = None
247
+ # Saved custom equation models ride along as extra candidates.
248
+ equations: list[ScanEquationCandidate] | None = None
249
+
250
+
251
+ @router.post("/scan")
252
+ def scan(req: ScanRequest) -> dict[str, Any]:
253
+ """Fit all candidate models and rank by AICc (calc.fit_scan.scan_models).
254
+
255
+ Per-candidate failures come back as error entries in ``results`` — only
256
+ invalid scan INPUT (length mismatch, bad dy, too few points) is a 422.
257
+ """
258
+ try:
259
+ result = scan_models(
260
+ req.x,
261
+ req.y,
262
+ dy=req.dy,
263
+ models=req.models,
264
+ equations=(
265
+ [e.model_dump() for e in req.equations] if req.equations is not None else None
266
+ ),
267
+ )
268
+ except ValueError as exc:
269
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
270
+ return to_jsonable(result) # type: ignore[no-any-return]
271
+
272
+
273
+ class PosteriorRequest(BaseModel):
274
+ model: str
275
+ x: list[float]
276
+ y: list[float]
277
+ p0: list[float]
278
+ num_steps: int = 10000
279
+ burn_in: int = 1000
280
+ seed: int = 0
281
+ lower: list[float] | None = None
282
+ upper: list[float] | None = None
283
+
284
+
285
+ @router.post("/posterior")
286
+ def posterior(req: PosteriorRequest) -> dict[str, Any]:
287
+ """MCMC posterior for a registry-model fit (calc.fit_bootstrap.fit_posterior)."""
288
+ _require_model(req.model)
289
+
290
+ def model_fcn(xa: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
291
+ return np.asarray(evaluate(req.model, xa, p), dtype=float)
292
+
293
+ try:
294
+ return to_jsonable( # type: ignore[no-any-return]
295
+ fit_posterior(
296
+ np.asarray(req.x, dtype=float),
297
+ np.asarray(req.y, dtype=float),
298
+ model_fcn,
299
+ req.p0,
300
+ num_steps=req.num_steps,
301
+ burn_in=req.burn_in,
302
+ seed=req.seed,
303
+ lower=req.lower,
304
+ upper=req.upper,
305
+ )
306
+ )
307
+ except (ValueError, IndexError) as exc:
308
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
309
+
310
+
311
+ # ── Find X from Y / Y from X on a fitted curve (MAIN #15) ──────────────────
312
+ # Inverse-evaluates a fit result the UI already holds (model/equation name +
313
+ # fitted params) -- no re-fit involved. Accepts EITHER a registry ``model``
314
+ # name or a saved custom ``equation`` string (mutually exclusive): both
315
+ # resolve to the same ``fcn(x, p) -> y`` shape (calc.fit_models.evaluate /
316
+ # calc.fit_equation.equation_model), so custom-equation fits get find-X/Y
317
+ # for free, not just registry models.
318
+
319
+
320
+ class FindXYRequest(BaseModel):
321
+ model: str | None = None
322
+ equation: str | None = None
323
+ params: list[float]
324
+ x_min: float
325
+ x_max: float
326
+ # Exactly one of x (find Y) / y (find X, all crossings) must be set.
327
+ x: float | None = None
328
+ y: float | None = None
329
+ grid_points: int = 2000
330
+
331
+
332
+ @router.post("/find-xy")
333
+ def find_xy(req: FindXYRequest) -> dict[str, Any]:
334
+ """Find Y at a given X, or every X where the fitted curve equals a given Y.
335
+
336
+ ``x`` set -> ``{"y": <float | null>}`` (a single evaluation).
337
+ ``y`` set -> ``{"x": [<float>, ...]}`` (every crossing within
338
+ ``[x_min, x_max]``; an empty list is a valid "no crossing" answer, not
339
+ an error).
340
+ """
341
+ if (req.model is None) == (req.equation is None):
342
+ raise HTTPException(
343
+ status_code=422, detail="specify exactly one of model or equation"
344
+ )
345
+ if (req.x is None) == (req.y is None):
346
+ raise HTTPException(
347
+ status_code=422, detail="specify exactly one of x (find Y) or y (find X)"
348
+ )
349
+ if req.x_max <= req.x_min:
350
+ raise HTTPException(status_code=422, detail="x_max must be greater than x_min")
351
+
352
+ fcn: ModelFn
353
+ if req.model is not None:
354
+ model_name = req.model
355
+ _require_model(model_name)
356
+
357
+ def fcn(xa: NDArray[np.float64], pp: NDArray[np.float64]) -> NDArray[np.float64]:
358
+ return evaluate(model_name, xa, pp)
359
+ else:
360
+ equation = req.equation
361
+ assert equation is not None # narrowed by the "exactly one" check above
362
+ try:
363
+ fcn, names = equation_model(equation)
364
+ except (ValueError, IndexError) as exc:
365
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
366
+ if len(req.params) != len(names):
367
+ raise HTTPException(
368
+ status_code=422,
369
+ detail=f"expected {len(names)} params for this equation, got {len(req.params)}",
370
+ )
371
+
372
+ try:
373
+ if req.x is not None:
374
+ return {"y": to_jsonable(find_y(fcn, req.params, req.x))}
375
+ assert req.y is not None # narrowed by the "exactly one" check above
376
+ xs = find_x(fcn, req.params, req.y, req.x_min, req.x_max, grid_points=req.grid_points)
377
+ return {"x": to_jsonable(xs)}
378
+ except (ValueError, IndexError, ZeroDivisionError) as exc:
379
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -0,0 +1,97 @@
1
+ """Thin route for the optional bumps fit engine (GOTO #10).
2
+
3
+ ``POST /api/fitting/bumps`` — the fast engines (amoeba / lm / de) run
4
+ synchronously and return the fit dict; ``engine='dream'`` submits to the
5
+ poll-model job runner (GOTO #9) and returns ``{"job_id": ...}``: poll
6
+ ``GET /api/jobs/{id}`` for progress and fetch ``GET /api/jobs/{id}/result``
7
+ for the same fit dict on completion. All math lives in
8
+ ``calc.fit_bumps``; the MATLAB-parity engine (``POST /api/fitting/fit``)
9
+ remains the default fit path.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from fastapi import APIRouter, HTTPException
17
+ from pydantic import BaseModel
18
+
19
+ from quantized.calc.fit_autoguess import auto_guess
20
+ from quantized.calc.fit_bumps import BUMPS_ENGINES, bumps_available, fit_bumps
21
+ from quantized.calc.fit_models import FIT_MODELS
22
+ from quantized.jobs import AbortFn, ProgressFn, jobs
23
+ from quantized.routes._payload import to_jsonable
24
+
25
+ router = APIRouter(prefix="/api/fitting", tags=["fitting"])
26
+
27
+
28
+ class BumpsFitRequest(BaseModel):
29
+ model: str
30
+ x: list[float]
31
+ y: list[float]
32
+ dy: list[float] | None = None
33
+ p0: list[float] | None = None
34
+ lower: list[float] | None = None
35
+ upper: list[float] | None = None
36
+ engine: str = "amoeba"
37
+ # dream-only tuning (ignored by the synchronous engines)
38
+ samples: int = 10_000
39
+ burn: int = 100
40
+ pop: int = 10
41
+ return_samples: bool = False
42
+
43
+
44
+ @router.post("/bumps")
45
+ def bumps_fit(req: BumpsFitRequest) -> dict[str, Any]:
46
+ """Fit a registry model with a bumps engine (sync) or queue a DREAM job."""
47
+ if req.model not in FIT_MODELS:
48
+ raise HTTPException(status_code=422, detail=f"unknown model: {req.model}")
49
+ if req.engine not in BUMPS_ENGINES:
50
+ raise HTTPException(
51
+ status_code=422,
52
+ detail=f"unknown engine: {req.engine} (choose from {', '.join(BUMPS_ENGINES)})",
53
+ )
54
+ if not bumps_available():
55
+ raise HTTPException(
56
+ status_code=422,
57
+ detail=(
58
+ "bumps is not installed - the optional fit engine needs "
59
+ "'pip install quantized[bumps]'"
60
+ ),
61
+ )
62
+ try:
63
+ p0 = req.p0 if req.p0 is not None else auto_guess(req.model, req.x, req.y)
64
+ except (ValueError, KeyError, IndexError) as exc:
65
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
66
+ kwargs: dict[str, Any] = {
67
+ "model": req.model,
68
+ "p0": [float(v) for v in p0],
69
+ "lower": req.lower,
70
+ "upper": req.upper,
71
+ "param_names": list(FIT_MODELS[req.model]["paramNames"]),
72
+ "engine": req.engine,
73
+ "samples": req.samples,
74
+ "burn": req.burn,
75
+ "pop": req.pop,
76
+ "return_samples": req.return_samples,
77
+ }
78
+
79
+ if req.engine == "dream":
80
+ # Long-running: submit through the poll-model job runner (GOTO #9).
81
+ def run_job(progress: ProgressFn, abort_check: AbortFn) -> Any:
82
+ def on_fraction(fraction: float) -> None:
83
+ progress(fraction, "sampling posterior")
84
+
85
+ result = fit_bumps(
86
+ req.x, req.y, req.dy,
87
+ progress_callback=on_fraction, abort_check=abort_check, **kwargs,
88
+ )
89
+ return to_jsonable(result)
90
+
91
+ return {"job_id": jobs.submit(run_job)}
92
+
93
+ try:
94
+ result = fit_bumps(req.x, req.y, req.dy, **kwargs)
95
+ except (ValueError, KeyError, IndexError) as exc:
96
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
97
+ return to_jsonable(result) # type: ignore[no-any-return]
@@ -0,0 +1,97 @@
1
+ """Thin route: Origin graph templates (``.otp``/``.otpu``) -> a
2
+ ``GraphTemplate`` JSON (gap-ecosystem plan item 5, decode-plan #21).
3
+
4
+ Template files carry no worksheet data (see
5
+ ``io/origin_project/templates.py``'s module docstring), so this is a
6
+ SEPARATE import surface from the dataset importers in ``routes/parsers.py``
7
+ -- ``.otp``/``.otpu`` are deliberately never registered in ``io/registry.py``
8
+ (the single-registry rule governs DATA parsers; a template is a style
9
+ preset, not a DataStruct).
10
+
11
+ Two ways in, mirroring ``routes/parsers.py``'s ``/import``+``/upload`` split:
12
+ ``GET`` reads a path the server can already see (desktop / CLI use, the same
13
+ containment guard as ``parsers.py``'s ``/import``); ``POST /upload`` takes
14
+ the file's bytes from the browser (file-picker / drag-drop). The frontend
15
+ wrapper (``api.ts`` client method + an "Import Origin template..." UI
16
+ hook-in) is explicitly OUT of scope for this item -- booked separately.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import tempfile
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from fastapi import APIRouter, HTTPException, UploadFile
27
+
28
+ from quantized.io.origin_project.container import OriginProjectError
29
+ from quantized.io.origin_project.templates import read_origin_template
30
+
31
+ router = APIRouter(prefix="/api/import/template", tags=["import"])
32
+
33
+
34
+ def _allowed_roots() -> tuple[str, ...]:
35
+ """Mirrors ``routes/parsers.py``'s containment allowlist (home / cwd /
36
+ temp, widened by ``QZ_DATA_ROOTS``) -- duplicated rather than imported so
37
+ the path-traversal guard below stays inline in the function the static
38
+ analyzer traces end to end (see ``parsers.py``'s identical comment)."""
39
+ raw = [Path.home(), Path.cwd(), Path(tempfile.gettempdir())]
40
+ raw += [Path(p) for p in os.environ.get("QZ_DATA_ROOTS", "").split(os.pathsep) if p.strip()]
41
+ roots: list[str] = []
42
+ for r in raw:
43
+ try:
44
+ roots.append(os.path.realpath(r))
45
+ except OSError:
46
+ continue
47
+ return tuple(roots)
48
+
49
+
50
+ @router.get("")
51
+ def import_template(path: str) -> dict[str, Any]:
52
+ """Read a server-visible Origin graph template (``.otp``/``.otpu``) by
53
+ path into a ``GraphTemplate``-shaped dict.
54
+
55
+ The path is ``os.path.realpath``-normalized (collapsing ``..`` and
56
+ symlinks) and confined to an allowed root (home / cwd / temp, widen via
57
+ ``QZ_DATA_ROOTS``) before any filesystem access -- the same guard
58
+ ``routes/parsers.py``'s ``/import`` uses.
59
+ """
60
+ try:
61
+ resolved = os.path.realpath(path)
62
+ except (OSError, ValueError) as exc:
63
+ raise HTTPException(status_code=400, detail="invalid path") from exc
64
+ within_allowed = False
65
+ for root in _allowed_roots():
66
+ try:
67
+ if os.path.commonpath((root, resolved)) == root:
68
+ within_allowed = True
69
+ break
70
+ except ValueError:
71
+ continue # different drives (Windows) -> not under this root
72
+ if not within_allowed:
73
+ raise HTTPException(
74
+ status_code=403,
75
+ detail="path is outside the allowed roots (set QZ_DATA_ROOTS to widen)",
76
+ )
77
+ if not os.path.isfile(resolved):
78
+ raise HTTPException(status_code=404, detail=f"file not found: {path}")
79
+ try:
80
+ return read_origin_template(Path(resolved))
81
+ except OriginProjectError as exc:
82
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
83
+
84
+
85
+ @router.post("/upload")
86
+ async def upload_template(file: UploadFile) -> dict[str, Any]:
87
+ """Import an uploaded Origin graph template (browser file-picker /
88
+ drag-drop) into a ``GraphTemplate``-shaped dict."""
89
+ name = Path(file.filename or "template.otp").name or "template.otp"
90
+ content = await file.read()
91
+ try:
92
+ with tempfile.TemporaryDirectory() as tmp:
93
+ dest = Path(tmp) / name
94
+ dest.write_bytes(content)
95
+ return read_origin_template(dest)
96
+ except OriginProjectError as exc:
97
+ raise HTTPException(status_code=422, detail=str(exc)) from exc