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.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- 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
|