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,85 @@
1
+ """Peak-shape profiles for XRD/spectroscopy fitting. Ports of MATLAB +utilities.
2
+
3
+ Pure functions: positions in, profile out. Used by the fitting model library.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from collections.abc import Sequence
10
+
11
+ import numpy as np
12
+ from numpy.typing import NDArray
13
+
14
+ __all__ = ["pseudo_voigt", "split_pearson_vii", "tch_pseudo_voigt"]
15
+
16
+ # Parameter vector: a plain sequence or a float ndarray (e.g. straight from an
17
+ # optimizer) — both are unpacked via float(...) so either works at runtime.
18
+ Params = Sequence[float] | NDArray[np.float64]
19
+
20
+ _LN2 = math.log(2)
21
+ _EPS = float(np.finfo(float).eps)
22
+
23
+
24
+ def pseudo_voigt(
25
+ x: NDArray[np.float64],
26
+ x0: float,
27
+ fwhm: float,
28
+ height: float,
29
+ eta: float,
30
+ bg: float = 0.0,
31
+ ) -> NDArray[np.float64]:
32
+ """Linear pseudo-Voigt: H·(eta·L + (1-eta)·G) + bg. Port of utilities.pseudoVoigt."""
33
+ if fwhm <= 0:
34
+ raise ValueError("fwhm must be positive")
35
+ if not 0.0 <= eta <= 1.0:
36
+ raise ValueError("eta must be in [0, 1]")
37
+ xv = np.asarray(x, dtype=float)
38
+ u = (xv - x0) / fwhm
39
+ lorentz = 1.0 / (1.0 + 4.0 * u**2)
40
+ gauss = np.exp(-4.0 * _LN2 * u**2)
41
+ return height * (eta * lorentz + (1.0 - eta) * gauss) + bg
42
+
43
+
44
+ def split_pearson_vii(x: NDArray[np.float64], params: Params) -> NDArray[np.float64]:
45
+ """Asymmetric split Pearson VII. params = [H, center, wL, wR, mL, mR, baseline]."""
46
+ height, center, w_l, w_r, m_l, m_r, baseline = (float(p) for p in params)
47
+ if w_l <= 0 or w_r <= 0:
48
+ raise ValueError("half-widths wL, wR must be positive")
49
+ if m_l < 0.5 or m_r < 0.5:
50
+ raise ValueError("shape exponents mL, mR must be >= 0.5")
51
+ xv = np.asarray(x, dtype=float)
52
+ y = np.zeros_like(xv)
53
+ mask_l = xv < center
54
+ mask_r = ~mask_l
55
+ k_l = 2.0 ** (1.0 / m_l) - 1.0
56
+ k_r = 2.0 ** (1.0 / m_r) - 1.0
57
+ y[mask_l] = height * (1.0 + k_l * ((xv[mask_l] - center) / w_l) ** 2) ** (-m_l)
58
+ y[mask_r] = height * (1.0 + k_r * ((xv[mask_r] - center) / w_r) ** 2) ** (-m_r)
59
+ return y + baseline
60
+
61
+
62
+ def tch_pseudo_voigt(x: NDArray[np.float64], params: Params) -> NDArray[np.float64]:
63
+ """Thompson-Cox-Hastings pseudo-Voigt. params = [H, x0, fG, fL, bg]."""
64
+ height, x0, f_g, f_l, bg = (float(p) for p in params)
65
+ f_g, f_l = abs(f_g), abs(f_l)
66
+ if f_g < _EPS and f_l < _EPS:
67
+ raise ValueError("at least one of fG, fL must be > 0")
68
+ f5 = (
69
+ f_g**5
70
+ + 2.69269 * f_g**4 * f_l
71
+ + 2.42843 * f_g**3 * f_l**2
72
+ + 4.47163 * f_g**2 * f_l**3
73
+ + 0.07842 * f_g * f_l**4
74
+ + f_l**5
75
+ )
76
+ f = f5 ** (1.0 / 5.0)
77
+ r = f_l / f
78
+ eta = 1.36603 * r - 0.47719 * r**2 + 0.11116 * r**3
79
+ eta = max(0.0, min(1.0, eta))
80
+ xv = np.asarray(x, dtype=float)
81
+ u = (xv - x0) / f
82
+ lorentz = 1.0 / (1.0 + 4.0 * u**2)
83
+ gauss = np.exp(-4.0 * _LN2 * u**2)
84
+ result = height * (eta * lorentz + (1.0 - eta) * gauss) + bg
85
+ return np.asarray(result, dtype=float)
@@ -0,0 +1,147 @@
1
+ """Pure plot-series builder: DataStruct + PlotState -> arrays ready for uPlot.
2
+
3
+ Pure layer — returns ndarrays; the wire (NaN -> null, column packing) is the
4
+ routes layer's job. No fastapi/pydantic imports.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from numpy.typing import NDArray
15
+
16
+ from quantized.datastruct import DataStruct
17
+
18
+ __all__ = ["PlotData", "PlotSeries", "PlotState", "build_series", "resolve_style_channels"]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class PlotState:
23
+ """Minimal plot selection/config (M1 subset of the full W6 model).
24
+
25
+ ``y2_keys`` names the channels drawn against a secondary (right) Y axis —
26
+ the dual-Y feature. Channels not listed there default to the primary axis.
27
+ """
28
+
29
+ x_key: int | str | None = None
30
+ y_keys: tuple[int | str, ...] | None = None
31
+ y2_keys: tuple[int | str, ...] | None = None
32
+ x_log: bool = False
33
+ y_log: bool = False
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class PlotSeries:
38
+ label: str
39
+ unit: str
40
+ values: NDArray[np.float64]
41
+ axis: int = 0 # 0 = primary (left) Y axis, 1 = secondary (right)
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class PlotData:
46
+ x: NDArray[np.float64]
47
+ x_label: str
48
+ x_unit: str
49
+ series: tuple[PlotSeries, ...]
50
+ x_log: bool
51
+ y_log: bool
52
+
53
+
54
+ def _resolve(ds: DataStruct, key: int | str) -> int:
55
+ return key if isinstance(key, int) else ds.labels.index(key)
56
+
57
+
58
+ def build_series(ds: DataStruct, state: PlotState | None = None) -> PlotData:
59
+ """Select x + y channels per ``state``; default x = ds.time, y = all channels."""
60
+ state = state or PlotState()
61
+
62
+ if state.x_key is None:
63
+ x = ds.time
64
+ x_label = str(ds.metadata.get("x_column_name", "x"))
65
+ x_unit = str(ds.metadata.get("x_column_unit", ""))
66
+ else:
67
+ xi = _resolve(ds, state.x_key)
68
+ x = ds.values[:, xi]
69
+ x_label = ds.labels[xi]
70
+ x_unit = ds.units[xi]
71
+
72
+ if state.y_keys is None:
73
+ y_indices = list(range(ds.n_channels))
74
+ else:
75
+ y_indices = [_resolve(ds, k) for k in state.y_keys]
76
+
77
+ y2 = {_resolve(ds, k) for k in state.y2_keys} if state.y2_keys is not None else set()
78
+ series = tuple(
79
+ PlotSeries(
80
+ label=ds.labels[i],
81
+ unit=ds.units[i],
82
+ values=ds.values[:, i],
83
+ axis=1 if i in y2 else 0,
84
+ )
85
+ for i in y_indices
86
+ )
87
+ return PlotData(
88
+ x=x,
89
+ x_label=x_label,
90
+ x_unit=x_unit,
91
+ series=series,
92
+ x_log=state.x_log,
93
+ y_log=state.y_log,
94
+ )
95
+
96
+
97
+ def resolve_style_channels(
98
+ ds: DataStruct,
99
+ y_keys: Sequence[int | str] | None,
100
+ series_styles: Sequence[Mapping[str, Any] | None] | None,
101
+ ) -> list[dict[str, Any] | None] | None:
102
+ """Resolve per-series style CHANNEL REFERENCES (MAIN #13's ``fill: {"vs":
103
+ <channel>}`` and MAIN #14's ``color_by: <channel>``) against ``ds`` and
104
+ the actual plotted channel order -- so ``calc.figure`` (and
105
+ ``calc.figure_page``) never touch the raw ``DataStruct``, only resolved
106
+ values (they stay format-only: numbers in, bytes out).
107
+
108
+ ``fill.vs`` (a dataset channel index -- the SAME semantic the frontend's
109
+ ``SeriesStyle.fill`` uses) resolves to the DISPLAY POSITION of that
110
+ channel among the plotted series -- dropped silently (no band) when the
111
+ channel isn't currently plotted, mirroring uPlot's own band mechanism,
112
+ which can only fill between two DRAWN series (see the frontend's
113
+ ``lib/uplotFill.ts``).
114
+
115
+ ``color_by`` (a dataset channel index) resolves to that channel's
116
+ concrete value array -- any channel, not required to be otherwise
117
+ plotted, since it's an auxiliary z-column, not an x/y series pick.
118
+
119
+ ``None`` (no styles requested) passes through unchanged; a malformed
120
+ style dict entry is left as-is (rendering degrades gracefully -- an
121
+ export must never 500 on a bad style hint).
122
+ """
123
+ if series_styles is None:
124
+ return None
125
+ plotted = list(range(ds.n_channels)) if y_keys is None else [_resolve(ds, k) for k in y_keys]
126
+ out: list[dict[str, Any] | None] = []
127
+ for spec in series_styles:
128
+ if not spec:
129
+ out.append(None)
130
+ continue
131
+ resolved: dict[str, Any] = dict(spec) # shallow copy -- never mutate the caller's dict
132
+ fill = resolved.get("fill")
133
+ if isinstance(fill, Mapping) and "vs" in fill:
134
+ try:
135
+ vs_pos = plotted.index(int(fill["vs"]))
136
+ except (ValueError, TypeError):
137
+ resolved.pop("fill", None)
138
+ else:
139
+ resolved["fill"] = {"vs": vs_pos}
140
+ color_by = resolved.get("color_by")
141
+ if isinstance(color_by, int) and not isinstance(color_by, bool):
142
+ if 0 <= color_by < ds.n_channels:
143
+ resolved["color_by"] = ds.values[:, color_by].tolist()
144
+ else:
145
+ resolved.pop("color_by", None)
146
+ out.append(resolved)
147
+ return out
@@ -0,0 +1,232 @@
1
+ """Pure data-processing utilities. Ports of MATLAB +utilities functions.
2
+
3
+ Column-wise operations on 1-D vectors or 2-D (samples x channels) arrays.
4
+ Pure layer — no fastapi/pydantic imports.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+ from scipy import integrate
12
+
13
+ __all__ = [
14
+ "cumulative_integral",
15
+ "derivative",
16
+ "log_derivative",
17
+ "normalize",
18
+ "smooth_data",
19
+ ]
20
+
21
+
22
+ def _as_columns(y: NDArray[np.float64]) -> tuple[NDArray[np.float64], bool]:
23
+ arr = np.asarray(y, dtype=float)
24
+ if arr.ndim == 1:
25
+ return arr.reshape(-1, 1), True
26
+ return arr, False
27
+
28
+
29
+ def normalize(
30
+ y: NDArray[np.float64],
31
+ *,
32
+ method: str = "range",
33
+ out_range: tuple[float, float] = (0.0, 1.0),
34
+ ) -> NDArray[np.float64]:
35
+ """Per-column normalization. method = 'range' | 'peak' | 'zscore'.
36
+
37
+ Port of utilities.normalize (zscore uses sample std, ddof=1).
38
+ """
39
+ if method not in ("range", "peak", "zscore"):
40
+ raise ValueError(f"method must be range/peak/zscore, got {method!r}")
41
+ mat, was_1d = _as_columns(y)
42
+ out = np.full(mat.shape, np.nan)
43
+ lo_out, hi_out = out_range
44
+ for c in range(mat.shape[1]):
45
+ col = mat[:, c]
46
+ if col.size == 0: # empty column: nothing to normalize (np.nanmin would raise)
47
+ out[:, c] = col
48
+ continue
49
+ if method == "range":
50
+ lo = np.nanmin(col)
51
+ hi = np.nanmax(col)
52
+ span = hi - lo
53
+ out[:, c] = lo_out if span == 0 else lo_out + (col - lo) / span * (hi_out - lo_out)
54
+ elif method == "peak":
55
+ pk = np.nanmax(np.abs(col))
56
+ out[:, c] = col if pk == 0 else col / pk
57
+ else: # zscore
58
+ mu = np.nanmean(col)
59
+ sg = np.nanstd(col, ddof=1)
60
+ out[:, c] = (col - mu) if sg == 0 else (col - mu) / sg
61
+ return out.ravel() if was_1d else out
62
+
63
+
64
+ def _matlab_gradient(f: NDArray[np.float64], x: NDArray[np.float64]) -> NDArray[np.float64]:
65
+ """Replicate MATLAB ``gradient(F, X)`` exactly (simple central differences).
66
+
67
+ Interior: (f[i+1]-f[i-1])/(x[i+1]-x[i-1]); ends: one-sided. (numpy.gradient
68
+ uses a different non-uniform formula, so it is NOT used here.)
69
+ """
70
+ n = f.size
71
+ g = np.empty(n)
72
+ if n == 0:
73
+ return g
74
+ if n == 1:
75
+ g[0] = 0.0
76
+ return g
77
+ g[0] = (f[1] - f[0]) / (x[1] - x[0])
78
+ g[-1] = (f[-1] - f[-2]) / (x[-1] - x[-2])
79
+ if n > 2:
80
+ g[1:-1] = (f[2:] - f[:-2]) / (x[2:] - x[:-2])
81
+ return g
82
+
83
+
84
+ def derivative(
85
+ x: NDArray[np.float64],
86
+ y: NDArray[np.float64],
87
+ *,
88
+ order: int = 1,
89
+ pre_smooth: int = 0,
90
+ ) -> NDArray[np.float64]:
91
+ """Numerical derivative dy/dx (order 1 or 2). Port of utilities.derivative.
92
+
93
+ With ``pre_smooth > 0`` the signal is gaussian-smoothed (window = pre_smooth)
94
+ before differentiating, matching the MATLAB ``PreSmooth`` option.
95
+ """
96
+ if order not in (1, 2):
97
+ raise ValueError("order must be 1 or 2")
98
+ xv = np.asarray(x, dtype=float).ravel()
99
+ mat, was_1d = _as_columns(y)
100
+ if xv.size != mat.shape[0]:
101
+ raise ValueError(f"x length ({xv.size}) must match y rows ({mat.shape[0]})")
102
+ if pre_smooth > 0:
103
+ smoothed = smooth_data(mat, method="gaussian", window=pre_smooth)
104
+ mat = smoothed if smoothed.ndim == 2 else smoothed.reshape(-1, 1)
105
+ out = np.zeros(mat.shape)
106
+ for c in range(mat.shape[1]):
107
+ d = _matlab_gradient(mat[:, c], xv)
108
+ if order == 2:
109
+ d = _matlab_gradient(d, xv)
110
+ out[:, c] = d
111
+ return out.ravel() if was_1d else out
112
+
113
+
114
+ def cumulative_integral(
115
+ x: NDArray[np.float64], y: NDArray[np.float64]
116
+ ) -> NDArray[np.float64]:
117
+ """Cumulative trapezoidal integral (leading 0). Port of utilities.cumulativeIntegral.
118
+
119
+ NaNs are treated as 0 during integration and restored as NaN in the output.
120
+ """
121
+ xv = np.asarray(x, dtype=float).ravel()
122
+ mat, was_1d = _as_columns(y)
123
+ if xv.size != mat.shape[0]:
124
+ raise ValueError(f"x length ({xv.size}) must match y rows ({mat.shape[0]})")
125
+ out = np.zeros(mat.shape)
126
+ for c in range(mat.shape[1]):
127
+ col = mat[:, c].copy()
128
+ nan_mask = np.isnan(col)
129
+ col[nan_mask] = 0.0
130
+ out[:, c] = integrate.cumulative_trapezoid(col, xv, initial=0.0)
131
+ out[nan_mask, c] = np.nan
132
+ return out.ravel() if was_1d else out
133
+
134
+
135
+ def log_derivative(
136
+ x: NDArray[np.float64], y: NDArray[np.float64]
137
+ ) -> NDArray[np.float64]:
138
+ """Logarithmic derivative (x/y)·dy/dx. Port of utilities.logDerivative.
139
+
140
+ NaN where x<=0 or y<=0 (log undefined). PreSmooth not yet supported.
141
+ """
142
+ xv = np.asarray(x, dtype=float).ravel()
143
+ mat, was_1d = _as_columns(y)
144
+ if xv.size != mat.shape[0]:
145
+ raise ValueError(f"x length ({xv.size}) must match y rows ({mat.shape[0]})")
146
+ out = np.full(mat.shape, np.nan)
147
+ for c in range(mat.shape[1]):
148
+ col = mat[:, c]
149
+ dydx = _matlab_gradient(col, xv)
150
+ valid = (xv > 0) & (col > 0)
151
+ out[valid, c] = (xv[valid] / col[valid]) * dydx[valid]
152
+ return out.ravel() if was_1d else out
153
+
154
+
155
+ def smooth_data(
156
+ y: NDArray[np.float64],
157
+ *,
158
+ method: str = "moving",
159
+ window: int = 5,
160
+ poly_order: int = 2,
161
+ ) -> NDArray[np.float64]:
162
+ """Column-wise smoothing. Port of utilities.smoothData.
163
+
164
+ ``window`` is the half-width (full window = ``2*window + 1``). Methods:
165
+
166
+ - ``'moving'``: boxcar average, reflect-padded at the edges.
167
+ - ``'gaussian'``: Gaussian kernel (sigma = hw/2), reflect-padded.
168
+ - ``'savitzky-golay'``: SG convolution interior + per-point polynomial fits
169
+ over the boundary window at each edge (matches MATLAB's edge handling).
170
+
171
+ The half-width is clamped to ``n-1`` per column; columns shorter than 2 are
172
+ returned unchanged.
173
+ """
174
+ if method not in ("moving", "gaussian", "savitzky-golay"):
175
+ raise ValueError("method must be moving/gaussian/savitzky-golay")
176
+ hw = window
177
+ if method == "savitzky-golay" and poly_order >= 2 * hw + 1:
178
+ raise ValueError(f"poly_order ({poly_order}) must be < window width ({2 * hw + 1})")
179
+
180
+ mat, was_1d = _as_columns(y)
181
+ out = np.full(mat.shape, np.nan)
182
+ for c in range(mat.shape[1]):
183
+ col = mat[:, c]
184
+ n = col.size
185
+ hwc = min(hw, n - 1)
186
+ if hwc < 1:
187
+ out[:, c] = col
188
+ continue
189
+
190
+ if method == "savitzky-golay":
191
+ out[:, c] = _savgol_column(col, hwc, min(poly_order, 2 * hwc))
192
+ else:
193
+ w_len = 2 * hwc + 1
194
+ if method == "moving":
195
+ kernel = np.ones(w_len) / w_len
196
+ else: # gaussian
197
+ sigma = hwc / 2.0
198
+ t = np.arange(-hwc, hwc + 1, dtype=float)
199
+ kernel = np.exp(-(t**2) / (2.0 * sigma**2))
200
+ kernel = kernel / kernel.sum()
201
+ left = col[1 : hwc + 1][::-1]
202
+ right = col[n - 1 - hwc : n - 1][::-1]
203
+ padded = np.concatenate([left, col, right])
204
+ out[:, c] = np.convolve(padded, kernel, mode="valid")[:n]
205
+ return out.ravel() if was_1d else out
206
+
207
+
208
+ def _savgol_column(col: NDArray[np.float64], hwc: int, poly_ord: int) -> NDArray[np.float64]:
209
+ """One column of Savitzky-Golay smoothing (interior kernel + polynomial edges)."""
210
+ n = col.size
211
+ w_len = 2 * hwc + 1
212
+ t = np.arange(-hwc, hwc + 1, dtype=float)
213
+ vand = np.vander(t, poly_ord + 1, increasing=True)
214
+ # SG smoothing kernel = first row of the normal-equations pseudoinverse.
215
+ coeff_mat = np.linalg.solve(vand.T @ vand, vand.T)
216
+ int_kernel = coeff_mat[0, :]
217
+
218
+ out = col.copy()
219
+ if n > 2 * hwc:
220
+ out = np.convolve(col, int_kernel[::-1], mode="same")
221
+
222
+ # Edges: one polynomial fit over the boundary window, evaluated per point.
223
+ n_pts = min(w_len, n)
224
+ t_local = np.arange(n_pts, dtype=float)
225
+ vand_local = np.vander(t_local, poly_ord + 1, increasing=True)
226
+ powers = np.arange(poly_ord + 1)
227
+ left_coeffs = np.linalg.lstsq(vand_local, col[:n_pts], rcond=None)[0]
228
+ right_coeffs = np.linalg.lstsq(vand_local, col[n - n_pts : n], rcond=None)[0]
229
+ for i in range(1, hwc + 1):
230
+ out[i - 1] = float(np.sum(left_coeffs * (i - 1.0) ** powers))
231
+ out[n - i] = float(np.sum(right_coeffs * float(n_pts - 1 - (i - 1)) ** powers))
232
+ return out
@@ -0,0 +1,48 @@
1
+ """Reciprocal-space coordinates for XRD reciprocal-space maps (RSM).
2
+
3
+ Port of the coplanar Q-space formula documented in MATLAB
4
+ ``parser.importXRDML`` (angular area-detector data -> reciprocal space):
5
+
6
+ theta = 2theta / 2
7
+ Qx = (4*pi/lambda) * sin(theta) * sin(omega - theta) [Ang^-1]
8
+ Qz = (4*pi/lambda) * sin(theta) * cos(omega - theta) [Ang^-1]
9
+
10
+ where ``2theta`` is the detector angle, ``omega`` the incident (sample-tilt)
11
+ angle, and ``lambda`` the X-ray wavelength (Angstrom). Standard coplanar
12
+ geometry: Qx is the in-plane, Qz the out-of-plane reciprocal-lattice
13
+ coordinate. At the symmetric condition ``omega == theta`` the in-plane term
14
+ ``sin(omega - theta)`` vanishes, so ``Qx == 0`` (the scan runs straight up the
15
+ specular Qz axis) — a useful sanity check.
16
+
17
+ Pure calc layer (ndarray in -> ndarray out); no fastapi/pydantic.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import numpy as np
23
+ from numpy.typing import ArrayLike, NDArray
24
+
25
+ __all__ = ["compute_qspace"]
26
+
27
+
28
+ def compute_qspace(
29
+ two_theta_deg: ArrayLike,
30
+ omega_deg: ArrayLike,
31
+ wavelength_a: float,
32
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
33
+ """Angular ``(2theta, omega)`` in degrees -> reciprocal-space ``(Qx, Qz)``.
34
+
35
+ Inputs broadcast together (numpy rules), so the common RSM-grid call passes
36
+ ``two_theta`` as a row ``(1, M)`` and ``omega`` as a column ``(N, 1)`` to get
37
+ ``(N, M)`` grids; equal-shaped arrays are handled element-wise. ``Qx``/``Qz``
38
+ are returned in ``Ang^-1``.
39
+ """
40
+ if not (np.isfinite(wavelength_a) and wavelength_a > 0):
41
+ raise ValueError(f"wavelength_a must be positive and finite, got {wavelength_a!r}")
42
+ theta = np.deg2rad(np.asarray(two_theta_deg, dtype=float)) / 2.0
43
+ omega = np.deg2rad(np.asarray(omega_deg, dtype=float))
44
+ k = 4.0 * np.pi / wavelength_a
45
+ sin_theta = np.sin(theta)
46
+ qx = np.asarray(k * sin_theta * np.sin(omega - theta), dtype=float)
47
+ qz = np.asarray(k * sin_theta * np.cos(omega - theta), dtype=float)
48
+ return qx, qz
@@ -0,0 +1,155 @@
1
+ """Data reductions ported from quantized_matlab (PORT_PLAN #19).
2
+
3
+ Four reductions, each replicating the MATLAB *algorithm* (not just the
4
+ answer — per the replicate-vs-delegate rule these are idiosyncratic local
5
+ implementations, so window functions, zero-padding, peak search bounds and
6
+ the superlattice heuristics are ported step-for-step):
7
+
8
+ - ``williamson_hall`` — ``+calc/+crystal/williamsonHall.m``
9
+ - ``fft_thickness`` — ``+bosonPlotter/peakTools.m`` ``fftThickness/doFFT``
10
+ (the math inside the dialog; the uifigure chrome is not part of the port)
11
+ - ``reflectivity_fft`` — ``peakTools.m`` ``reflectivityFFT/doReflFFT``
12
+ - ``spin_asymmetry`` — ``+bosonPlotter/computeAsymmetryForExport.m``
13
+ (the (R++ − R−−)/(R++ + R−−) formula + exact error propagation; the
14
+ polarization-pair discovery is GUI bookkeeping and stays in the caller)
15
+
16
+ pchip interpolation delegates to scipy (documented equivalent of MATLAB
17
+ ``interp1(..., 'pchip')``); everything else is explicit.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ from numpy.typing import NDArray
27
+
28
+ from quantized.calc.reductions_fft import fft_thickness, reflectivity_fft
29
+
30
+ __all__ = [
31
+ "fft_thickness",
32
+ "reflectivity_fft",
33
+ "spin_asymmetry",
34
+ "williamson_hall",
35
+ ]
36
+
37
+ _FloatArray = NDArray[np.float64]
38
+
39
+
40
+ def williamson_hall(
41
+ two_theta_deg: Any,
42
+ fwhm_deg: Any,
43
+ *,
44
+ wavelength_a: float = 1.5406,
45
+ k_factor: float = 0.9,
46
+ instrumental_broadening_deg: float = 0.0,
47
+ ) -> dict[str, Any]:
48
+ """Separate crystallite size and microstrain from XRD peak widths.
49
+
50
+ Williamson-Hall (uniform strain model): ``beta*cos(theta) = K*lambda/D
51
+ + 4*eps*sin(theta)``; a linear fit of beta*cos(theta) vs 4*sin(theta)
52
+ gives slope = microstrain and intercept = K*lambda/D. Instrumental
53
+ broadening is subtracted in quadrature (clamped at 1e-16 like MATLAB
54
+ when a peak is narrower than the instrument).
55
+ """
56
+ tt = np.asarray(two_theta_deg, dtype=float).ravel()
57
+ fw = np.asarray(fwhm_deg, dtype=float).ravel()
58
+ n = tt.size
59
+ if n != fw.size:
60
+ raise ValueError(
61
+ f"two_theta and fwhm must have the same length (got {n} vs {fw.size})"
62
+ )
63
+ if n < 2:
64
+ raise ValueError(f"at least 2 peaks are required for the Williamson-Hall fit (got {n})")
65
+ if not np.all((tt > 0) & (tt < 180)):
66
+ raise ValueError("all 2-theta values must be in the range (0, 180) degrees")
67
+ if not np.all(fw > 0):
68
+ raise ValueError("all FWHM values must be positive")
69
+ if wavelength_a <= 0:
70
+ raise ValueError("wavelength_a must be positive")
71
+ if k_factor <= 0:
72
+ raise ValueError("k_factor must be positive")
73
+ if instrumental_broadening_deg < 0:
74
+ raise ValueError("instrumental_broadening_deg must be non-negative")
75
+
76
+ theta = np.asarray((tt / 2.0) * (math.pi / 180.0), dtype=float)
77
+ beta_meas = np.asarray(fw * (math.pi / 180.0), dtype=float)
78
+
79
+ beta_inst = instrumental_broadening_deg * (math.pi / 180.0)
80
+ if beta_inst > 0:
81
+ beta_sq = np.asarray(beta_meas**2 - beta_inst**2, dtype=float)
82
+ # MATLAB warns and clamps when the instrument is broader than a peak.
83
+ beta_sq = np.asarray(np.maximum(beta_sq, 1e-16), dtype=float)
84
+ beta = np.asarray(np.sqrt(beta_sq), dtype=float)
85
+ else:
86
+ beta = beta_meas
87
+
88
+ x = np.asarray(4.0 * np.sin(theta), dtype=float)
89
+ y = np.asarray(beta * np.cos(theta), dtype=float)
90
+
91
+ design = np.column_stack([x, np.ones(n)])
92
+ coeffs, _, _, _ = np.linalg.lstsq(design, y, rcond=None)
93
+ slope = float(coeffs[0])
94
+ intercept = float(coeffs[1])
95
+
96
+ if intercept <= 0:
97
+ grain_size_nm = float("nan") # undefined; peaks likely span phases
98
+ else:
99
+ grain_size_nm = (k_factor * wavelength_a) / intercept / 10.0
100
+
101
+ y_fit = design @ coeffs
102
+ ss_res = float(np.sum((y - y_fit) ** 2))
103
+ ss_tot = float(np.sum((y - np.mean(y)) ** 2))
104
+ r2 = 1.0 if ss_tot < np.finfo(float).eps else 1.0 - ss_res / ss_tot
105
+
106
+ return {
107
+ "grain_size_nm": grain_size_nm,
108
+ "microstrain": slope,
109
+ "r2": r2,
110
+ "plot_x": x.tolist(),
111
+ "plot_y": y.tolist(),
112
+ "fit_line": [slope, intercept],
113
+ }
114
+
115
+
116
+ def spin_asymmetry(
117
+ r_pp: Any,
118
+ r_mm: Any,
119
+ dr_pp: Any = None,
120
+ dr_mm: Any = None,
121
+ ) -> dict[str, Any]:
122
+ """Neutron spin asymmetry ``(R++ - R--) / (R++ + R--)``.
123
+
124
+ Points where either channel is non-positive or NaN yield NaN (matching
125
+ MATLAB's validity mask). The propagated error uses the exact partials
126
+ ``dA/dR++ = 2 R-- / (R++ + R--)^2`` and ``dA/dR-- = -2 R++ / (...)^2``;
127
+ absent uncertainties are treated as zero (so ``d_asymmetry`` is 0 on
128
+ valid points, NaN on invalid ones — same as the MATLAB export path).
129
+ """
130
+ rpp = np.asarray(r_pp, dtype=float).ravel()
131
+ rmm = np.asarray(r_mm, dtype=float).ravel()
132
+ if rpp.size != rmm.size:
133
+ raise ValueError(
134
+ "spin channels must share one Q grid (interpolate first); "
135
+ f"got {rpp.size} vs {rmm.size} points"
136
+ )
137
+ dpp = np.zeros_like(rpp) if dr_pp is None else np.asarray(dr_pp, dtype=float).ravel()
138
+ dmm = np.zeros_like(rmm) if dr_mm is None else np.asarray(dr_mm, dtype=float).ravel()
139
+ if dpp.size != rpp.size or dmm.size != rmm.size:
140
+ raise ValueError("uncertainty arrays must match the reflectivity length")
141
+
142
+ valid = (rpp > 0) & (rmm > 0) & ~np.isnan(rpp) & ~np.isnan(rmm)
143
+ asym = np.full(rpp.shape, np.nan)
144
+ err = np.full(rpp.shape, np.nan)
145
+ total = rpp + rmm
146
+ asym[valid] = (rpp[valid] - rmm[valid]) / total[valid]
147
+ da_dpp = 2.0 * rmm[valid] / total[valid] ** 2
148
+ da_dmm = -2.0 * rpp[valid] / total[valid] ** 2
149
+ err[valid] = np.sqrt((da_dpp * dpp[valid]) ** 2 + (da_dmm * dmm[valid]) ** 2)
150
+
151
+ return {
152
+ "asymmetry": asym.tolist(),
153
+ "d_asymmetry": err.tolist(),
154
+ "n_valid": int(valid.sum()),
155
+ }