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