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,281 @@
1
+ """Thin parser routes: import a file -> DataStruct JSON. No business logic.
2
+
3
+ Two ways in: ``/import`` reads a path the server can already see (desktop / CLI
4
+ use); ``/upload`` takes the file's bytes from the browser (the GUI file-picker
5
+ and drag-drop). Both auto-detect format via ``io.import_auto``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import struct
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from fastapi import APIRouter, HTTPException, UploadFile
17
+ from pydantic import BaseModel
18
+
19
+ from quantized.datastruct import DataStruct
20
+ from quantized.io import import_auto
21
+ from quantized.io.origin_project import (
22
+ drop_empty_library_books,
23
+ drop_nonactionable_figures,
24
+ read_origin_project_all,
25
+ )
26
+ from quantized.io.origin_project.figures import extract_figures
27
+ from quantized.io.origin_project.figures_opju import extract_figures_opju
28
+ from quantized.io.origin_project.preview import decimate_datastruct
29
+ from quantized.routes._bookcache import cache_project_books
30
+ from quantized.routes._payload import datastruct_payload, jsonify
31
+ from quantized.routes._uploadcache import stage_upload
32
+
33
+ router = APIRouter(prefix="/api/parsers", tags=["parsers"])
34
+
35
+ # Rows kept in a non-primary book's preview (Library sparkline resolution;
36
+ # see io/origin_project/preview.decimate_datastruct).
37
+ _PREVIEW_POINTS = 200
38
+
39
+
40
+ class ImportRequest(BaseModel):
41
+ path: str
42
+ # Escape hatch (ORIGIN_FILE_DECODE_PLAN #38): request the pre-#38 full-
43
+ # inline "books": [payload, ...] shape instead of the lazy inventory. Used
44
+ # by tooling that reads every book's data immediately after import (e.g.
45
+ # tools/visual/origin_figures.mjs) and has no fetch-on-activate flow.
46
+ full_books: bool = False
47
+
48
+
49
+ def _allowed_roots() -> tuple[str, ...]:
50
+ """Real (symlink-resolved) absolute paths ``/import`` may read from: the
51
+ user's home, the current working directory, and the system temp dir — widen
52
+ with the ``QZ_DATA_ROOTS`` env var (os.pathsep-separated)."""
53
+ raw = [Path.home(), Path.cwd(), Path(tempfile.gettempdir())]
54
+ raw += [Path(p) for p in os.environ.get("QZ_DATA_ROOTS", "").split(os.pathsep) if p.strip()]
55
+ roots: list[str] = []
56
+ for r in raw:
57
+ try:
58
+ roots.append(os.path.realpath(r))
59
+ except OSError:
60
+ continue
61
+ return tuple(roots)
62
+
63
+
64
+ def _origin_book_id(ds: DataStruct) -> str:
65
+ return str(ds.metadata.get("origin_book", ""))
66
+
67
+
68
+ def _slim_metadata(meta: Any) -> dict[str, Any]:
69
+ """Wire-payload metadata, minus ``origin_books`` — the full per-project
70
+ book inventory ``_build_book`` (io/origin_project/opj.py) embeds in
71
+ EVERY book's metadata. Grepped: nothing in routes/calc or the frontend
72
+ reads it back off the wire (``read_origin_books`` callers that DO use it
73
+ go through the pure Python API, untouched by this trim) — it duplicates
74
+ ~10-15 KB per book project-wide, which is material at PNR.opj's 122-book
75
+ scale (~1.4 MB of dead weight). Trimmed only here, at the HTTP boundary."""
76
+ return {k: v for k, v in dict(meta).items() if k != "origin_books"}
77
+
78
+
79
+ def _book_payload(ds: DataStruct) -> dict[str, Any]:
80
+ """``datastruct_payload``, with ``_slim_metadata`` applied."""
81
+ payload = datastruct_payload(ds)
82
+ payload["metadata"] = _slim_metadata(payload["metadata"])
83
+ return payload
84
+
85
+
86
+ def _book_primary_marker(ds: DataStruct) -> dict[str, Any]:
87
+ """The ``books[]`` entry for the book ALREADY returned in full at the
88
+ payload's top level — carries no ``time``/``values``/preview of its own
89
+ (nothing to duplicate): the frontend builds this book's Dataset from the
90
+ top-level payload instead of this entry. ``primary: true`` is its
91
+ discriminant (see ``_book_preview_payload``'s ``lazy: true`` sibling)."""
92
+ return {
93
+ "lazy": False,
94
+ "primary": True,
95
+ "id": _origin_book_id(ds),
96
+ "labels": list(ds.labels),
97
+ "units": list(ds.units),
98
+ "metadata": _slim_metadata(ds.metadata),
99
+ "rows": ds.n_points,
100
+ "cols": ds.n_channels,
101
+ }
102
+
103
+
104
+ def _book_preview_payload(ds: DataStruct) -> dict[str, Any]:
105
+ """A non-primary book's lightweight inventory entry: real labels/units/
106
+ metadata (so the Library folder tree, tags, and book name all resolve
107
+ immediately) plus a downsampled preview time/values series (so a Library
108
+ sparkline renders without the full column data) — never the full
109
+ ``.time``/``.values``. ``lazy: true`` is the frontend's discriminant
110
+ between this shape and a full/primary entry."""
111
+ preview = decimate_datastruct(ds, target_points=_PREVIEW_POINTS)
112
+ return {
113
+ "lazy": True,
114
+ "id": _origin_book_id(ds),
115
+ "labels": list(ds.labels),
116
+ "units": list(ds.units),
117
+ "metadata": _slim_metadata(ds.metadata),
118
+ "rows": ds.n_points,
119
+ "cols": ds.n_channels,
120
+ # Just time/values (not a full datastruct_payload): labels/units/
121
+ # metadata above already describe this book, so nesting a second
122
+ # copy of them under `preview` would double the very weight this
123
+ # entry exists to avoid.
124
+ "preview": {"time": jsonify(preview.time), "values": jsonify(preview.values)},
125
+ }
126
+
127
+
128
+ def _book_source_ref(path: Path, upload_token: str | None) -> dict[str, str]:
129
+ """A stable reference the frontend echoes back to ``/api/parsers/books/data``
130
+ to fetch one lazy book's full data later: an upload token when this import
131
+ came from ``/upload`` (its bytes are staged, not at a caller-visible path),
132
+ else the resolved path ``/import`` already validated."""
133
+ if upload_token is not None:
134
+ return {"kind": "upload", "token": upload_token}
135
+ return {"kind": "path", "path": str(path)}
136
+
137
+
138
+ def _import_with_books(
139
+ path: Path, *, full_books: bool = False, upload_token: str | None = None
140
+ ) -> dict[str, Any]:
141
+ """Single-DataStruct payload; Origin projects also carry every workbook.
142
+
143
+ A multi-book project adds ``"books": [...]`` so the Library still lists
144
+ every workbook immediately (the locked import-all UX) — but, per
145
+ ORIGIN_FILE_DECODE_PLAN #38, the PRIMARY book (the one this function also
146
+ returns in full at the top level) gets a no-data MARKER entry
147
+ (``_book_primary_marker`` — nothing to duplicate), and every OTHER entry
148
+ is a lightweight inventory + downsampled preview (``_book_preview_payload``,
149
+ never the full ``.time``/``.values``); ``"book_source"`` is the reference
150
+ a later ``/api/parsers/books/data`` call uses to fetch one book's full
151
+ data on its first activation in the UI. Importing PNR.opj (122 books,
152
+ 8.5M cells) this way shrinks the response from ~85 MB to ~2 MB (profiled
153
+ 2026-07-09 perf-quick-wins follow-up). Pass ``full_books=True`` (the
154
+ pre-#38 behaviour, byte-for-byte: every book inline, no markers/preview)
155
+ to get every book's data inline instead — used by tooling with no
156
+ fetch-on-activate flow (see ``ImportRequest.full_books``'s docstring);
157
+ other formats are untouched either way.
158
+
159
+ Origin projects (``.opj``/``.opju``) are parsed ONCE via
160
+ ``read_origin_project_all``: the primary dataset and the full book list
161
+ used to come from two independent full-project parses (``import_auto`` ->
162
+ ``read_origin_project``, then a separate ``read_origin_books``), each
163
+ re-reading the file from disk and re-decoding every column; that
164
+ redundant parse dominated the ~4s round-trip on a 121.56 MB / 8.5M-cell
165
+ project (profiled 2026-07-09). The same already-read bytes are reused
166
+ below for the figures scan too, instead of a third disk read. When lazy,
167
+ the parsed book list is also handed to ``_bookcache`` so the common
168
+ "activate a book right after import" path never re-parses.
169
+ """
170
+ suffix = path.suffix.lower()
171
+ if suffix in (".opj", ".opju"):
172
+ raw = path.read_bytes()
173
+ ds, all_books = read_origin_project_all(path, raw=raw)
174
+ books = drop_empty_library_books(all_books)
175
+ if full_books:
176
+ # Byte-for-byte the pre-#38 shape: every book inline, top level
177
+ # unchanged (no metadata slimming — tooling built against the old
178
+ # response, e.g. tools/visual/origin_figures.mjs, gets exactly
179
+ # what it always got).
180
+ payload = datastruct_payload(ds)
181
+ if len(books) > 1:
182
+ payload["books"] = [datastruct_payload(b) for b in books]
183
+ else:
184
+ payload = _book_payload(ds)
185
+ if len(books) > 1:
186
+ primary_id = _origin_book_id(ds)
187
+ payload["books"] = [
188
+ _book_primary_marker(b)
189
+ if _origin_book_id(b) == primary_id
190
+ else _book_preview_payload(b)
191
+ for b in books
192
+ ]
193
+ payload["book_source"] = _book_source_ref(path, upload_token)
194
+ cache_project_books(path, books)
195
+ try:
196
+ if suffix == ".opj":
197
+ figs = extract_figures(raw)
198
+ else:
199
+ # Gate out non-actionable layer anchors (internal storage/thumbnail
200
+ # blocks with no bound curves and no source) so the Library's Figures
201
+ # section shows only restorable graphs, not dead "SYSTEM" rows.
202
+ figs = drop_nonactionable_figures(extract_figures_opju(raw))
203
+ except (IndexError, ValueError, KeyError, struct.error):
204
+ # Figures are an optional nicety; a decode hiccup on a malformed or
205
+ # truncated project must degrade to "no figures", never fail the
206
+ # whole import (the data books already succeeded above).
207
+ figs = []
208
+ if figs:
209
+ payload["figures"] = figs
210
+ return payload
211
+
212
+ ds = import_auto(path)
213
+ return datastruct_payload(ds)
214
+
215
+
216
+ @router.post("/import")
217
+ def import_file(req: ImportRequest) -> dict[str, Any]:
218
+ """Auto-detect format and import a local file path into a DataStruct.
219
+
220
+ ``/import`` reads a path the server can already see (local desktop / CLI
221
+ use). The path is ``os.path.realpath``-normalized (collapsing ``..`` and
222
+ symlinks) and confined to an allowed root (home / cwd / temp, widen via
223
+ ``QZ_DATA_ROOTS``) via ``os.path.commonpath`` before any filesystem access,
224
+ so the localhost API cannot be used to read system files (e.g.
225
+ ``/etc/passwd``) through path traversal.
226
+ """
227
+ try:
228
+ resolved = os.path.realpath(req.path)
229
+ except (OSError, ValueError) as exc:
230
+ raise HTTPException(status_code=400, detail="invalid path") from exc
231
+ # Inline containment guard (kept in this function so the static analyzer can
232
+ # see the path-traversal barrier sits between the taint and the sink).
233
+ within_allowed = False
234
+ for root in _allowed_roots():
235
+ try:
236
+ if os.path.commonpath((root, resolved)) == root:
237
+ within_allowed = True
238
+ break
239
+ except ValueError:
240
+ continue # different drives (Windows) -> not under this root
241
+ if not within_allowed:
242
+ raise HTTPException(
243
+ status_code=403,
244
+ detail="path is outside the allowed roots (set QZ_DATA_ROOTS to widen)",
245
+ )
246
+ if not os.path.isfile(resolved):
247
+ raise HTTPException(status_code=404, detail=f"file not found: {req.path}")
248
+ try:
249
+ return _import_with_books(Path(resolved), full_books=req.full_books)
250
+ except (ValueError, KeyError, OSError) as exc:
251
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
252
+
253
+
254
+ @router.post("/upload")
255
+ async def upload_file(file: UploadFile, full_books: bool = False) -> dict[str, Any]:
256
+ """Import an uploaded data file (browser file-picker / drag-drop).
257
+
258
+ The bytes are staged under the original *basename* (so the extension
259
+ still drives format dispatch, and ``..`` path parts can't escape). An
260
+ Origin project (``.opj``/``.opju``) is staged PERSISTENTLY (bounded LRU,
261
+ see ``_uploadcache``) instead of in an ephemeral temp dir, because a lazy
262
+ multi-book import (#38, the default — pass ``?full_books=true`` for the
263
+ old inline-everything behaviour) needs the bytes to still be around when
264
+ the browser later activates a non-primary book and fetches its full data
265
+ (``/api/parsers/books/data``). Every other upload keeps the ephemeral
266
+ temp dir: it's deleted before this handler returns, since nothing needs
267
+ it afterwards.
268
+ """
269
+ name = Path(file.filename or "upload.dat").name or "upload.dat"
270
+ content = await file.read()
271
+ suffix = Path(name).suffix.lower()
272
+ try:
273
+ if suffix in (".opj", ".opju"):
274
+ dest, token = stage_upload(name, content)
275
+ return _import_with_books(dest, full_books=full_books, upload_token=token)
276
+ with tempfile.TemporaryDirectory() as tmp:
277
+ dest = Path(tmp) / name
278
+ dest.write_bytes(content)
279
+ return _import_with_books(dest, full_books=full_books)
280
+ except (ValueError, KeyError, OSError) as exc:
281
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -0,0 +1,184 @@
1
+ """Thin peak routes. ``/find`` wraps ``calc.peaks.find_peaks_robust`` (golden vs
2
+ MATLAB findPeaksRobust); ``/fit`` wraps ``calc.peak_fit.fit_single_peak`` (golden
3
+ vs fitSinglePeak); ``/fit-multi`` wraps ``calc.peak_multifit.fit_multi_peak``
4
+ (golden vs peakAnalysis.onFitSimultaneous). Validate -> call -> serialize; no
5
+ business logic here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ from fastapi import APIRouter, HTTPException
14
+ from pydantic import BaseModel
15
+
16
+ from quantized.calc.peak_batch import batch_integrate_peaks
17
+ from quantized.calc.peak_fit import MODELS, fit_single_peak
18
+ from quantized.calc.peak_integrate import integrate_peaks
19
+ from quantized.calc.peak_multifit import fit_multi_peak
20
+ from quantized.calc.peaks import find_peaks_robust
21
+ from quantized.routes._payload import jsonify, to_jsonable
22
+
23
+ # Models accepted by the simultaneous fit (compositeEval branches).
24
+ MULTI_MODELS = ("Lorentzian", "Gaussian", "Pseudo-Voigt", "Split Pearson VII", "TCH-pV")
25
+ LINK_MODES = ("None", "Shared FWHM", "Shared FWHM + eta")
26
+
27
+ router = APIRouter(prefix="/api/peaks", tags=["peaks"])
28
+
29
+
30
+ class FindPeaksRequest(BaseModel):
31
+ x: list[float]
32
+ y: list[float]
33
+ snr_threshold: float = 5.0
34
+ min_separation: float = 0.0
35
+ max_peaks: int = 50
36
+ max_window_deg: float = 2.0
37
+ min_width_deg: float = 0.01
38
+ max_width_deg: float = 10.0
39
+ min_prominence: float = 0.02
40
+ sensitivity: str = "medium"
41
+
42
+
43
+ @router.post("/find")
44
+ def find(req: FindPeaksRequest) -> dict[str, Any]:
45
+ """Find peaks in (x, y); returns the peak list + the estimated background."""
46
+ try:
47
+ peaks, background = find_peaks_robust(
48
+ req.x,
49
+ req.y,
50
+ snr_threshold=req.snr_threshold,
51
+ min_separation=req.min_separation,
52
+ max_peaks=req.max_peaks,
53
+ max_window_deg=req.max_window_deg,
54
+ min_width_deg=req.min_width_deg,
55
+ max_width_deg=req.max_width_deg,
56
+ min_prominence=req.min_prominence,
57
+ sensitivity=req.sensitivity,
58
+ )
59
+ except (ValueError, IndexError, KeyError) as exc:
60
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
61
+ return {"peaks": to_jsonable(peaks), "background": jsonify(background)}
62
+
63
+
64
+ class FitPeakRequest(BaseModel):
65
+ x: list[float]
66
+ y: list[float]
67
+ x_lo: float
68
+ x_hi: float
69
+ seed_center: float
70
+ seed_fwhm: float | None = None
71
+ model: str = "Lorentzian"
72
+ snip_bg: list[float] | None = None
73
+
74
+
75
+ @router.post("/fit")
76
+ def fit(req: FitPeakRequest) -> dict[str, Any]:
77
+ """Fit one peak in [x_lo, x_hi] to ``model``; returns the fit result dict."""
78
+ if req.model not in MODELS:
79
+ raise HTTPException(status_code=422, detail=f"unknown model: {req.model}")
80
+ try:
81
+ result = fit_single_peak(
82
+ req.x, req.y, req.x_lo, req.x_hi,
83
+ seed_center=req.seed_center,
84
+ seed_fwhm=float("nan") if req.seed_fwhm is None else req.seed_fwhm,
85
+ model=req.model,
86
+ snip_bg=req.snip_bg,
87
+ )
88
+ except (ValueError, IndexError, KeyError) as exc:
89
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
90
+ out: dict[str, Any] = to_jsonable(result)
91
+ return out
92
+
93
+
94
+ class PeakSeed(BaseModel):
95
+ center: float
96
+ fwhm: float
97
+ height: float
98
+ eta: float | None = None
99
+
100
+
101
+ class FitMultiPeakRequest(BaseModel):
102
+ x: list[float]
103
+ y: list[float]
104
+ peaks: list[PeakSeed]
105
+ model: str = "Lorentzian"
106
+ bg_degree: int = 1
107
+ constrain: bool = False
108
+ link_mode: str = "None"
109
+
110
+
111
+ @router.post("/fit-multi")
112
+ def fit_multi(req: FitMultiPeakRequest) -> dict[str, Any]:
113
+ """Fit all ``peaks`` + a polynomial background simultaneously; returns the
114
+ global-fit result (fitted peaks, bg coeffs, R2/rmse)."""
115
+ if req.model not in MULTI_MODELS:
116
+ raise HTTPException(status_code=422, detail=f"unknown model: {req.model}")
117
+ if req.link_mode not in LINK_MODES:
118
+ raise HTTPException(status_code=422, detail=f"unknown link_mode: {req.link_mode}")
119
+ if not req.peaks:
120
+ raise HTTPException(status_code=422, detail="need at least one peak seed")
121
+ seeds = [p.model_dump(exclude_none=True) for p in req.peaks]
122
+ try:
123
+ result = fit_multi_peak(
124
+ req.x, req.y, seeds,
125
+ model=req.model, bg_degree=req.bg_degree,
126
+ constrain=req.constrain, link_mode=req.link_mode,
127
+ )
128
+ except (ValueError, IndexError, KeyError) as exc:
129
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
130
+ out: dict[str, Any] = to_jsonable(result)
131
+ return out
132
+
133
+
134
+ class IntegrateRequest(BaseModel):
135
+ x: list[float]
136
+ y: list[float]
137
+ regions: list[tuple[float, float]]
138
+ baseline: str = "linear"
139
+
140
+
141
+ @router.post("/integrate")
142
+ def integrate(req: IntegrateRequest) -> dict[str, Any]:
143
+ """Integrate-only peak analysis: area/centroid/FWHM/%-area per region."""
144
+ try:
145
+ return to_jsonable( # type: ignore[no-any-return]
146
+ integrate_peaks(
147
+ np.asarray(req.x, dtype=float),
148
+ np.asarray(req.y, dtype=float),
149
+ [(float(a), float(b)) for a, b in req.regions],
150
+ baseline=req.baseline,
151
+ )
152
+ )
153
+ except (ValueError, IndexError) as exc:
154
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
155
+
156
+
157
+ class BatchIntegrateRequest(BaseModel):
158
+ x: list[float]
159
+ spectra: list[list[float]] # each same length as x
160
+ regions: list[tuple[float, float]]
161
+ baseline: str = "linear"
162
+ align: bool = False
163
+ reference: int = 0
164
+ labels: list[str] | None = None
165
+
166
+
167
+ @router.post("/integrate-batch")
168
+ def integrate_batch(req: BatchIntegrateRequest) -> dict[str, Any]:
169
+ """Integrate fixed regions across a spectra series (optional alignment).
170
+
171
+ Returns per-spectrum results + area/centroid/FWHM matrices for trend
172
+ plotting; a failing spectrum is flagged, not fatal."""
173
+ try:
174
+ return to_jsonable( # type: ignore[no-any-return]
175
+ batch_integrate_peaks(
176
+ np.asarray(req.x, dtype=float),
177
+ [np.asarray(s, dtype=float) for s in req.spectra],
178
+ [(float(a), float(b)) for a, b in req.regions],
179
+ baseline=req.baseline, align=req.align,
180
+ reference=req.reference, labels=req.labels,
181
+ )
182
+ )
183
+ except (ValueError, IndexError) as exc:
184
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -0,0 +1,103 @@
1
+ """Thin plot route: DataStruct + selection -> uPlot column-oriented payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel, Field
9
+
10
+ from quantized.calc.map import MapState, map_from_datastruct
11
+ from quantized.calc.plotting import PlotState, build_series
12
+ from quantized.datastruct import DataStruct
13
+ from quantized.routes._payload import jsonify, to_jsonable
14
+
15
+ router = APIRouter(prefix="/api/plot", tags=["plot"])
16
+
17
+
18
+ class PlotRequest(BaseModel):
19
+ dataset: dict[str, Any]
20
+ x_key: int | str | None = None
21
+ y_keys: list[int | str] | None = None
22
+ y2_keys: list[int | str] | None = None
23
+ x_log: bool = False
24
+ y_log: bool = False
25
+
26
+
27
+ @router.post("/series")
28
+ def plot_series(req: PlotRequest) -> dict[str, Any]:
29
+ """Build uPlot-ready series from a posted DataStruct."""
30
+ try:
31
+ ds = DataStruct.from_dict(req.dataset)
32
+ state = PlotState(
33
+ x_key=req.x_key,
34
+ y_keys=tuple(req.y_keys) if req.y_keys is not None else None,
35
+ y2_keys=tuple(req.y2_keys) if req.y2_keys is not None else None,
36
+ x_log=req.x_log,
37
+ y_log=req.y_log,
38
+ )
39
+ plot = build_series(ds, state)
40
+ except (ValueError, KeyError, IndexError) as exc:
41
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
42
+
43
+ # uPlot wants column-oriented data: [xValues, series1Values, series2Values, ...]
44
+ data = [jsonify(plot.x)] + [jsonify(s.values) for s in plot.series]
45
+ return {
46
+ "data": data,
47
+ "series": [{"label": s.label, "unit": s.unit, "axis": s.axis} for s in plot.series],
48
+ "x": {"label": plot.x_label, "unit": plot.x_unit, "log": plot.x_log},
49
+ "y": {"log": plot.y_log},
50
+ }
51
+
52
+
53
+ class MapRequest(BaseModel):
54
+ """Three channels of a (scattered) dataset -> a regular 2-D grid for heatmap."""
55
+
56
+ dataset: dict[str, Any]
57
+ x_key: int | str
58
+ y_key: int | str
59
+ z_key: int | str
60
+ method: str = "natural"
61
+ nx: int = Field(default=200, ge=2, le=2000)
62
+ ny: int = Field(default=200, ge=2, le=2000)
63
+ xlim: tuple[float, float] | None = None
64
+ ylim: tuple[float, float] | None = None
65
+ extrapolation: str = "none"
66
+ smoothing: float = 0.0
67
+ idw_power: float = 2.0
68
+
69
+
70
+ @router.post("/map")
71
+ def plot_map(req: MapRequest) -> dict[str, Any]:
72
+ """Regrid scattered (x, y, z) channels into a Canvas2D-ready heatmap grid."""
73
+ try:
74
+ ds = DataStruct.from_dict(req.dataset)
75
+ state = MapState(
76
+ method=req.method,
77
+ nx=req.nx,
78
+ ny=req.ny,
79
+ xlim=req.xlim,
80
+ ylim=req.ylim,
81
+ extrapolation=req.extrapolation,
82
+ smoothing=req.smoothing,
83
+ idw_power=req.idw_power,
84
+ )
85
+ m = map_from_datastruct(ds, req.x_key, req.y_key, req.z_key, state)
86
+ except (ValueError, KeyError, IndexError) as exc:
87
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
88
+
89
+ # x_axis/y_axis are regular (finite by construction); z_grid has NaN gaps
90
+ # outside the convex hull -> jsonify maps those to null (a heatmap gap).
91
+ return {
92
+ "x_axis": jsonify(m.x_axis),
93
+ "y_axis": jsonify(m.y_axis),
94
+ "z_grid": jsonify(m.z_grid),
95
+ "x": {"label": m.x_label, "unit": m.x_unit},
96
+ "y": {"label": m.y_label, "unit": m.y_unit},
97
+ "z": {
98
+ "label": m.z_label,
99
+ "unit": m.z_unit,
100
+ "min": to_jsonable(m.z_min),
101
+ "max": to_jsonable(m.z_max),
102
+ },
103
+ }
@@ -0,0 +1,121 @@
1
+ """Thin reductions route. Wraps ``calc.reductions`` (PORT_PLAN #19).
2
+
3
+ Williamson-Hall size/strain separation, FFT film thickness (Laue fringes),
4
+ reflectivity FFT (Kiessig fringes + superlattice analysis), and neutron
5
+ spin asymmetry. All math lives in calc; this only validates + serializes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+ from pydantic import BaseModel
14
+
15
+ from quantized.calc.reductions import (
16
+ fft_thickness,
17
+ reflectivity_fft,
18
+ spin_asymmetry,
19
+ williamson_hall,
20
+ )
21
+
22
+ router = APIRouter(prefix="/api/reductions", tags=["reductions"])
23
+
24
+
25
+ class WilliamsonHallRequest(BaseModel):
26
+ two_theta_deg: list[float]
27
+ fwhm_deg: list[float]
28
+ wavelength_a: float = 1.5406
29
+ k_factor: float = 0.9
30
+ instrumental_broadening_deg: float = 0.0
31
+
32
+
33
+ @router.post("/williamson-hall")
34
+ def williamson_hall_route(req: WilliamsonHallRequest) -> dict[str, Any]:
35
+ """Crystallite size + microstrain from XRD peak positions and widths."""
36
+ try:
37
+ return williamson_hall(
38
+ req.two_theta_deg,
39
+ req.fwhm_deg,
40
+ wavelength_a=req.wavelength_a,
41
+ k_factor=req.k_factor,
42
+ instrumental_broadening_deg=req.instrumental_broadening_deg,
43
+ )
44
+ except ValueError as exc:
45
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
46
+
47
+
48
+ class FFTThicknessRequest(BaseModel):
49
+ two_theta_deg: list[float]
50
+ intensity: list[float]
51
+ wavelength_a: float
52
+ two_theta_min: float | None = None
53
+ two_theta_max: float | None = None
54
+ window: str = "hann"
55
+ max_thickness_nm: float = 200.0
56
+
57
+
58
+ @router.post("/fft-thickness")
59
+ def fft_thickness_route(req: FFTThicknessRequest) -> dict[str, Any]:
60
+ """Film thickness from Laue-fringe periodicity (XRD FFT)."""
61
+ try:
62
+ return fft_thickness(
63
+ req.two_theta_deg,
64
+ req.intensity,
65
+ req.wavelength_a,
66
+ two_theta_min=req.two_theta_min,
67
+ two_theta_max=req.two_theta_max,
68
+ window=req.window,
69
+ max_thickness_nm=req.max_thickness_nm,
70
+ )
71
+ except ValueError as exc:
72
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
73
+
74
+
75
+ class ReflectivityFFTRequest(BaseModel):
76
+ x: list[float]
77
+ reflectivity: list[float]
78
+ is_neutron: bool = False
79
+ wavelength_a: float | None = None
80
+ x_min: float | None = None
81
+ x_max: float | None = None
82
+ window: str = "hann"
83
+ preprocess: str = "logR"
84
+ max_thickness_nm: float = 500.0
85
+ peak_prominence_threshold: float = 0.05
86
+
87
+
88
+ @router.post("/reflectivity-fft")
89
+ def reflectivity_fft_route(req: ReflectivityFFTRequest) -> dict[str, Any]:
90
+ """Kiessig-fringe FFT thickness(es) + superlattice analysis."""
91
+ try:
92
+ return reflectivity_fft(
93
+ req.x,
94
+ req.reflectivity,
95
+ is_neutron=req.is_neutron,
96
+ wavelength_a=req.wavelength_a,
97
+ x_min=req.x_min,
98
+ x_max=req.x_max,
99
+ window=req.window,
100
+ preprocess=req.preprocess,
101
+ max_thickness_nm=req.max_thickness_nm,
102
+ peak_prominence_threshold=req.peak_prominence_threshold,
103
+ )
104
+ except ValueError as exc:
105
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
106
+
107
+
108
+ class SpinAsymmetryRequest(BaseModel):
109
+ r_pp: list[float]
110
+ r_mm: list[float]
111
+ dr_pp: list[float] | None = None
112
+ dr_mm: list[float] | None = None
113
+
114
+
115
+ @router.post("/spin-asymmetry")
116
+ def spin_asymmetry_route(req: SpinAsymmetryRequest) -> dict[str, Any]:
117
+ """Neutron spin asymmetry (R++ - R--)/(R++ + R--) with propagated error."""
118
+ try:
119
+ return spin_asymmetry(req.r_pp, req.r_mm, req.dr_pp, req.dr_mm)
120
+ except ValueError as exc:
121
+ raise HTTPException(status_code=422, detail=str(exc)) from exc