quantized-lab 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,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)
|