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,464 @@
1
+ """Magnetometry helpers. Ports of MATLAB +utilities magnetometry functions.
2
+
3
+ Pure calc layer. ``subtract_mag_background`` removes a linear (dia/paramagnetic)
4
+ background fit over a high-temperature window; ``convert_mag_units`` converts
5
+ field and (sample-aware) moment units.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from numpy.typing import ArrayLike, NDArray
15
+
16
+ from .processing import derivative
17
+
18
+ __all__ = [
19
+ "convert_mag_units",
20
+ "hysteresis_analysis",
21
+ "subtract_hysteresis_background",
22
+ "subtract_mag_background",
23
+ ]
24
+
25
+ _EPS = float(np.finfo(float).eps)
26
+
27
+ # Field unit <-> Oersted conversion factors (CGS<->SI).
28
+ _FIELD_TO_OE = {"Oe": 1.0, "T": 1e4, "mT": 10.0, "A/m": 4 * math.pi / 1e3}
29
+ _FIELD_FROM_OE = {"Oe": 1.0, "T": 1e-4, "mT": 0.1, "A/m": 1e3 / (4 * math.pi)}
30
+
31
+
32
+ def subtract_mag_background(
33
+ temperature: ArrayLike,
34
+ moment: ArrayLike,
35
+ *,
36
+ fit_range: tuple[float, float] | None = None,
37
+ auto_fraction: float = 0.1,
38
+ ) -> tuple[NDArray[np.float64], float, float]:
39
+ """Subtract a linear background fit over a high-T window. Port of subtractMagBackground.
40
+
41
+ Fits ``M = slope*T + intercept`` over ``fit_range`` (or, by default, the top
42
+ ``auto_fraction`` of the temperature span) and subtracts it from all points.
43
+ Returns ``(corrected, slope, intercept)``. Falls back to the full range if the
44
+ fit window has fewer than 2 points.
45
+ """
46
+ t = np.asarray(temperature, dtype=float).ravel()
47
+ m = np.asarray(moment, dtype=float).ravel()
48
+ n = t.size
49
+ if n < 3:
50
+ raise ValueError("need at least 3 data points")
51
+ if m.size != n:
52
+ raise ValueError("temperature and moment must be the same length")
53
+
54
+ t_min, t_max = float(t.min()), float(t.max())
55
+ if fit_range is None:
56
+ mask = t >= (t_max - auto_fraction * (t_max - t_min))
57
+ else:
58
+ mask = (t >= fit_range[0]) & (t <= fit_range[1])
59
+ if int(mask.sum()) < 2:
60
+ mask = np.ones(n, dtype=bool)
61
+
62
+ slope, intercept = np.polyfit(t[mask], m[mask], 1)
63
+ corrected = m - (slope * t + intercept)
64
+ return np.asarray(corrected, dtype=float), float(slope), float(intercept)
65
+
66
+
67
+ def subtract_hysteresis_background(
68
+ h: ArrayLike,
69
+ m: ArrayLike,
70
+ *,
71
+ hi_fraction: float = 0.7,
72
+ min_points: int = 4,
73
+ ) -> tuple[NDArray[np.float64], float, float]:
74
+ """Remove a linear dia/paramagnetic background from an M-H hysteresis loop
75
+ and vertically centre it.
76
+
77
+ Both saturated tails (``|H| > hi_fraction * max|H|``) sit where
78
+ ``M ~= +/-Ms + chi*H + offset``. Each tail is fit *separately* for its slope,
79
+ the two are averaged, and that background susceptibility ``chi`` is removed
80
+ (``M -= chi*H``). The loop is then centred on the midpoint of its two
81
+ saturation plateaus so **no vertical offset remains** — the tails land
82
+ symmetrically on ``+/-Ms`` about ``M = 0``.
83
+
84
+ Fitting the tails separately matters: a single fit across *both* tails folds
85
+ the +/-Ms jump between them into the slope (``chi + Ms/Hmax``), which
86
+ over-subtracts and shears a well-saturated loop toward the origin. Per-tail
87
+ slopes see only the constant ``+Ms`` or ``-Ms`` within one tail, so they
88
+ recover the true background.
89
+
90
+ Improves on MATLAB ``bosonPlotter.hysteresis.subtractLinearBG`` (single
91
+ both-tails fit, slope-only, offset kept — which left the loop vertically
92
+ shifted). Falls back to a both-tails slope with no centring when high field
93
+ is present on only one side (a minor loop — a symmetric centre is undefined).
94
+ A no-op (``chi = offset = 0``, ``M`` unchanged) when fewer than
95
+ ``min_points`` high-field points exist or the field span is degenerate.
96
+
97
+ Distinct from :func:`subtract_mag_background` (M-vs-T, one-sided high-T
98
+ window). Do not use that on a hysteresis loop.
99
+
100
+ Returns ``(corrected, slope, offset)`` — ``slope`` = removed susceptibility
101
+ ``chi``, ``offset`` = removed vertical shift.
102
+ """
103
+ hv = np.asarray(h, dtype=float).ravel()
104
+ mv = np.asarray(m, dtype=float).ravel()
105
+ if hv.size != mv.size:
106
+ raise ValueError("h and m must be the same length")
107
+ if hv.size == 0:
108
+ raise ValueError("need at least 1 data point")
109
+
110
+ h_max = float(np.nanmax(np.abs(hv)))
111
+ if not np.isfinite(h_max) or h_max == 0.0:
112
+ return mv.copy(), 0.0, 0.0
113
+ thresh = hi_fraction * h_max
114
+ pos = hv > thresh
115
+ neg = hv < -thresh
116
+ n_pos = int(np.count_nonzero(pos))
117
+ n_neg = int(np.count_nonzero(neg))
118
+
119
+ # Both saturated tails present: per-tail slope + vertical centring.
120
+ if n_pos >= 2 and n_neg >= 2 and (n_pos + n_neg) >= min_points:
121
+ slope = 0.5 * (
122
+ float(np.polyfit(hv[pos], mv[pos], 1)[0])
123
+ + float(np.polyfit(hv[neg], mv[neg], 1)[0])
124
+ )
125
+ if not np.isfinite(slope):
126
+ return mv.copy(), 0.0, 0.0
127
+ corrected = np.asarray(mv - slope * hv, dtype=float)
128
+ offset = 0.5 * (float(np.mean(corrected[pos])) + float(np.mean(corrected[neg])))
129
+ if not np.isfinite(offset):
130
+ offset = 0.0
131
+ return np.asarray(corrected - offset, dtype=float), slope, offset
132
+
133
+ # One-sided high field (a minor loop): both-tails slope only, no centring.
134
+ hi = np.abs(hv) > thresh
135
+ if int(np.count_nonzero(hi)) < min_points:
136
+ return mv.copy(), 0.0, 0.0
137
+ slope = float(np.polyfit(hv[hi], mv[hi], 1)[0])
138
+ if not np.isfinite(slope):
139
+ return mv.copy(), 0.0, 0.0
140
+ return np.asarray(mv - slope * hv, dtype=float), slope, 0.0
141
+
142
+
143
+ def _field_factor(from_u: str, to_u: str) -> tuple[float, bool, str]:
144
+ if from_u == to_u:
145
+ return 1.0, True, ""
146
+ if from_u not in _FIELD_TO_OE:
147
+ return 1.0, False, f'Unknown source field unit "{from_u}"'
148
+ if to_u not in _FIELD_FROM_OE:
149
+ return 1.0, False, f'Unknown target field unit "{to_u}"'
150
+ return _FIELD_TO_OE[from_u] * _FIELD_FROM_OE[to_u], True, ""
151
+
152
+
153
+ def _moment_factor(
154
+ from_u: str, to_u: str, mass_g: float, vol_cm3: float
155
+ ) -> tuple[float, bool, str]:
156
+ if from_u == to_u:
157
+ return 1.0, True, ""
158
+ if from_u != "emu":
159
+ msg = f'Moment conversions from "{from_u}" are not yet supported (only from "emu")'
160
+ return 1.0, False, msg
161
+ if to_u == "emu":
162
+ return 1.0, True, ""
163
+ if to_u == "A·m²":
164
+ return 1e-3, True, ""
165
+ if to_u == "emu/g":
166
+ if mass_g <= 0:
167
+ return 1.0, False, "Cannot convert moment to emu/g: sample mass is 0."
168
+ return 1.0 / mass_g, True, ""
169
+ if to_u in ("emu/cm³", "kA/m"):
170
+ if vol_cm3 <= 0:
171
+ return 1.0, False, f"Cannot convert moment to {to_u}: sample volume is 0."
172
+ return 1.0 / vol_cm3, True, ""
173
+ return 1.0, False, f'Unknown target moment unit "{to_u}"'
174
+
175
+
176
+ def _append_warn(s: str, msg: str) -> str:
177
+ if not msg:
178
+ return s
179
+ return msg if not s else f"{s}\n{msg}"
180
+
181
+
182
+ def convert_mag_units(
183
+ x: ArrayLike,
184
+ y: ArrayLike,
185
+ *,
186
+ from_field: str = "Oe",
187
+ to_field: str = "Oe",
188
+ from_moment: str = "emu",
189
+ to_moment: str = "emu",
190
+ sample_mass: float = 0.0,
191
+ sample_volume: float = 0.0,
192
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], str, str, str]:
193
+ """Convert field (x) and moment (y) units. Port of convertMagUnits.
194
+
195
+ Returns ``(x_out, y_out, x_unit, y_unit, warning)``. Moment conversions are
196
+ sample-aware (emu/g needs mass, emu/cm³ and kA/m need volume) and only from
197
+ ``emu``. On a failed conversion the data is left unchanged, the unit label
198
+ reverts to the source, and a message is appended to ``warning``.
199
+ """
200
+ x_out = np.asarray(x, dtype=float)
201
+ y_out = np.asarray(y, dtype=float)
202
+ x_unit, y_unit, warning = to_field, to_moment, ""
203
+
204
+ x_factor, x_ok, x_reason = _field_factor(from_field, to_field)
205
+ if not x_ok:
206
+ warning = _append_warn(warning, x_reason)
207
+ x_unit = from_field
208
+ elif x_out.size:
209
+ x_out = x_out * x_factor
210
+
211
+ y_factor, y_ok, y_reason = _moment_factor(from_moment, to_moment, sample_mass, sample_volume)
212
+ if not y_ok:
213
+ warning = _append_warn(warning, y_reason)
214
+ y_unit = from_moment
215
+ elif y_out.size:
216
+ y_out = y_out * y_factor
217
+
218
+ return x_out, y_out, x_unit, y_unit, warning
219
+
220
+
221
+ def _interp_crossing(
222
+ x: NDArray[np.float64], y: NDArray[np.float64], target_y: float
223
+ ) -> float:
224
+ """x where y crosses target_y (steepest crossing wins). Port of interpCrossing."""
225
+ dy = y - target_y
226
+ cross = np.flatnonzero(dy[:-1] * dy[1:] < 0)
227
+ if cross.size == 0:
228
+ return float("nan")
229
+ x_cross = np.empty(cross.size)
230
+ slopes = np.empty(cross.size)
231
+ for ci, i in enumerate(cross):
232
+ x_cross[ci] = x[i] - dy[i] * (x[i + 1] - x[i]) / (dy[i + 1] - dy[i])
233
+ slopes[ci] = abs(dy[i + 1] - dy[i]) / max(abs(x[i + 1] - x[i]), _EPS)
234
+ return float(x_cross[int(np.argmax(slopes))])
235
+
236
+
237
+ def _compute_fwhm(x: NDArray[np.float64], y: NDArray[np.float64], peak_idx: int) -> float:
238
+ """FWHM of a peak by half-max crossings on both sides. Port of computeFWHM."""
239
+ finite = np.isfinite(x) & np.isfinite(y)
240
+ if not finite[peak_idx] or int(finite.sum()) < 3:
241
+ return float("nan")
242
+ half = y[peak_idx] / 2.0
243
+ if not np.isfinite(half) or half <= 0:
244
+ return float("nan")
245
+
246
+ x_left = float("nan")
247
+ for i in range(peak_idx - 1, -1, -1):
248
+ if not finite[i]:
249
+ continue
250
+ if y[i] < half:
251
+ denom = y[i + 1] - y[i]
252
+ frac = 0.0 if abs(denom) < _EPS else (half - y[i]) / denom
253
+ x_left = x[i] + frac * (x[i + 1] - x[i])
254
+ break
255
+ x_right = float("nan")
256
+ for i in range(peak_idx + 1, y.size):
257
+ if not finite[i]:
258
+ continue
259
+ if y[i] < half:
260
+ denom = y[i - 1] - y[i]
261
+ frac = 0.0 if abs(denom) < _EPS else (half - y[i]) / denom
262
+ x_right = x[i] + frac * (x[i - 1] - x[i])
263
+ break
264
+
265
+ if math.isnan(x_left) and math.isnan(x_right):
266
+ fw = float("nan")
267
+ elif math.isnan(x_left):
268
+ fw = 2.0 * abs(x_right - x[peak_idx])
269
+ elif math.isnan(x_right):
270
+ fw = 2.0 * abs(x[peak_idx] - x_left)
271
+ else:
272
+ fw = abs(x_right - x_left)
273
+ return fw if np.isfinite(fw) else float("nan")
274
+
275
+
276
+ def _sorted_unique(
277
+ x: NDArray[np.float64], y: NDArray[np.float64]
278
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
279
+ """MATLAB unique('stable')+sort: sorted unique x with first-occurrence y."""
280
+ xu, idx = np.unique(x, return_index=True)
281
+ return xu, y[idx]
282
+
283
+
284
+ def hysteresis_analysis(
285
+ h: ArrayLike,
286
+ m: ArrayLike,
287
+ *,
288
+ saturation_fraction: float = 0.8,
289
+ pre_smooth: int = 0,
290
+ virgin_detect: bool = True,
291
+ ) -> dict[str, Any]:
292
+ """Analyze an M-H hysteresis loop. Port of utilities.hysteresisAnalysis.
293
+
294
+ Splits the loop into ascending/descending branches (by sweep direction),
295
+ extracts coercivity ``Hc`` (M=0 crossings), remanence ``Mr`` (H=0 crossings),
296
+ saturation ``Ms`` (high-field average), squareness, loop area, and the
297
+ switching-field distribution ``SFD`` (peak dM/dH). Returns a dict mirroring the
298
+ MATLAB result struct (per-branch arrays, dM/dH, warnings).
299
+ """
300
+ hv = np.asarray(h, dtype=float).ravel()
301
+ mv = np.asarray(m, dtype=float).ravel()
302
+ n = hv.size
303
+ if n < 20:
304
+ raise ValueError("need at least 20 data points")
305
+ warnings: list[str] = []
306
+ if pre_smooth > 0:
307
+ mv = smooth_data_savgol(mv, pre_smooth)
308
+
309
+ sign_dh = np.sign(np.diff(hv))
310
+ sign_dh[sign_dh == 0] = 1.0
311
+ reversals = np.flatnonzero(np.diff(sign_dh) != 0) + 2 # 1-based segment boundaries
312
+ seg_starts = np.concatenate([[1], reversals]).astype(int)
313
+ seg_ends = np.concatenate([reversals - 1, [n]]).astype(int)
314
+ n_segs = seg_starts.size
315
+
316
+ seg_ranges = np.zeros(n_segs)
317
+ seg_dirs = np.zeros(n_segs)
318
+ for si in range(n_segs):
319
+ s, e = int(seg_starts[si]), int(seg_ends[si])
320
+ if e > s:
321
+ seg_ranges[si] = hv[e - 1] - hv[s - 1]
322
+ seg_dirs[si] = np.sign(seg_ranges[si])
323
+
324
+ asc_segs = list(np.flatnonzero(seg_dirs > 0))
325
+ desc_segs = list(np.flatnonzero(seg_dirs < 0))
326
+
327
+ virgin = {"H": np.array([]), "M": np.array([])}
328
+ if virgin_detect and asc_segs:
329
+ first_seg = int(asc_segs[0])
330
+ s1, e1 = int(seg_starts[first_seg]), int(seg_ends[first_seg])
331
+ if abs(hv[s1 - 1]) < 0.1 * float(np.max(np.abs(hv))) and first_seg == 0:
332
+ virgin = {"H": hv[s1 - 1 : e1], "M": mv[s1 - 1 : e1]}
333
+ asc_segs.pop(0)
334
+
335
+ def _best_branch(segs: list[Any]) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
336
+ if not segs:
337
+ return np.array([]), np.array([])
338
+ best = int(segs[int(np.argmax(np.abs(seg_ranges[segs])))])
339
+ s, e = int(seg_starts[best]), int(seg_ends[best])
340
+ return hv[s - 1 : e], mv[s - 1 : e]
341
+
342
+ asc_h, asc_m = _best_branch(asc_segs)
343
+ if asc_h.size == 0:
344
+ warnings.append("No ascending branch detected")
345
+ desc_h, desc_m = _best_branch(desc_segs)
346
+ if desc_h.size == 0:
347
+ warnings.append("No descending branch detected")
348
+
349
+ hc = np.array([np.nan, np.nan])
350
+ if asc_h.size:
351
+ hc[0] = _interp_crossing(asc_h, asc_m, 0.0)
352
+ if math.isnan(hc[0]):
353
+ warnings.append("No M=0 crossing on ascending branch")
354
+ if desc_h.size:
355
+ hc[1] = _interp_crossing(desc_h, desc_m, 0.0)
356
+ if math.isnan(hc[1]):
357
+ warnings.append("No M=0 crossing on descending branch")
358
+ hc_mean = _nanmean_abs(hc)
359
+ if np.all(np.isfinite(hc)) and hc_mean > 0:
360
+ asymm = abs(abs(hc[0]) - abs(hc[1])) / hc_mean
361
+ if asymm > 0.1:
362
+ warnings.append(f"Asymmetric loop: |Hc| differ by {asymm * 100:.0f}%")
363
+
364
+ mr = np.array([np.nan, np.nan])
365
+ if asc_h.size:
366
+ mr[0] = _interp_crossing(asc_m, asc_h, 0.0)
367
+ if math.isnan(mr[0]):
368
+ warnings.append("No H=0 crossing on ascending branch")
369
+ if desc_h.size:
370
+ mr[1] = _interp_crossing(desc_m, desc_h, 0.0)
371
+ if math.isnan(mr[1]):
372
+ warnings.append("No H=0 crossing on descending branch")
373
+ mr_mean = _nanmean_abs(mr)
374
+
375
+ hmax = float(np.max(np.abs(hv)))
376
+ sat_thresh = saturation_fraction * hmax
377
+ ms = np.array([np.nan, np.nan])
378
+ if desc_h.size:
379
+ hi = desc_h > sat_thresh
380
+ if int(hi.sum()) >= 3:
381
+ ms[0] = float(np.mean(desc_m[hi]))
382
+ if asc_h.size:
383
+ lo = asc_h < -sat_thresh
384
+ if int(lo.sum()) >= 3:
385
+ ms[1] = float(np.mean(asc_m[lo]))
386
+ ms_mean = _nanmean_abs(ms)
387
+
388
+ if desc_h.size and asc_h.size:
389
+ all_hi = np.abs(hv) > sat_thresh
390
+ if int(all_hi.sum()) >= 6:
391
+ m_hi = mv[all_hi]
392
+ dm_rel = float(np.std(m_hi, ddof=1) / max(abs(np.mean(m_hi)), _EPS))
393
+ if dm_rel > 0.1:
394
+ warnings.append("Loop may not be saturated (high-field M still varying)")
395
+
396
+ squareness = float(np.fmin(mr_mean / max(ms_mean, _EPS), 1.0))
397
+
398
+ sfd = {"peakH": float("nan"), "peakdMdH": float("nan"), "fwhm": float("nan")}
399
+ dmdh_asc: NDArray[np.float64] = np.array([])
400
+ dmdh_desc: NDArray[np.float64] = np.array([])
401
+ if asc_h.size >= 5:
402
+ hu, mu = _sorted_unique(asc_h, asc_m)
403
+ if hu.size >= 5:
404
+ dmdh_asc = derivative(hu, mu, pre_smooth=max(3, pre_smooth))
405
+ pk = int(np.argmax(np.abs(dmdh_asc)))
406
+ sfd = {
407
+ "peakH": float(hu[pk]),
408
+ "peakdMdH": float(dmdh_asc[pk]),
409
+ "fwhm": _compute_fwhm(hu, np.abs(dmdh_asc), pk),
410
+ }
411
+ if desc_h.size >= 5:
412
+ hud, mud = _sorted_unique(desc_h, desc_m)
413
+ if hud.size >= 5:
414
+ dmdh_desc = derivative(hud, mud, pre_smooth=max(3, pre_smooth))
415
+
416
+ loop_area = float("nan")
417
+ if asc_h.size and desc_h.size:
418
+ ha_u, ma_u = _sorted_unique(asc_h, asc_m)
419
+ hd_u, md_u = _sorted_unique(desc_h, desc_m)
420
+ hmin_ov = max(ha_u[0], hd_u[0])
421
+ hmax_ov = min(ha_u[-1], hd_u[-1])
422
+ if hmax_ov > hmin_ov and ha_u.size >= 2 and hd_u.size >= 2:
423
+ hgrid = np.linspace(hmin_ov, hmax_ov, 500)
424
+ m_asc_i = np.interp(hgrid, ha_u, ma_u, left=np.nan, right=np.nan)
425
+ m_desc_i = np.interp(hgrid, hd_u, md_u, left=np.nan, right=np.nan)
426
+ valid = ~np.isnan(m_asc_i) & ~np.isnan(m_desc_i)
427
+ if int(valid.sum()) > 10:
428
+ loop_area = abs(
429
+ float(np.trapezoid(m_desc_i[valid], hgrid[valid]))
430
+ - float(np.trapezoid(m_asc_i[valid], hgrid[valid]))
431
+ )
432
+
433
+ return {
434
+ "Hc": hc,
435
+ "HcMean": hc_mean,
436
+ "Mr": mr,
437
+ "MrMean": mr_mean,
438
+ "Ms": ms,
439
+ "MsMean": ms_mean,
440
+ "squareness": squareness,
441
+ "loopArea": loop_area,
442
+ "SFD": sfd,
443
+ "ascending": {"H": asc_h, "M": asc_m},
444
+ "descending": {"H": desc_h, "M": desc_m},
445
+ "virgin": virgin,
446
+ "dMdH_asc": dmdh_asc,
447
+ "dMdH_desc": dmdh_desc,
448
+ "warnings": warnings,
449
+ }
450
+
451
+
452
+ def _nanmean_abs(v: NDArray[np.float64]) -> float:
453
+ """mean(abs(v), 'omitnan'); NaN if all-NaN (no warning)."""
454
+ av = np.abs(v)
455
+ if np.all(np.isnan(av)):
456
+ return float("nan")
457
+ return float(np.nanmean(av))
458
+
459
+
460
+ def smooth_data_savgol(m: NDArray[np.float64], window: int) -> NDArray[np.float64]:
461
+ """Savitzky-Golay presmooth used by the (default-off) PreSmooth path."""
462
+ from .processing import smooth_data
463
+
464
+ return smooth_data(m, method="savitzky-golay", window=window)
quantized/calc/map.py ADDED
@@ -0,0 +1,228 @@
1
+ """2-D map data contract + builder: scattered (x, y, z) -> regular grid.
2
+
3
+ ``DataStruct`` is the 1-D contract (``time`` ``N``, ``values`` ``N×M``); a 2-D
4
+ map (a ``Z`` field over an ``X×Y`` grid — e.g. an XRD reciprocal-space map) is a
5
+ *sibling* structure, not a forced fit. ``MapData`` holds the regular grid
6
+ produced by :func:`quantized.calc.interp2d.regrid2d`, ready for a Canvas2D
7
+ heatmap render.
8
+
9
+ Storage is the compact regular-grid form: 1-D ``x_axis`` (``nx``) and ``y_axis``
10
+ (``ny``) plus a 2-D ``z_grid`` (``ny × nx``) — cell ``z_grid[j, i]`` sits at
11
+ ``(x_axis[i], y_axis[j])``. This is ``nx + ny`` axis floats instead of the
12
+ ``2·nx·ny`` of full meshgrids, and is exactly what a heatmap consumes.
13
+
14
+ Pure calc layer — ndarrays in, ``MapData`` out. No fastapi/pydantic imports
15
+ (enforced by ``test_repo_integrity``). The instance is frozen and its arrays are
16
+ read-only, honouring the "raw data is preserved, never mutated in place" rule.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass, field
23
+ from types import MappingProxyType
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+ from numpy.typing import ArrayLike, NDArray
28
+
29
+ from quantized.calc.interp2d import regrid2d
30
+ from quantized.datastruct import DataStruct
31
+
32
+ __all__ = ["MapData", "MapState", "build_map", "map_from_datastruct"]
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class MapState:
37
+ """Gridding config for :func:`build_map` (the 2-D analogue of ``PlotState``).
38
+
39
+ Mirrors :func:`quantized.calc.interp2d.regrid2d`'s parameters. ``method``
40
+ defaults to ``"natural"`` (MATLAB ``scatteredInterpolant``'s default; here
41
+ Clough-Tocher C1 cubic — not bit-for-bit MATLAB-equal, see ``interp2d``).
42
+ """
43
+
44
+ method: str = "natural"
45
+ nx: int = 200
46
+ ny: int = 200
47
+ xlim: tuple[float, float] | None = None
48
+ ylim: tuple[float, float] | None = None
49
+ extrapolation: str = "none"
50
+ smoothing: float = 0.0
51
+ idw_power: float = 2.0
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class MapData:
56
+ """Immutable regular-grid 2-D map. Build via :func:`build_map`.
57
+
58
+ ``z_grid`` is ``(ny, nx)``; ``x_axis`` is ``(nx,)`` and ``y_axis`` is
59
+ ``(ny,)``. Outside the data convex hull ``z_grid`` is ``NaN`` (gaps), so use
60
+ the nan-aware :attr:`z_min` / :attr:`z_max` for colour scaling.
61
+ """
62
+
63
+ x_axis: NDArray[np.float64]
64
+ y_axis: NDArray[np.float64]
65
+ z_grid: NDArray[np.float64]
66
+ x_label: str = "x"
67
+ x_unit: str = ""
68
+ y_label: str = "y"
69
+ y_unit: str = ""
70
+ z_label: str = "z"
71
+ z_unit: str = ""
72
+ metadata: Mapping[str, Any] = field(default_factory=dict)
73
+
74
+ def __post_init__(self) -> None:
75
+ x_axis = np.asarray(self.x_axis, dtype=float).ravel()
76
+ y_axis = np.asarray(self.y_axis, dtype=float).ravel()
77
+ z_grid = np.asarray(self.z_grid, dtype=float)
78
+ if z_grid.ndim != 2:
79
+ raise ValueError(f"z_grid must be 2-D, got {z_grid.ndim}-D")
80
+ ny, nx = z_grid.shape
81
+ if x_axis.shape[0] != nx:
82
+ raise ValueError(
83
+ f"x_axis length ({x_axis.shape[0]}) must equal z_grid columns ({nx})"
84
+ )
85
+ if y_axis.shape[0] != ny:
86
+ raise ValueError(
87
+ f"y_axis length ({y_axis.shape[0]}) must equal z_grid rows ({ny})"
88
+ )
89
+
90
+ x_axis.flags.writeable = False
91
+ y_axis.flags.writeable = False
92
+ z_grid.flags.writeable = False
93
+
94
+ object.__setattr__(self, "x_axis", x_axis)
95
+ object.__setattr__(self, "y_axis", y_axis)
96
+ object.__setattr__(self, "z_grid", z_grid)
97
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
98
+
99
+ # ── Shape helpers ─────────────────────────────────────────────────────
100
+ @property
101
+ def nx(self) -> int:
102
+ return int(self.x_axis.shape[0])
103
+
104
+ @property
105
+ def ny(self) -> int:
106
+ return int(self.y_axis.shape[0])
107
+
108
+ @property
109
+ def z_min(self) -> float:
110
+ """Finite minimum of ``z_grid`` (``nan`` if the grid is all-NaN/empty)."""
111
+ return _finite_extreme(self.z_grid, np.nanmin)
112
+
113
+ @property
114
+ def z_max(self) -> float:
115
+ """Finite maximum of ``z_grid`` (``nan`` if the grid is all-NaN/empty)."""
116
+ return _finite_extreme(self.z_grid, np.nanmax)
117
+
118
+ # ── Serialization (route boundary) ────────────────────────────────────
119
+ # Raw lists keep NaN (Python-round-trippable, like DataStruct.to_dict). The
120
+ # HTTP boundary maps non-finite floats to null — a routes concern (jsonify).
121
+ def to_dict(self) -> dict[str, Any]:
122
+ return {
123
+ "x_axis": self.x_axis.tolist(),
124
+ "y_axis": self.y_axis.tolist(),
125
+ "z_grid": self.z_grid.tolist(),
126
+ "x": {"label": self.x_label, "unit": self.x_unit},
127
+ "y": {"label": self.y_label, "unit": self.y_unit},
128
+ "z": {
129
+ "label": self.z_label,
130
+ "unit": self.z_unit,
131
+ "min": self.z_min,
132
+ "max": self.z_max,
133
+ },
134
+ "metadata": dict(self.metadata),
135
+ }
136
+
137
+
138
+ def _finite_extreme(grid: NDArray[np.float64], reducer: Any) -> float:
139
+ """``nanmin``/``nanmax`` that returns ``nan`` (not a RuntimeWarning) when empty."""
140
+ if grid.size == 0 or not np.isfinite(grid).any():
141
+ return float("nan")
142
+ return float(reducer(grid))
143
+
144
+
145
+ def build_map(
146
+ x: ArrayLike,
147
+ y: ArrayLike,
148
+ z: ArrayLike,
149
+ state: MapState | None = None,
150
+ *,
151
+ x_label: str = "x",
152
+ x_unit: str = "",
153
+ y_label: str = "y",
154
+ y_unit: str = "",
155
+ z_label: str = "z",
156
+ z_unit: str = "",
157
+ metadata: Mapping[str, Any] | None = None,
158
+ ) -> MapData:
159
+ """Regrid scattered ``(x, y, z)`` onto a regular grid and wrap as ``MapData``.
160
+
161
+ Delegates the interpolation to :func:`quantized.calc.interp2d.regrid2d`; see
162
+ that function for per-method parity caveats. Raises ``ValueError`` for fewer
163
+ than 3 points or a degenerate axis range (propagated from ``regrid2d``).
164
+ """
165
+ state = state or MapState()
166
+ xq, yq, zq = regrid2d(
167
+ x,
168
+ y,
169
+ z,
170
+ nx=state.nx,
171
+ ny=state.ny,
172
+ method=state.method,
173
+ xlim=state.xlim,
174
+ ylim=state.ylim,
175
+ extrapolation=state.extrapolation,
176
+ smoothing=state.smoothing,
177
+ idw_power=state.idw_power,
178
+ )
179
+ # regrid2d builds the grid via meshgrid(linspace, linspace), so the axes are
180
+ # the first row / column of the returned meshgrids.
181
+ return MapData(
182
+ x_axis=xq[0, :],
183
+ y_axis=yq[:, 0],
184
+ z_grid=zq,
185
+ x_label=x_label,
186
+ x_unit=x_unit,
187
+ y_label=y_label,
188
+ y_unit=y_unit,
189
+ z_label=z_label,
190
+ z_unit=z_unit,
191
+ metadata=dict(metadata) if metadata is not None else {},
192
+ )
193
+
194
+
195
+ def map_from_datastruct(
196
+ ds: DataStruct,
197
+ x_key: int | str,
198
+ y_key: int | str,
199
+ z_key: int | str,
200
+ state: MapState | None = None,
201
+ ) -> MapData:
202
+ """Build a ``MapData`` from three channels of a (scattered) ``DataStruct``.
203
+
204
+ The 3-column ``(x, y, z)`` form is how RSM/contour ASCII exports arrive
205
+ before a 2-D area-detector parser exists, so any such dataset can be mapped
206
+ today. Labels/units are carried from the chosen channels.
207
+ """
208
+ xi = _resolve(ds, x_key)
209
+ yi = _resolve(ds, y_key)
210
+ zi = _resolve(ds, z_key)
211
+ return build_map(
212
+ ds.values[:, xi],
213
+ ds.values[:, yi],
214
+ ds.values[:, zi],
215
+ state,
216
+ x_label=ds.labels[xi],
217
+ x_unit=ds.units[xi],
218
+ y_label=ds.labels[yi],
219
+ y_unit=ds.units[yi],
220
+ z_label=ds.labels[zi],
221
+ z_unit=ds.units[zi],
222
+ metadata={"source": ds.metadata.get("source", "")},
223
+ )
224
+
225
+
226
+ def _resolve(ds: DataStruct, key: int | str) -> int:
227
+ """Channel index from an int index or a label string (mirrors plotting._resolve)."""
228
+ return key if isinstance(key, int) else ds.labels.index(key)