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,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
|
quantized/routes/plot.py
ADDED
|
@@ -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
|