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,227 @@
1
+ """Emitters: turn calc result dicts into :class:`ReportSheet`s (#36).
2
+
3
+ Each analysis already returns a plain result dict; these functions map the
4
+ known fields into the report schema (:mod:`quantized.calc.report`) so a fit,
5
+ peak fit, or stats test lands as a structured report that the #37/#38
6
+ exporters and the frontend viewer render uniformly. No new math — pure
7
+ re-shaping.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping, Sequence
13
+ from typing import Any
14
+
15
+ from quantized.calc.report import (
16
+ ReportSheet,
17
+ params_block,
18
+ section,
19
+ table_block,
20
+ text_block,
21
+ )
22
+
23
+ __all__ = [
24
+ "from_anova",
25
+ "from_batch_integrate",
26
+ "from_curve_fit",
27
+ "from_integrate",
28
+ "from_multipeak_fit",
29
+ "from_stats_table",
30
+ ]
31
+
32
+
33
+ def _gof_table(result: Mapping[str, Any], keys: Sequence[tuple[str, str]]) -> dict[str, Any]:
34
+ """A two-column goodness-of-fit table from selected result keys."""
35
+ rows = [[label, result[key]] for label, key in keys if result.get(key) is not None]
36
+ return table_block(["Metric", "Value"], rows, caption="Goodness of fit")
37
+
38
+
39
+ def from_curve_fit(
40
+ result: Mapping[str, Any],
41
+ *,
42
+ param_names: Sequence[str],
43
+ param_units: Sequence[str] | None = None,
44
+ title: str = "Curve fit",
45
+ model_name: str | None = None,
46
+ source_refs: Sequence[Mapping[str, Any]] | None = None,
47
+ ) -> ReportSheet:
48
+ """Build a report from a ``calc.fitting`` result dict.
49
+
50
+ ``result`` carries ``params`` / ``errors`` arrays (whose order matches
51
+ ``param_names``) plus goodness-of-fit scalars (R2, chiSqRed, RMSE, AIC).
52
+ """
53
+ params = list(result.get("params", []))
54
+ errors = list(result.get("errors", []) or [None] * len(params))
55
+ units = list(param_units) if param_units is not None else [""] * len(params)
56
+ if len(param_names) != len(params):
57
+ raise ValueError(
58
+ f"param_names ({len(param_names)}) must match params ({len(params)})"
59
+ )
60
+ rows = [
61
+ {"name": param_names[i], "value": params[i],
62
+ "error": errors[i] if i < len(errors) else None,
63
+ "unit": units[i] if i < len(units) else ""}
64
+ for i in range(len(params))
65
+ ]
66
+ blocks: list[dict[str, Any]] = []
67
+ if model_name:
68
+ blocks.append(text_block(f"Model: {model_name}"))
69
+ blocks.append(params_block(rows, caption="Fitted parameters"))
70
+ blocks.append(
71
+ _gof_table(result, [
72
+ ("R²", "R2"), ("Reduced χ²", "chiSqRed"),
73
+ ("RMSE", "RMSE"), ("AIC", "AIC"),
74
+ ("Free params", "nFree"), ("Points", "nPoints"),
75
+ ])
76
+ )
77
+ return ReportSheet(
78
+ title=title,
79
+ sections=(section("Fit results", blocks),),
80
+ source_refs=tuple(dict(r) for r in (source_refs or ())),
81
+ )
82
+
83
+
84
+ def from_multipeak_fit(
85
+ result: Mapping[str, Any],
86
+ *,
87
+ title: str = "Multi-peak fit",
88
+ source_refs: Sequence[Mapping[str, Any]] | None = None,
89
+ ) -> ReportSheet:
90
+ """Build a report from a ``calc.peak_multifit`` result dict."""
91
+ peaks = list(result.get("peaks", []))
92
+ cols = ["Peak", "Model", "Center", "FWHM", "Height", "Area", "η"]
93
+ rows = []
94
+ for i, pk in enumerate(peaks, start=1):
95
+ rows.append([
96
+ i, pk.get("model", result.get("model", "")),
97
+ pk.get("center"), pk.get("fwhm"), pk.get("height"),
98
+ pk.get("area"), pk.get("eta"),
99
+ ])
100
+ blocks: list[dict[str, Any]] = [
101
+ table_block(cols, rows, caption=f"{len(peaks)} peak(s)"),
102
+ _gof_table(result, [("RMSE", "rmse"), ("Peaks", "nPeaks")]),
103
+ ]
104
+ return ReportSheet(
105
+ title=title,
106
+ sections=(section("Peak fit", blocks),),
107
+ source_refs=tuple(dict(r) for r in (source_refs or ())),
108
+ )
109
+
110
+
111
+ # Human labels + display order for the common ANOVA-style row-dict keys.
112
+ _STATS_COLUMNS: dict[str, str] = {
113
+ "source": "Source", "SS": "SS", "df": "df", "MS": "MS", "F": "F", "p": "p",
114
+ "group": "Group", "diff": "Difference", "statistic": "Statistic",
115
+ "ciLow": "CI low", "ciHigh": "CI high", "significant": "Significant",
116
+ "i": "i", "j": "j",
117
+ }
118
+
119
+
120
+ def from_stats_table(
121
+ records: Sequence[Mapping[str, Any]],
122
+ *,
123
+ title: str,
124
+ section_title: str = "Results",
125
+ columns: Sequence[str] | None = None,
126
+ caption: str | None = None,
127
+ source_refs: Sequence[Mapping[str, Any]] | None = None,
128
+ ) -> ReportSheet:
129
+ """Build a report from a list of uniform row-dicts (ANOVA, post-hoc, ...).
130
+
131
+ ``columns`` selects/orders the dict keys to show; by default it uses the
132
+ keys of the first record, relabeled via a small known-key map.
133
+ """
134
+ if not records:
135
+ raise ValueError("from_stats_table needs at least one record")
136
+ keys = list(columns) if columns is not None else list(records[0].keys())
137
+ headers = [_STATS_COLUMNS.get(k, k) for k in keys]
138
+ rows = [[rec.get(k) for k in keys] for rec in records]
139
+ return ReportSheet(
140
+ title=title,
141
+ sections=(section(section_title, [table_block(headers, rows, caption=caption)]),),
142
+ source_refs=tuple(dict(r) for r in (source_refs or ())),
143
+ )
144
+
145
+
146
+ def from_anova(
147
+ result: Mapping[str, Any],
148
+ *,
149
+ title: str = "ANOVA",
150
+ source_refs: Sequence[Mapping[str, Any]] | None = None,
151
+ ) -> ReportSheet:
152
+ """Build a report from any ANOVA result dict carrying a ``table`` key."""
153
+ table = result.get("table")
154
+ if not table:
155
+ raise ValueError("from_anova needs a result with a non-empty 'table'")
156
+ return from_stats_table(
157
+ table, title=title, section_title="ANOVA table",
158
+ columns=["source", "SS", "df", "MS", "F", "p"],
159
+ source_refs=source_refs,
160
+ )
161
+
162
+
163
+ def from_integrate(
164
+ result: Mapping[str, Any],
165
+ *,
166
+ title: str = "Peak integration",
167
+ source_refs: Sequence[Mapping[str, Any]] | None = None,
168
+ ) -> ReportSheet:
169
+ """Build a report from a ``calc.peak_integrate.integrate_peaks`` result."""
170
+ peaks = list(result.get("peaks", []))
171
+ if not peaks:
172
+ raise ValueError("from_integrate needs a result with peaks")
173
+ cols = ["Region", "Area", "% area", "Centroid", "Height", "Position", "FWHM"]
174
+ rows = []
175
+ for i, pk in enumerate(peaks, start=1):
176
+ region = pk.get("region")
177
+ region_str = f"{region[0]:g}–{region[1]:g}" if isinstance(region, list) else i
178
+ rows.append([
179
+ region_str, pk.get("area"), pk.get("area_pct"), pk.get("centroid"),
180
+ pk.get("height"), pk.get("position"), pk.get("fwhm"),
181
+ ])
182
+ caption = f"{len(peaks)} region(s), {result.get('baseline')} baseline"
183
+ blocks = [
184
+ table_block(cols, rows, caption=caption),
185
+ text_block(f"Total net area: {result.get('total_area')}"),
186
+ ]
187
+ return ReportSheet(
188
+ title=title,
189
+ sections=(section("Integration", blocks),),
190
+ source_refs=tuple(dict(r) for r in (source_refs or ())),
191
+ )
192
+
193
+
194
+ def from_batch_integrate(
195
+ result: Mapping[str, Any],
196
+ *,
197
+ title: str = "Batch peak integration",
198
+ source_refs: Sequence[Mapping[str, Any]] | None = None,
199
+ ) -> ReportSheet:
200
+ """Build a report from a ``calc.peak_batch.batch_integrate_peaks`` result.
201
+
202
+ The area matrix (spectrum x region) becomes a trend table — one row per
203
+ spectrum, one column per region — plus per-spectrum alignment shift.
204
+ """
205
+ results = list(result.get("results", []))
206
+ regions = list(result.get("regions", []))
207
+ if not results or not regions:
208
+ raise ValueError("from_batch_integrate needs results and regions")
209
+ area_m = result.get("area_matrix", [])
210
+ region_cols = [f"{r[0]:g}–{r[1]:g}" for r in regions]
211
+ header = ["Spectrum", *(["Shift"] if result.get("aligned") else []), *region_cols]
212
+ rows = []
213
+ for i, row in enumerate(results):
214
+ cells: list[Any] = [row.get("label", i)]
215
+ if result.get("aligned"):
216
+ cells.append(row.get("shift_samples"))
217
+ cells.extend(area_m[i] if i < len(area_m) else [None] * len(regions))
218
+ rows.append(cells)
219
+ n_failed = result.get("n_failed", 0)
220
+ caption = f"{len(results)} spectra × {len(regions)} region(s)"
221
+ if n_failed:
222
+ caption += f" ({n_failed} failed)"
223
+ return ReportSheet(
224
+ title=title,
225
+ sections=(section("Area trends", [table_block(header, rows, caption=caption)]),),
226
+ source_refs=tuple(dict(r) for r in (source_refs or ())),
227
+ )
@@ -0,0 +1,142 @@
1
+ """Resample a DataStruct onto a new x-grid. Port of utilities.resampleData.
2
+
3
+ Pure calc layer. Supports four grid modes (n_points / step / grid / match_dataset;
4
+ exactly one, or none for a 500-point default) and four interpolation methods
5
+ (linear / pchip / spline=not-a-knot / makima). Out-of-range samples are NaN unless
6
+ ``extrapolate=True``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ import numpy as np
14
+ from numpy.typing import ArrayLike, NDArray
15
+ from scipy.interpolate import (
16
+ Akima1DInterpolator,
17
+ CubicSpline,
18
+ PchipInterpolator,
19
+ interp1d,
20
+ )
21
+
22
+ from ..datastruct import DataStruct
23
+
24
+ __all__ = ["resample_data"]
25
+
26
+ _METHODS = ("linear", "pchip", "spline", "makima")
27
+
28
+
29
+ def _colon(a: float, d: float, b: float) -> NDArray[np.float64]:
30
+ """MATLAB ``a:d:b`` colon grid (endpoint included only if it lands exactly)."""
31
+ if d == 0:
32
+ raise ValueError("resample step must be non-zero")
33
+ n = int(math.floor((b - a) / d + 1e-10))
34
+ return np.asarray(a + np.arange(n + 1) * d, dtype=float)
35
+
36
+
37
+ def _sanitize_xy(
38
+ x: NDArray[np.float64], y: NDArray[np.float64]
39
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
40
+ """Make ``(x, y)`` safe for interpolation: drop non-finite pairs, sort by x,
41
+ and collapse duplicate x (mean y). Mirrors MATLAB ``interp1``'s effective
42
+ tolerance (it ignores NaN values and accepts unsorted input). Every step is
43
+ guarded, so clean, strictly-increasing, finite data passes through untouched
44
+ — golden parity is preserved; only real-world data (NaN gaps, hysteresis /
45
+ unsorted sweeps, step profiles with repeated x) is repaired."""
46
+ finite = np.isfinite(x) & np.isfinite(y)
47
+ if not bool(finite.all()):
48
+ x, y = x[finite], y[finite]
49
+ if x.size > 1 and not bool(np.all(np.diff(x) > 0)):
50
+ order = np.argsort(x, kind="stable")
51
+ x, y = x[order], y[order]
52
+ if bool((x[1:] == x[:-1]).any()):
53
+ ux, inv = np.unique(x, return_inverse=True)
54
+ sums = np.zeros(ux.size)
55
+ cnts = np.zeros(ux.size)
56
+ np.add.at(sums, inv, y)
57
+ np.add.at(cnts, inv, 1.0)
58
+ x, y = ux, sums / cnts
59
+ return x, y
60
+
61
+
62
+ def _interp_column(
63
+ x: NDArray[np.float64],
64
+ y: NDArray[np.float64],
65
+ x_new: NDArray[np.float64],
66
+ method: str,
67
+ extrapolate: bool,
68
+ ) -> NDArray[np.float64]:
69
+ x, y = _sanitize_xy(x, y)
70
+ if x.size < 2:
71
+ # Too few finite points to interpolate — undefined everywhere.
72
+ return np.full(np.shape(x_new), np.nan, dtype=float)
73
+ if method == "linear":
74
+ if extrapolate:
75
+ fn = interp1d(x, y, kind="linear", fill_value="extrapolate", assume_sorted=True)
76
+ return np.asarray(fn(x_new), dtype=float)
77
+ return np.asarray(np.interp(x_new, x, y, left=np.nan, right=np.nan), dtype=float)
78
+ if method == "pchip":
79
+ return np.asarray(PchipInterpolator(x, y, extrapolate=extrapolate)(x_new), dtype=float)
80
+ if method == "spline":
81
+ spline = CubicSpline(x, y, bc_type="not-a-knot", extrapolate=extrapolate)
82
+ return np.asarray(spline(x_new), dtype=float)
83
+ akima = Akima1DInterpolator(x, y, method="makima", extrapolate=extrapolate)
84
+ return np.asarray(akima(x_new), dtype=float)
85
+
86
+
87
+ def resample_data(
88
+ data: DataStruct,
89
+ *,
90
+ n_points: int | None = None,
91
+ step: float | None = None,
92
+ grid: ArrayLike | None = None,
93
+ match_dataset: DataStruct | None = None,
94
+ method: str = "makima",
95
+ extrapolate: bool = False,
96
+ ) -> DataStruct:
97
+ """Resample ``data`` onto a new x-grid, interpolating every channel.
98
+
99
+ Specify exactly one grid mode (``n_points``, ``step``, ``grid`` or
100
+ ``match_dataset``); with none, a 500-point linspace over the data range is
101
+ used. Returns a new DataStruct with ``resampled``/``resampleMethod``/
102
+ ``resamplePoints`` stamped into ``metadata``.
103
+ """
104
+ if method not in _METHODS:
105
+ raise ValueError(f"method must be one of {_METHODS}")
106
+ x_old = np.asarray(data.time, dtype=float).ravel()
107
+ y_old = np.asarray(data.values, dtype=float)
108
+ if y_old.ndim == 1:
109
+ y_old = y_old.reshape(-1, 1)
110
+ if x_old.size < 2:
111
+ raise ValueError("need at least 2 data points")
112
+
113
+ modes = sum(v is not None for v in (n_points, step, grid, match_dataset))
114
+ if modes > 1:
115
+ raise ValueError("specify only one of: n_points, step, grid, match_dataset")
116
+ # MATLAB min/max ignore NaN; mirror that so a NaN in the x axis doesn't
117
+ # collapse the auto-grid to all-NaN. No-op on clean data (golden parity).
118
+ if not np.isfinite(x_old).any():
119
+ raise ValueError("x axis has no finite values")
120
+ lo, hi = float(np.nanmin(x_old)), float(np.nanmax(x_old))
121
+ if modes == 0:
122
+ x_new = np.linspace(lo, hi, 500)
123
+ elif n_points is not None:
124
+ x_new = np.linspace(lo, hi, int(n_points))
125
+ elif step is not None:
126
+ x_new = _colon(lo, float(step), hi)
127
+ elif grid is not None:
128
+ x_new = np.asarray(grid, dtype=float).ravel()
129
+ else: # match_dataset
130
+ x_new = np.asarray(match_dataset.time, dtype=float).ravel() # type: ignore[union-attr]
131
+
132
+ y_new = np.empty((x_new.size, y_old.shape[1]))
133
+ for c in range(y_old.shape[1]):
134
+ y_new[:, c] = _interp_column(x_old, y_old[:, c], x_new, method, extrapolate)
135
+
136
+ meta = dict(data.metadata)
137
+ meta["resampled"] = True
138
+ meta["resampleMethod"] = method
139
+ meta["resamplePoints"] = int(x_new.size)
140
+ return DataStruct.create(
141
+ x_new, y_new, labels=data.labels, units=data.units, metadata=meta
142
+ )
quantized/calc/rsm.py ADDED
@@ -0,0 +1,91 @@
1
+ """RSM strain & relaxation analysis. Port of MATLAB ``fitting.rsmStrain``.
2
+
3
+ Given the reciprocal-space peak centres of a substrate and an epitaxial film
4
+ (from a reciprocal-space map — see ``io/xrdml`` 2D + ``calc/qspace``), compute
5
+ the in-plane / out-of-plane strain and the relaxation.
6
+
7
+ Theory (no Miller indices needed — the absolute scale cancels in the ratios):
8
+ for a fixed (hkl), ``|Q| = 2*pi/a`` so the real-space lattice parameter is
9
+ inversely proportional to Q. The in-plane lattice ``a_par ~ 1/Qx`` and the
10
+ out-of-plane ``a_perp ~ 1/Qz``, hence::
11
+
12
+ eps_parallel = (a_film_par - a_sub_par) / a_sub_par = Qx_sub/Qx_film - 1
13
+ eps_perp = (a_film_perp - a_sub_perp) / a_sub_perp = Qz_sub/Qz_film - 1
14
+
15
+ Relaxation measures how far the film has departed from pseudomorphism
16
+ (``Qx_film == Qx_sub``) toward its bulk (relaxed) position::
17
+
18
+ R = (Qx_film - Qx_sub) / (Qx_bulk - Qx_sub) # 0 = strained, 1 = relaxed
19
+
20
+ Returned absolute lattices use the nominal ``a = 2*pi/|Q|`` (consistent ratios).
21
+
22
+ Pure calc layer — no fastapi/pydantic. Mirrors the MATLAB function's outputs.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ import sys
29
+ from typing import Any
30
+
31
+ __all__ = ["rsm_strain"]
32
+
33
+ # Machine epsilon floor for |Qx| (matches MATLAB's ``max(abs(Qx), eps)`` guard
34
+ # so a symmetric reflection with Qx == 0 gives a finite nominal lattice).
35
+ _EPS = sys.float_info.epsilon
36
+
37
+
38
+ def rsm_strain(
39
+ q_sub: tuple[float, float],
40
+ q_film: tuple[float, float],
41
+ *,
42
+ bulk: tuple[float, float] | None = None,
43
+ ) -> dict[str, Any]:
44
+ """Strain + relaxation from a substrate/film peak pair in an RSM.
45
+
46
+ Args:
47
+ q_sub: substrate peak centre ``(Qx, Qz)`` in reciprocal space (Ang^-1).
48
+ q_film: film peak centre ``(Qx, Qz)`` (Ang^-1).
49
+ bulk: optional bulk (relaxed) film position ``(Qx, Qz)``; enables the
50
+ relaxation calculation. When omitted, ``relaxation`` is NaN.
51
+
52
+ Returns a dict with ``eps_parallel``, ``eps_perp``, ``a_sub_parallel``,
53
+ ``a_sub_perp``, ``a_film_parallel``, ``a_film_perp``, ``relaxation``.
54
+ ``eps_parallel`` is NaN for a symmetric reflection (``Qx == 0``: no in-plane
55
+ information). Raises ``ValueError`` if either ``Qz`` is zero.
56
+ """
57
+ qx_sub, qz_sub = float(q_sub[0]), float(q_sub[1])
58
+ qx_film, qz_film = float(q_film[0]), float(q_film[1])
59
+
60
+ if qz_sub == 0 or qz_film == 0:
61
+ raise ValueError(
62
+ f"Qz must be non-zero for both peaks (got sub={qz_sub:.4g}, film={qz_film:.4g})"
63
+ )
64
+
65
+ # Strain via Q ratios (no Miller indices required).
66
+ eps_par = math.nan if (qx_sub == 0 or qx_film == 0) else qx_sub / qx_film - 1
67
+ eps_perp = qz_sub / qz_film - 1
68
+
69
+ # Nominal absolute lattices (|Q| = 2*pi/a for any (hkl); consistent ratios).
70
+ two_pi = 2.0 * math.pi
71
+ a_sub_par = two_pi / max(abs(qx_sub), _EPS)
72
+ a_sub_perp = two_pi / abs(qz_sub)
73
+ a_film_par = two_pi / max(abs(qx_film), _EPS)
74
+ a_film_perp = two_pi / abs(qz_film)
75
+
76
+ # Relaxation (only when a bulk position is supplied).
77
+ relaxation = math.nan
78
+ if bulk is not None:
79
+ qx_bulk = float(bulk[0])
80
+ denom = qx_bulk - qx_sub
81
+ relaxation = math.nan if denom == 0 else (qx_film - qx_sub) / denom
82
+
83
+ return {
84
+ "eps_parallel": eps_par,
85
+ "eps_perp": eps_perp,
86
+ "a_sub_parallel": a_sub_par,
87
+ "a_sub_perp": a_sub_perp,
88
+ "a_film_parallel": a_film_par,
89
+ "a_film_perp": a_film_perp,
90
+ "relaxation": relaxation,
91
+ }