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,80 @@
|
|
|
1
|
+
"""Inverse-evaluate a fitted curve: X -> Y, and Y -> X (ALL crossings). MAIN #15.
|
|
2
|
+
|
|
3
|
+
Pure calc layer. ``find_y`` is a direct model evaluation at one point.
|
|
4
|
+
``find_x`` samples ``model_fcn(x, params)`` on a dense grid over
|
|
5
|
+
``[x_min, x_max]``, brackets every sign change (or exact zero) of
|
|
6
|
+
``y(x) - target``, and refines each bracket with ``scipy.optimize.brentq`` --
|
|
7
|
+
so a non-monotonic curve (e.g. a Gaussian) reports every crossing, not just
|
|
8
|
+
the first. ``model_fcn`` is a plain ``fcn(x, p) -> y`` callable, the same
|
|
9
|
+
shape used by both ``calc.fit_models.evaluate`` (registry models) and
|
|
10
|
+
``calc.fit_equation.equation_model`` (saved custom equations) -- so this
|
|
11
|
+
module works identically for either, and the route layer only has to pick
|
|
12
|
+
which one produced the callable.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from numpy.typing import NDArray
|
|
21
|
+
from scipy.optimize import brentq
|
|
22
|
+
|
|
23
|
+
__all__ = ["ModelFn", "find_x", "find_y"]
|
|
24
|
+
|
|
25
|
+
ModelFn = Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_y(fcn: ModelFn, params: Sequence[float], x: float) -> float:
|
|
29
|
+
"""Evaluate the fitted model at a single x."""
|
|
30
|
+
p = np.asarray(params, dtype=float)
|
|
31
|
+
y = fcn(np.asarray([x], dtype=float), p)
|
|
32
|
+
return float(np.asarray(y, dtype=float).ravel()[0])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_x(
|
|
36
|
+
fcn: ModelFn,
|
|
37
|
+
params: Sequence[float],
|
|
38
|
+
target: float,
|
|
39
|
+
x_min: float,
|
|
40
|
+
x_max: float,
|
|
41
|
+
*,
|
|
42
|
+
grid_points: int = 2000,
|
|
43
|
+
) -> list[float]:
|
|
44
|
+
"""All x in [x_min, x_max] where ``fcn(x, params) == target``.
|
|
45
|
+
|
|
46
|
+
Returns crossings in ascending order; an empty list (not an error) when
|
|
47
|
+
the curve never reaches ``target`` over the range -- e.g. a monotonic
|
|
48
|
+
curve entirely above or below it.
|
|
49
|
+
"""
|
|
50
|
+
if x_max <= x_min:
|
|
51
|
+
raise ValueError("x_max must be greater than x_min")
|
|
52
|
+
if grid_points < 2:
|
|
53
|
+
raise ValueError("grid_points must be at least 2")
|
|
54
|
+
p = np.asarray(params, dtype=float)
|
|
55
|
+
xs = np.linspace(x_min, x_max, grid_points)
|
|
56
|
+
ys = np.asarray(fcn(xs, p), dtype=float).ravel()
|
|
57
|
+
g = ys - target
|
|
58
|
+
|
|
59
|
+
def g_at(xv: float) -> float:
|
|
60
|
+
yv = fcn(np.asarray([xv], dtype=float), p)
|
|
61
|
+
return float(np.asarray(yv, dtype=float).ravel()[0]) - target
|
|
62
|
+
|
|
63
|
+
# Grid spacing sets the merge tolerance below: a root that lands exactly
|
|
64
|
+
# on a grid node is found once via the `g0 == 0` branch, so the following
|
|
65
|
+
# bracket (whose left endpoint is that same node) must not re-report it.
|
|
66
|
+
dx_tol = (x_max - x_min) / (grid_points - 1) * 1e-6
|
|
67
|
+
roots: list[float] = []
|
|
68
|
+
for i in range(grid_points - 1):
|
|
69
|
+
g0, g1 = float(g[i]), float(g[i + 1])
|
|
70
|
+
if not (np.isfinite(g0) and np.isfinite(g1)):
|
|
71
|
+
continue # degenerate model params (e.g. div-by-zero) -- skip
|
|
72
|
+
if g0 == 0.0:
|
|
73
|
+
if not roots or xs[i] - roots[-1] > dx_tol:
|
|
74
|
+
roots.append(float(xs[i]))
|
|
75
|
+
continue
|
|
76
|
+
if g0 * g1 < 0.0:
|
|
77
|
+
roots.append(brentq(g_at, float(xs[i]), float(xs[i + 1])))
|
|
78
|
+
if np.isfinite(g[-1]) and g[-1] == 0.0 and (not roots or xs[-1] - roots[-1] > dx_tol):
|
|
79
|
+
roots.append(float(xs[-1]))
|
|
80
|
+
return roots
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Curve-fit model library. Port of fitting.models (the model evaluators).
|
|
2
|
+
|
|
3
|
+
Pure calc layer. Each model is ``f(x, p) -> y``; ``FIT_MODELS`` maps a model name
|
|
4
|
+
to its evaluator plus metadata (p0 / lower / upper bounds / paramNames). Closed-
|
|
5
|
+
form models live here; helper-based magnetic/thermal models (Langevin, Brillouin,
|
|
6
|
+
Stoner-Wohlfarth, Debye, Einstein) are registered in ``fit_models_special``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import ArrayLike, NDArray
|
|
17
|
+
|
|
18
|
+
from .peakshapes import pseudo_voigt
|
|
19
|
+
|
|
20
|
+
__all__ = ["FIT_MODELS", "evaluate", "model_names", "register_model", "unregister_model"]
|
|
21
|
+
|
|
22
|
+
_INF = float("inf")
|
|
23
|
+
_EPS = float(np.finfo(float).eps)
|
|
24
|
+
_FWHM_PER_SIGMA = 2.0 * math.sqrt(2.0 * math.log(2.0))
|
|
25
|
+
|
|
26
|
+
ModelFn = Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
|
|
27
|
+
|
|
28
|
+
FIT_MODELS: dict[str, dict[str, Any]] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register_model(
|
|
32
|
+
name: str, category: str, fcn: ModelFn, param_names: list[str],
|
|
33
|
+
p0: list[float], lb: list[float], ub: list[float],
|
|
34
|
+
) -> None:
|
|
35
|
+
FIT_MODELS[name] = {
|
|
36
|
+
"fcn": fcn, "category": category, "paramNames": param_names,
|
|
37
|
+
"p0": p0, "lb": lb, "ub": ub, "nParams": len(p0),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def unregister_model(name: str) -> None:
|
|
42
|
+
"""Remove a registered model (used to unload plugin-contributed models)."""
|
|
43
|
+
FIT_MODELS.pop(name, None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def evaluate(name: str, x: ArrayLike, p: ArrayLike) -> NDArray[np.float64]:
|
|
47
|
+
"""Evaluate model ``name`` at points ``x`` with parameters ``p``."""
|
|
48
|
+
fcn: ModelFn = FIT_MODELS[name]["fcn"]
|
|
49
|
+
return np.asarray(fcn(np.asarray(x, dtype=float), np.asarray(p, dtype=float)), dtype=float)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def model_names() -> list[str]:
|
|
53
|
+
return list(FIT_MODELS)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Linear / polynomial ─────────────────────────────────────────────────
|
|
57
|
+
def _linear(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
58
|
+
return np.asarray(p[0] * x + p[1], dtype=float)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _quadratic(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
62
|
+
return np.asarray(p[0] * x**2 + p[1] * x + p[2], dtype=float)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cubic(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
66
|
+
return np.asarray(p[0] * x**3 + p[1] * x**2 + p[2] * x + p[3], dtype=float)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _poly4(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
70
|
+
return np.asarray(p[0] * x**4 + p[1] * x**3 + p[2] * x**2 + p[3] * x + p[4], dtype=float)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Decay / growth ──────────────────────────────────────────────────────
|
|
74
|
+
def _exp_decay(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
75
|
+
return np.asarray(p[0] * np.exp(-x / p[1]) + p[2], dtype=float)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _stretched_exp(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
79
|
+
return np.asarray(p[0] * np.exp(-((x / p[1]) ** p[2])) + p[3], dtype=float)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _biexp_decay(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
83
|
+
return np.asarray(p[0] * np.exp(-x / p[1]) + p[2] * np.exp(-x / p[3]) + p[4], dtype=float)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _exp_growth(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
87
|
+
return np.asarray(p[0] * np.exp(x / p[1]) + p[2], dtype=float)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _sat_growth(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
91
|
+
return np.asarray(p[0] * (1 - np.exp(-x / p[1])) + p[2], dtype=float)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Peak shapes ─────────────────────────────────────────────────────────
|
|
95
|
+
def _gaussian(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
96
|
+
return np.asarray(p[0] * np.exp(-((x - p[1]) ** 2) / (2 * p[2] ** 2)), dtype=float)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _lorentzian(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
100
|
+
return np.asarray(p[0] / (1 + ((x - p[1]) / p[2]) ** 2), dtype=float)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _pseudo_voigt(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
104
|
+
return pseudo_voigt(x, float(p[1]), _FWHM_PER_SIGMA * float(p[2]), float(p[0]), float(p[3]))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Power / sigmoid / misc ──────────────────────────────────────────────
|
|
108
|
+
def _power_law(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
109
|
+
return np.asarray(p[0] * np.abs(x) ** p[1] + p[2], dtype=float)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _allometric(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
113
|
+
return np.asarray(p[0] * np.abs(x) ** p[1], dtype=float)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _logistic(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
117
|
+
return np.asarray(p[0] / (1 + np.exp(-p[1] * (x - p[2]))) + p[3], dtype=float)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _tanh(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
121
|
+
return np.asarray(p[0] * np.tanh(p[1] * (x - p[2])) + p[3], dtype=float)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _curie_weiss(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
125
|
+
return np.asarray(p[0] / (x - p[1]), dtype=float)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _bloch(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
129
|
+
return np.asarray(p[0] * (1 - p[1] * x**1.5), dtype=float)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _arrhenius(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
133
|
+
return np.asarray(p[0] * np.exp(-p[1] / x), dtype=float)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _vft(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
137
|
+
return np.asarray(p[0] * np.exp(p[1] / (8.617e-5 * (x - p[2]))), dtype=float)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _langmuir(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
141
|
+
return np.asarray(p[0] * x / (p[1] + x), dtype=float)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _logarithmic(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
145
|
+
return np.asarray(p[0] * np.log(np.abs(x) + _EPS) + p[1], dtype=float)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _sqrt(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
149
|
+
return np.asarray(p[0] * np.sqrt(np.abs(x)) + p[1], dtype=float)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── Registry (closed-form models) ───────────────────────────────────────
|
|
153
|
+
register_model("Linear", "Linear", _linear, ["m", "b"], [1, 0], [-_INF, -_INF], [_INF, _INF])
|
|
154
|
+
register_model("Quadratic", "Linear", _quadratic, ["a", "b", "c"], [0, 1, 0],
|
|
155
|
+
[-_INF, -_INF, -_INF], [_INF, _INF, _INF])
|
|
156
|
+
register_model("Cubic", "Linear", _cubic, ["a", "b", "c", "d"], [0, 0, 1, 0],
|
|
157
|
+
[-_INF] * 4, [_INF] * 4)
|
|
158
|
+
register_model("Poly 4", "Linear", _poly4, ["a", "b", "c", "d", "e"], [0, 0, 0, 1, 0],
|
|
159
|
+
[-_INF] * 5, [_INF] * 5)
|
|
160
|
+
register_model("Exponential Decay", "Decay", _exp_decay, ["A", "τ", "C"], [1, 1, 0],
|
|
161
|
+
[-_INF, 0, -_INF], [_INF, _INF, _INF])
|
|
162
|
+
register_model("Stretched Exponential", "Decay", _stretched_exp, ["A", "τ", "β", "C"],
|
|
163
|
+
[1, 1, 0.5, 0], [-_INF, 0, 0, -_INF], [_INF, _INF, 2, _INF])
|
|
164
|
+
register_model("Bi-exponential Decay", "Decay", _biexp_decay, ["A₁", "τ₁", "A₂", "τ₂", "C"],
|
|
165
|
+
[1, 1, 0.5, 5, 0], [-_INF, 0, -_INF, 0, -_INF], [_INF] * 5)
|
|
166
|
+
register_model("Exponential Growth", "Growth", _exp_growth, ["A", "τ", "C"], [1, 1, 0],
|
|
167
|
+
[-_INF, 0, -_INF], [_INF, _INF, _INF])
|
|
168
|
+
register_model("Saturation Growth", "Growth", _sat_growth, ["A", "τ", "C"], [1, 1, 0],
|
|
169
|
+
[-_INF, 0, -_INF], [_INF, _INF, _INF])
|
|
170
|
+
register_model("Gaussian", "Peak", _gaussian, ["A", "μ", "σ"], [1, 0, 1],
|
|
171
|
+
[-_INF, -_INF, 0], [_INF, _INF, _INF])
|
|
172
|
+
register_model("Lorentzian", "Peak", _lorentzian, ["A", "x₀", "γ"], [1, 0, 1],
|
|
173
|
+
[-_INF, -_INF, 0], [_INF, _INF, _INF])
|
|
174
|
+
register_model("Pseudo-Voigt", "Peak", _pseudo_voigt, ["A", "x₀", "w", "η"], [1, 0, 1, 0.5],
|
|
175
|
+
[-_INF, -_INF, 0, 0], [_INF, _INF, _INF, 1])
|
|
176
|
+
register_model("Power Law", "Power", _power_law, ["A", "n", "C"], [1, 1, 0],
|
|
177
|
+
[-_INF, -_INF, -_INF], [_INF, _INF, _INF])
|
|
178
|
+
register_model("Allometric", "Power", _allometric, ["A", "n"], [1, 1], [-_INF, -_INF], [_INF, _INF])
|
|
179
|
+
register_model("Logistic", "Sigmoid", _logistic, ["A", "k", "x₀", "C"], [1, 1, 0, 0],
|
|
180
|
+
[-_INF, 0, -_INF, -_INF], [_INF, _INF, _INF, _INF])
|
|
181
|
+
register_model("Tanh", "Sigmoid", _tanh, ["A", "k", "x₀", "C"], [1, 1, 0, 0],
|
|
182
|
+
[-_INF, 0, -_INF, -_INF], [_INF, _INF, _INF, _INF])
|
|
183
|
+
register_model("Curie-Weiss", "Magnetic", _curie_weiss, ["C", "θ"], [1, 0],
|
|
184
|
+
[0, -_INF], [_INF, _INF])
|
|
185
|
+
register_model("Bloch T^3/2", "Magnetic", _bloch, ["M₀", "B"], [1, 1e-5], [0, 0], [_INF, _INF])
|
|
186
|
+
register_model("Arrhenius", "Thermal", _arrhenius, ["A", "Eₐ/kB"], [1, 1000], [0, 0], [_INF, _INF])
|
|
187
|
+
register_model("VFT", "Decay", _vft, ["τ₀", "Ea_eV", "T₀"], [1e-10, 0.05, 0], [0, 0, 0],
|
|
188
|
+
[1, 10, _INF])
|
|
189
|
+
register_model("Langmuir", "Thermal", _langmuir, ["A", "K"], [1, 1], [0, 0], [_INF, _INF])
|
|
190
|
+
register_model("Logarithmic", "Other", _logarithmic, ["a", "b"], [1, 0], [-_INF, -_INF],
|
|
191
|
+
[_INF, _INF])
|
|
192
|
+
register_model("Square Root", "Other", _sqrt, ["a", "b"], [1, 0], [-_INF, -_INF], [_INF, _INF])
|
|
193
|
+
|
|
194
|
+
# Register the helper-based magnetic/heat-capacity models (side-effect import).
|
|
195
|
+
from . import fit_models_special # noqa: E402, F401
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Helper-based fitting models (magnetic + heat-capacity). Extends fit_models.
|
|
2
|
+
|
|
3
|
+
Imported for its registration side effects (see the import at the bottom of
|
|
4
|
+
fit_models.py). Models: Langevin, Brillouin, Stoner-Wohlfarth, Debye, Einstein,
|
|
5
|
+
Debye+Einstein. Port of the local helpers in fitting/models.m.
|
|
6
|
+
|
|
7
|
+
The ``Hysteresis``-category models (tanh loop, two-component F+P, linear
|
|
8
|
+
background, approach-to-saturation, Langevin+background) are a port of
|
|
9
|
+
``+fitting/hysteresisModels.m`` — the catalogue the magnetometry hysteresis
|
|
10
|
+
workshop offers for M-H loop fitting. They are empirical loop descriptors, not
|
|
11
|
+
full Stoner-Wohlfarth astroid solutions (see Cullity & Graham, "Introduction to
|
|
12
|
+
Magnetic Materials", 2nd ed., Ch. 7/9/11; Akulov, Z. Phys. 67, 794 (1931)).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from numpy.typing import NDArray
|
|
21
|
+
from scipy.integrate import quad
|
|
22
|
+
|
|
23
|
+
from .fit_models import register_model
|
|
24
|
+
|
|
25
|
+
_INF = float("inf")
|
|
26
|
+
_EPS = float(np.finfo(float).eps)
|
|
27
|
+
_R = 8.314 # molar gas constant J/(mol·K)
|
|
28
|
+
_DEBYE_LIMIT = 4 * math.pi**4 / 15 # integral_0^inf x^4 e^x/(e^x-1)^2 dx
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _langevin(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
32
|
+
a, b = float(p[0]), float(p[1])
|
|
33
|
+
u = x / max(b, _EPS)
|
|
34
|
+
out = np.empty_like(u, dtype=float)
|
|
35
|
+
small = np.abs(u) < 1e-4
|
|
36
|
+
out[small] = a * (u[small] / 3 - u[small] ** 3 / 45)
|
|
37
|
+
us = u[~small]
|
|
38
|
+
out[~small] = a * (1.0 / np.tanh(us) - 1.0 / us)
|
|
39
|
+
return np.asarray(out, dtype=float)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _brillouin_bj(j: float, y: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
43
|
+
if j == 0:
|
|
44
|
+
return np.zeros_like(y, dtype=float)
|
|
45
|
+
a = (2 * j + 1) / (2 * j)
|
|
46
|
+
b = 1 / (2 * j)
|
|
47
|
+
out = np.empty_like(y, dtype=float)
|
|
48
|
+
small = np.abs(y) < 1e-6
|
|
49
|
+
out[small] = (j + 1) / (3 * j) * y[small]
|
|
50
|
+
yl = y[~small]
|
|
51
|
+
out[~small] = a / np.tanh(a * yl) - b / np.tanh(b * yl)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _brillouin(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
56
|
+
ms, j, g, t = float(p[0]), float(p[1]), float(p[2]), float(p[3])
|
|
57
|
+
y = g * 5.7884e-5 * j * x / (8.617e-5 * t)
|
|
58
|
+
return np.asarray(ms * _brillouin_bj(j, y), dtype=float)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _stoner_wohlfarth(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
62
|
+
ms, hc = float(p[0]), float(p[1])
|
|
63
|
+
hk = max(float(p[2]), _EPS)
|
|
64
|
+
heff = x - np.sign(x) * hc
|
|
65
|
+
return np.asarray(ms * np.tanh(heff / hk), dtype=float)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _debye_integrand(t: float) -> float:
|
|
69
|
+
et = math.exp(t)
|
|
70
|
+
return t**4 * et / max((et - 1) ** 2, _EPS)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _debye_integral(u: float) -> float:
|
|
74
|
+
if u > 30:
|
|
75
|
+
return _DEBYE_LIMIT
|
|
76
|
+
if u < 1e-4:
|
|
77
|
+
return u**3 / 3
|
|
78
|
+
val, _ = quad(_debye_integrand, 0.0, u, epsrel=1e-6, epsabs=1e-10)
|
|
79
|
+
return float(val)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _debye(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
83
|
+
gamma, theta, n = float(p[0]), max(float(p[1]), 1.0), max(float(p[2]), 0.0)
|
|
84
|
+
t = np.asarray(x, dtype=float).ravel()
|
|
85
|
+
out = np.empty(t.size)
|
|
86
|
+
for k in range(t.size):
|
|
87
|
+
tk = max(float(t[k]), 0.01)
|
|
88
|
+
u = theta / tk
|
|
89
|
+
c_lat = 9 * _R * (1 / u) ** 3 * _debye_integral(u)
|
|
90
|
+
out[k] = gamma * tk + n * c_lat * 1000
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _einstein_lattice(theta: float, tk: float) -> float:
|
|
95
|
+
u = theta / tk
|
|
96
|
+
eu = math.exp(min(u, 500))
|
|
97
|
+
return 3 * _R * u**2 * eu / max((eu - 1) ** 2, _EPS)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _einstein(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
101
|
+
gamma, theta, n = float(p[0]), max(float(p[1]), 1.0), max(float(p[2]), 0.0)
|
|
102
|
+
t = np.asarray(x, dtype=float).ravel()
|
|
103
|
+
out = np.empty(t.size)
|
|
104
|
+
for k in range(t.size):
|
|
105
|
+
tk = max(float(t[k]), 0.01)
|
|
106
|
+
out[k] = gamma * tk + n * _einstein_lattice(theta, tk) * 1000
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _debye_einstein(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
111
|
+
gamma = float(p[0])
|
|
112
|
+
theta_d, n_d = max(float(p[1]), 1.0), max(float(p[2]), 0.0)
|
|
113
|
+
theta_e, n_e = max(float(p[3]), 1.0), max(float(p[4]), 0.0)
|
|
114
|
+
t = np.asarray(x, dtype=float).ravel()
|
|
115
|
+
out = np.empty(t.size)
|
|
116
|
+
for k in range(t.size):
|
|
117
|
+
tk = max(float(t[k]), 0.01)
|
|
118
|
+
c_d = 9 * _R * (1 / (theta_d / tk)) ** 3 * _debye_integral(theta_d / tk)
|
|
119
|
+
c_e = _einstein_lattice(theta_e, tk)
|
|
120
|
+
out[k] = gamma * tk + (n_d * c_d + n_e * c_e) * 1000
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Hysteresis (magnetic M-H loop descriptors) ──────────────────────────
|
|
125
|
+
def _tanh_hysteresis(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
126
|
+
"""M = Ms·tanh((H - Hc)/Hw). Soft-ferromagnet loop. p = [Ms, Hc, Hw]."""
|
|
127
|
+
ms, hc, hw = float(p[0]), float(p[1]), max(abs(float(p[2])), _EPS)
|
|
128
|
+
return np.asarray(ms * np.tanh((x - hc) / hw), dtype=float)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _two_component(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
132
|
+
"""M = Ms·tanh((H - Hc)/Hw) + χ·H. Ferromagnetic loop + linear (para) BG.
|
|
133
|
+
|
|
134
|
+
p = [Ms, Hc, Hw, χ].
|
|
135
|
+
"""
|
|
136
|
+
ms, hc, hw, chi = float(p[0]), float(p[1]), max(abs(float(p[2])), _EPS), float(p[3])
|
|
137
|
+
return np.asarray(ms * np.tanh((x - hc) / hw) + chi * x, dtype=float)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _linear_background(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
141
|
+
"""M = χ·H + offset. Pure linear (dia/paramagnetic) background. p = [χ, offset]."""
|
|
142
|
+
return np.asarray(float(p[0]) * x + float(p[1]), dtype=float)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _approach_saturation(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
146
|
+
"""M = Ms(1 - a/|H| - b/H²) + χ·H. High-field Akulov expansion. p = [Ms, a, b, χ]."""
|
|
147
|
+
ms, a, b, chi = float(p[0]), float(p[1]), float(p[2]), float(p[3])
|
|
148
|
+
xv = np.asarray(x, dtype=float)
|
|
149
|
+
return np.asarray(
|
|
150
|
+
ms * (1.0 - a / (np.abs(xv) + _EPS) - b / (xv**2 + _EPS)) + chi * xv, dtype=float
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _langevin_bg(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
155
|
+
"""M = Ms·L(αH) + χ·H, L(u) = coth(u) - 1/u. Superparamagnet + BG. p = [Ms, α, χ]."""
|
|
156
|
+
ms, alpha, chi = float(p[0]), float(p[1]), float(p[2])
|
|
157
|
+
u = alpha * np.asarray(x, dtype=float)
|
|
158
|
+
out = np.empty_like(u, dtype=float)
|
|
159
|
+
small = np.abs(u) < 1e-4
|
|
160
|
+
out[small] = ms * (u[small] / 3.0 - u[small] ** 3 / 45.0)
|
|
161
|
+
us = u[~small]
|
|
162
|
+
out[~small] = ms * (1.0 / np.tanh(us) - 1.0 / us)
|
|
163
|
+
return np.asarray(out + chi * np.asarray(x, dtype=float), dtype=float)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
register_model("Tanh Hysteresis", "Hysteresis", _tanh_hysteresis, ["Ms", "Hc", "Hw"],
|
|
167
|
+
[1e-3, 100, 200], [0, -_INF, 0], [_INF, _INF, _INF])
|
|
168
|
+
register_model("Two-Component (F+P)", "Hysteresis", _two_component, ["Ms", "Hc", "Hw", "χ"],
|
|
169
|
+
[1e-3, 100, 200, 0], [0, -_INF, 0, -_INF], [_INF, _INF, _INF, _INF])
|
|
170
|
+
register_model("Linear Background", "Hysteresis", _linear_background, ["χ", "offset"],
|
|
171
|
+
[1e-7, 0], [-_INF, -_INF], [_INF, _INF])
|
|
172
|
+
register_model("Approach to Saturation", "Hysteresis", _approach_saturation,
|
|
173
|
+
["Ms", "a", "b", "χ"], [1e-3, 1, 1, 0], [0, 0, 0, -_INF],
|
|
174
|
+
[_INF, _INF, _INF, _INF])
|
|
175
|
+
register_model("Langevin + Background", "Hysteresis", _langevin_bg, ["Ms", "μ/kT", "χ"],
|
|
176
|
+
[1e-3, 1e-3, 0], [0, 0, -_INF], [_INF, _INF, _INF])
|
|
177
|
+
|
|
178
|
+
register_model("Langevin", "Magnetic", _langevin, ["A", "B"], [1, 1], [0, 0], [_INF, _INF])
|
|
179
|
+
register_model("Brillouin", "Magnetic", _brillouin, ["Ms", "J", "g", "T"], [1, 0.5, 2, 300],
|
|
180
|
+
[0, 0.5, 0, 0], [_INF, 7, 10, 1000])
|
|
181
|
+
register_model("Stoner-Wohlfarth", "Magnetic", _stoner_wohlfarth, ["Ms", "Hc", "Hk"],
|
|
182
|
+
[1, 100, 500], [0, 0, 0], [_INF, _INF, _INF])
|
|
183
|
+
register_model("Debye", "Thermal", _debye, ["gamma", "thetaD", "n"], [5, 200, 1],
|
|
184
|
+
[0, 1, 0.1], [_INF, _INF, 20])
|
|
185
|
+
register_model("Einstein", "Thermal", _einstein, ["gamma", "thetaE", "n"], [5, 150, 1],
|
|
186
|
+
[0, 1, 0.1], [_INF, _INF, 20])
|
|
187
|
+
register_model("Debye+Einstein", "Thermal", _debye_einstein,
|
|
188
|
+
["gamma", "thetaD", "n_D", "thetaE", "n_E"], [5, 200, 0.8, 150, 0.2],
|
|
189
|
+
[0, 1, 0, 1, 0], [_INF, _INF, 20, _INF, 20])
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Orthogonal distance (Deming) regression. Port of fitting.odrFit.
|
|
2
|
+
|
|
3
|
+
Pure calc layer. Linear fit ``y = slope·x + intercept`` minimising the squared
|
|
4
|
+
*perpendicular* distances (errors in both x and y), with a variance ratio
|
|
5
|
+
``lambda = σy²/σx²`` (λ→∞ → OLS, λ→0 → inverse OLS, λ=1 → symmetric ODR). The
|
|
6
|
+
estimator is closed-form (Deming); standard errors come from jackknife
|
|
7
|
+
leave-one-out refits — exactly as the MATLAB original.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import ArrayLike, NDArray
|
|
17
|
+
|
|
18
|
+
__all__ = ["odr_fit"]
|
|
19
|
+
|
|
20
|
+
_EPS = float(np.finfo(float).eps)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _deming_fit(x: NDArray[np.float64], y: NDArray[np.float64], lam: float) -> tuple[float, float]:
|
|
24
|
+
"""Closed-form Deming regression on centered moments (port of demingFit)."""
|
|
25
|
+
xbar, ybar = float(np.mean(x)), float(np.mean(y))
|
|
26
|
+
xc = x - xbar
|
|
27
|
+
yc = y - ybar
|
|
28
|
+
sxx = float(np.sum(xc**2))
|
|
29
|
+
syy = float(np.sum(yc**2))
|
|
30
|
+
sxy = float(np.sum(xc * yc))
|
|
31
|
+
if abs(sxy) < _EPS:
|
|
32
|
+
slope = 0.0 # no linear correlation → flat line anchored at the mean
|
|
33
|
+
else:
|
|
34
|
+
disc = (syy - lam * sxx) ** 2 + 4.0 * lam * sxy**2
|
|
35
|
+
slope = (syy - lam * sxx + math.copysign(1.0, sxy) * math.sqrt(disc)) / (2.0 * sxy)
|
|
36
|
+
intercept = ybar - slope * xbar
|
|
37
|
+
return slope, intercept
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def odr_fit(
|
|
41
|
+
x: ArrayLike,
|
|
42
|
+
y: ArrayLike,
|
|
43
|
+
*,
|
|
44
|
+
lambda_: float = 1.0,
|
|
45
|
+
x_error: ArrayLike | None = None,
|
|
46
|
+
y_error: ArrayLike | None = None,
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
"""Deming/orthogonal linear regression. Port of fitting.odrFit.
|
|
49
|
+
|
|
50
|
+
``lambda_`` is the σy²/σx² ratio (default 1 → symmetric ODR). If both
|
|
51
|
+
``x_error`` and ``y_error`` are given, λ is derived as
|
|
52
|
+
``(mean(y_error)/mean(x_error))²``. Returns a dict with ``slope``/``intercept``,
|
|
53
|
+
jackknife ``slopeErr``/``interceptErr``, ``lambda``, ``rss``, ``rmse``, ``n``.
|
|
54
|
+
"""
|
|
55
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
56
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
57
|
+
if xv.size != yv.size:
|
|
58
|
+
raise ValueError("x and y must have the same length")
|
|
59
|
+
n = xv.size
|
|
60
|
+
if n < 3:
|
|
61
|
+
raise ValueError(f"ODR requires at least 3 points (got {n})")
|
|
62
|
+
|
|
63
|
+
lam = float(lambda_)
|
|
64
|
+
if lam <= 0:
|
|
65
|
+
raise ValueError("lambda_ must be positive")
|
|
66
|
+
if x_error is not None and y_error is not None:
|
|
67
|
+
xe_mean = float(np.nanmean(np.asarray(x_error, dtype=float)))
|
|
68
|
+
ye_mean = float(np.nanmean(np.asarray(y_error, dtype=float)))
|
|
69
|
+
if xe_mean <= 0:
|
|
70
|
+
raise ValueError("mean x_error must be positive")
|
|
71
|
+
lam = (ye_mean / xe_mean) ** 2
|
|
72
|
+
|
|
73
|
+
slope, intercept = _deming_fit(xv, yv, lam)
|
|
74
|
+
|
|
75
|
+
# Orthogonal residuals: perpendicular distance from each point to the line.
|
|
76
|
+
res = (slope * xv - yv + intercept) / math.sqrt(slope**2 + 1.0)
|
|
77
|
+
rss = float(np.sum(res**2))
|
|
78
|
+
rmse = math.sqrt(rss / n)
|
|
79
|
+
|
|
80
|
+
# Jackknife standard errors — refit n times leaving one point out.
|
|
81
|
+
slopes = np.empty(n)
|
|
82
|
+
intercepts = np.empty(n)
|
|
83
|
+
for k in range(n):
|
|
84
|
+
mask = np.arange(n) != k
|
|
85
|
+
slopes[k], intercepts[k] = _deming_fit(xv[mask], yv[mask], lam)
|
|
86
|
+
factor = (n - 1) / n
|
|
87
|
+
slope_err = math.sqrt(factor * float(np.sum((slopes - np.mean(slopes)) ** 2)))
|
|
88
|
+
intercept_err = math.sqrt(factor * float(np.sum((intercepts - np.mean(intercepts)) ** 2)))
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"slope": slope,
|
|
92
|
+
"intercept": intercept,
|
|
93
|
+
"slopeErr": slope_err,
|
|
94
|
+
"interceptErr": intercept_err,
|
|
95
|
+
"lambda": lam,
|
|
96
|
+
"rss": rss,
|
|
97
|
+
"rmse": rmse,
|
|
98
|
+
"n": n,
|
|
99
|
+
}
|