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,217 @@
|
|
|
1
|
+
"""Axis tick-label FORMATTING for matplotlib export (MAIN #24) -- mirrors the
|
|
2
|
+
on-screen `AxisFormat` contract (`frontend/src/lib/uplotOpts.ts`'s
|
|
3
|
+
`tickFormatter` + `frontend/src/lib/ticks.ts`'s `decimalsForIncrement`) so a
|
|
4
|
+
published figure's tick labels read exactly like the interactive plot's.
|
|
5
|
+
Pure layer, split out of ``calc.figure`` purely to stay under the 500-line
|
|
6
|
+
god-module ceiling (mirrors ``figure_scale``/``figure_break``/``figure_overrides``).
|
|
7
|
+
|
|
8
|
+
``AxisFormat`` is ``{mode: "auto"|"fixed"|"sci"|"eng", digits: number}``:
|
|
9
|
+
``auto`` returns ``None`` here (matplotlib's own default formatter stays,
|
|
10
|
+
unlike the frontend's own Intl-based ``auto`` formatter -- the two rendering
|
|
11
|
+
engines don't need byte-identical ``auto`` output, only the explicit
|
|
12
|
+
fixed/sci/eng modes the owner can actually configure per axis). ``fixed``/
|
|
13
|
+
``sci``/``eng`` each floor their configured ``digits`` at whatever the DRAWN
|
|
14
|
+
tick increment needs (screen-side ``decimalsForIncrement``/
|
|
15
|
+
``mantissaDecimalFloor``), the same "never render two different ticks with
|
|
16
|
+
the same label" guarantee MAIN #20 fixed on-screen.
|
|
17
|
+
|
|
18
|
+
matplotlib has no direct equivalent of uPlot's ``values(u, splits, ...,
|
|
19
|
+
foundIncr)`` callback (which receives the increment its own tick generator
|
|
20
|
+
just found). A tick label is instead built one at a time by a
|
|
21
|
+
``Formatter.__call__(x, pos)``, with no increment argument at all.
|
|
22
|
+
``_AxisTickFormatter`` below is a ``matplotlib.ticker.Formatter`` subclass
|
|
23
|
+
(not a plain ``FuncFormatter`` closure) so it can read ``self.axis`` (set by
|
|
24
|
+
``Axis.set_major_formatter``) and pull ``self.axis.get_majorticklocs()``
|
|
25
|
+
LAZILY, INSIDE ``__call__`` -- at draw time, after the axis's locator has
|
|
26
|
+
committed to its final tick positions. A ``FuncFormatter`` closure computed
|
|
27
|
+
once when the formatter is attached would instead capture whatever ticks
|
|
28
|
+
existed at THAT moment, which ``tight_layout``/``savefig`` can still revise
|
|
29
|
+
before the real draw -- the locator-aware subclass approach is robust to
|
|
30
|
+
matplotlib recomputing ticks between attachment and draw.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import math
|
|
36
|
+
from collections.abc import Mapping
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from matplotlib.ticker import Formatter
|
|
40
|
+
|
|
41
|
+
__all__ = ["apply_tick_formats", "axis_tick_formatter"]
|
|
42
|
+
|
|
43
|
+
_MODES = ("fixed", "sci", "eng")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pow10(k: int) -> float:
|
|
47
|
+
"""Exact ``10**k`` for integer ``k`` via decimal-literal parsing --
|
|
48
|
+
mirrors the frontend's ``pow10`` (``lib/ticks.ts``), which exists because
|
|
49
|
+
``Math.pow(10, k)`` is not guaranteed correctly rounded; both Python's
|
|
50
|
+
``float()`` and JS's ``Number()`` ARE required to correctly round a
|
|
51
|
+
parsed decimal literal, so this stays exact on every platform."""
|
|
52
|
+
return float(f"1e{k}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _js_round(v: float) -> int:
|
|
56
|
+
"""``Math.round`` semantics (round half AWAY FROM negative infinity, i.e.
|
|
57
|
+
half rounds up) -- Python's builtin ``round`` is round-half-to-EVEN, which
|
|
58
|
+
disagrees with JS at exact X.5 digit counts. Only the sign/tie-break at
|
|
59
|
+
``.5`` differs; both agree everywhere else."""
|
|
60
|
+
return math.floor(v + 0.5)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _decimals_for_increment(incr: float, max_decimals: int = 20) -> int:
|
|
64
|
+
"""Port of the frontend's ``decimalsForIncrement`` (``lib/ticks.ts``):
|
|
65
|
+
the decimal-place FLOOR a fixed-decimal tick formatter must never go
|
|
66
|
+
below, so ticks ``incr`` apart never collapse to the same label. Starts
|
|
67
|
+
from the log10 order of magnitude, then round-trips (format -> parse ->
|
|
68
|
+
compare) upward for "nice" non-power-of-10 steps whose exact decimal form
|
|
69
|
+
needs a digit or two more than ``-log10(incr)`` alone implies."""
|
|
70
|
+
if not (incr > 0) or not math.isfinite(incr):
|
|
71
|
+
return 0
|
|
72
|
+
d = max(0, min(max_decimals, math.ceil(-math.log10(incr) - 1e-9)))
|
|
73
|
+
tol = max(incr * 1e-6, 1e-15)
|
|
74
|
+
while d < max_decimals and abs(float(f"{incr:.{d}f}") - incr) > tol:
|
|
75
|
+
d += 1
|
|
76
|
+
return d
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _mantissa_decimal_floor(incr: float, exp: int) -> int:
|
|
80
|
+
"""Port of ``mantissaDecimalFloor``: ``incr`` rescaled into the
|
|
81
|
+
mantissa's own units (divided by the value's own ``10**exp``) before
|
|
82
|
+
flooring -- used by the sci/eng formatters below."""
|
|
83
|
+
return _decimals_for_increment(incr / _pow10(exp)) if incr > 0 else 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _splits_increment(locs: Any) -> float:
|
|
87
|
+
"""Smallest positive gap between the axis's CURRENT major tick
|
|
88
|
+
locations -- port of the frontend's ``splitsIncrement``, minus its
|
|
89
|
+
uPlot-specific ``foundIncr`` fallback (matplotlib's Locator/Formatter
|
|
90
|
+
split has no equivalent second value fed to the formatter). A
|
|
91
|
+
degenerate axis (fewer than 2 finite ticks) returns 0, which floors
|
|
92
|
+
nothing (``max(digits, 0) == digits``) -- the same degenerate-range
|
|
93
|
+
behaviour the frontend documents for ``decimalsForIncrement``."""
|
|
94
|
+
vals = sorted(v for v in locs if math.isfinite(v))
|
|
95
|
+
incr = math.inf
|
|
96
|
+
for a, b in zip(vals, vals[1:], strict=False):
|
|
97
|
+
gap = b - a
|
|
98
|
+
if 0 < gap < incr:
|
|
99
|
+
incr = gap
|
|
100
|
+
return incr if math.isfinite(incr) else 0.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _strip_neg_zero(formatted: str) -> str:
|
|
104
|
+
"""Port of ``stripNegZero``: a legitimately non-zero split (e.g.
|
|
105
|
+
``-0.00003``) can still format as "-0"/"-0.00"/"-0.00e+0" once rounded to
|
|
106
|
+
the tick's display precision -- never meaningful data (MAIN #20, the
|
|
107
|
+
owner's screenshot showed a bare "-0" tick on a dense M-H moment axis)."""
|
|
108
|
+
if not formatted.startswith("-"):
|
|
109
|
+
return formatted
|
|
110
|
+
bare = formatted[1:]
|
|
111
|
+
try:
|
|
112
|
+
return bare if float(bare) == 0 else formatted
|
|
113
|
+
except ValueError:
|
|
114
|
+
return formatted
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _to_exponential(v: float, d: int) -> str:
|
|
118
|
+
"""``v.toExponential(d)`` equivalent, in the frontend's plain (no rich
|
|
119
|
+
``x10^n`` markup) shape: ``1.20e-3`` / ``1.20e+3`` -- NOT Python's own
|
|
120
|
+
zero-padded ``%e`` shape (``1.20e-03``). Python's ``%e`` formatting is
|
|
121
|
+
itself correctly rounded (it normalizes a mantissa that rounds up to 10
|
|
122
|
+
into the next exponent automatically, the same guarantee JS's
|
|
123
|
+
``Number.prototype.toExponential`` makes), so this only reformats its
|
|
124
|
+
exponent -- no zero-padding, explicit sign -- rather than re-deriving the
|
|
125
|
+
mantissa by hand."""
|
|
126
|
+
mantissa, exp_str = f"{v:.{d}e}".split("e")
|
|
127
|
+
exp = int(exp_str)
|
|
128
|
+
return f"{mantissa}e{'+' if exp >= 0 else '-'}{abs(exp)}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _format_fixed(v: float, digits: int, incr: float) -> str:
|
|
132
|
+
d = max(digits, _decimals_for_increment(incr))
|
|
133
|
+
return _strip_neg_zero(f"{v:.{min(20, d)}f}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _format_sci(v: float, digits: int, incr: float) -> str:
|
|
137
|
+
exp = 0 if v == 0 else math.floor(math.log10(abs(v)))
|
|
138
|
+
d = max(digits, _mantissa_decimal_floor(incr, exp))
|
|
139
|
+
return _strip_neg_zero(_to_exponential(v, min(20, d)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _format_eng(v: float, digits: int, incr: float) -> str:
|
|
143
|
+
"""Engineering notation: mantissa in [1, 1000), exponent a multiple of 3
|
|
144
|
+
(e.g. ``1.2e-3``, ``12.3e-6``) -- port of the frontend's ``formatEng``.
|
|
145
|
+
``v == 0`` has no meaningful exponent, so it renders bare "0". A mantissa
|
|
146
|
+
that rounds up to >= 1000 bumps the exponent by 3 and re-divides."""
|
|
147
|
+
if v == 0:
|
|
148
|
+
return "0"
|
|
149
|
+
sign = "-" if v < 0 else ""
|
|
150
|
+
av = abs(v)
|
|
151
|
+
exp = math.floor(math.floor(math.log10(av)) / 3) * 3
|
|
152
|
+
d = min(20, max(digits, _mantissa_decimal_floor(incr, exp)))
|
|
153
|
+
mantissa = av / _pow10(exp)
|
|
154
|
+
m_str = f"{mantissa:.{d}f}"
|
|
155
|
+
if float(m_str) >= 1000:
|
|
156
|
+
exp += 3
|
|
157
|
+
mantissa = av / _pow10(exp)
|
|
158
|
+
m_str = f"{mantissa:.{d}f}"
|
|
159
|
+
exp_str = f"+{exp}" if exp >= 0 else str(exp)
|
|
160
|
+
return _strip_neg_zero(f"{sign}{m_str}e{exp_str}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _AxisTickFormatter(Formatter):
|
|
164
|
+
"""A ``matplotlib.ticker.Formatter`` for one non-``auto`` ``AxisFormat``
|
|
165
|
+
mode. See the module doc for why this is a ``Formatter`` subclass
|
|
166
|
+
(reading ``self.axis`` lazily) rather than a ``FuncFormatter`` closure."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, mode: str, digits: float) -> None:
|
|
169
|
+
self.mode = mode
|
|
170
|
+
self.digits = max(0, min(20, _js_round(digits)))
|
|
171
|
+
|
|
172
|
+
def __call__(self, x: float, pos: int | None = None) -> str:
|
|
173
|
+
# `self.axis` is typed as a union of matplotlib's real `Axis` and two
|
|
174
|
+
# internal placeholder types (`_DummyAxis`/`_AxisWrapper`) that don't
|
|
175
|
+
# declare `get_majorticklocs` -- getattr-with-default sidesteps the
|
|
176
|
+
# union-attr mismatch; a placeholder axis (never seen in practice,
|
|
177
|
+
# only used by matplotlib internals for detached artists) just skips
|
|
178
|
+
# the increment floor, same as the "no axis attached yet" case.
|
|
179
|
+
get_locs = getattr(self.axis, "get_majorticklocs", None)
|
|
180
|
+
locs = get_locs() if callable(get_locs) else ()
|
|
181
|
+
incr = _splits_increment(locs)
|
|
182
|
+
if self.mode == "sci":
|
|
183
|
+
return _format_sci(x, self.digits, incr)
|
|
184
|
+
if self.mode == "eng":
|
|
185
|
+
return _format_eng(x, self.digits, incr)
|
|
186
|
+
return _format_fixed(x, self.digits, incr)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def axis_tick_formatter(fmt: Mapping[str, Any] | None) -> Formatter | None:
|
|
190
|
+
"""Build a matplotlib tick ``Formatter`` from an ``AxisFormat``-shaped
|
|
191
|
+
mapping (``{"mode": ..., "digits": ...}``). ``None``/an ``"auto"`` mode
|
|
192
|
+
returns ``None`` -- matplotlib's own default formatter stays untouched,
|
|
193
|
+
the same "no override" contract ``apply_axis_scale``'s callers rely on
|
|
194
|
+
for ``"linear"``."""
|
|
195
|
+
if not fmt:
|
|
196
|
+
return None
|
|
197
|
+
mode = fmt.get("mode", "auto")
|
|
198
|
+
if mode not in _MODES:
|
|
199
|
+
return None
|
|
200
|
+
digits = fmt.get("digits", 2)
|
|
201
|
+
return _AxisTickFormatter(str(mode), float(digits))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def apply_tick_formats(
|
|
205
|
+
ax: Any, x_fmt: Mapping[str, Any] | None, y_fmt: Mapping[str, Any] | None
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Apply ``x_fmt``/``y_fmt`` (``AxisFormat``-shaped mappings) to ``ax``'s
|
|
208
|
+
x/y major tick formatter, when non-``auto``. The single application
|
|
209
|
+
chokepoint shared by ``figure.draw_series_axes`` (single-figure export +
|
|
210
|
+
figure-page panels) and ``figure_break.render_breaks_impl`` (broken-axis
|
|
211
|
+
panels) -- MAIN #24."""
|
|
212
|
+
xf = axis_tick_formatter(x_fmt)
|
|
213
|
+
if xf is not None:
|
|
214
|
+
ax.xaxis.set_major_formatter(xf)
|
|
215
|
+
yf = axis_tick_formatter(y_fmt)
|
|
216
|
+
if yf is not None:
|
|
217
|
+
ax.yaxis.set_major_formatter(yf)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Initial-parameter estimation for curve fitting. Port of fitting.autoGuess.
|
|
2
|
+
|
|
3
|
+
Pure calc layer. Each model name maps to a deterministic heuristic that derives
|
|
4
|
+
starting parameters from the (x, y) data, beginning from the model's default p0
|
|
5
|
+
and overriding specific entries. Replicated verbatim (including MATLAB quirks).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import ArrayLike
|
|
14
|
+
|
|
15
|
+
from .fit_models import FIT_MODELS
|
|
16
|
+
|
|
17
|
+
__all__ = ["auto_guess"]
|
|
18
|
+
|
|
19
|
+
_EPS = float(np.finfo(float).eps)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _first(mask: np.ndarray) -> int:
|
|
23
|
+
"""First True index, or -1 if none (MATLAB find(...,1))."""
|
|
24
|
+
idx = np.flatnonzero(mask)
|
|
25
|
+
return int(idx[0]) if idx.size else -1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def auto_guess(model_name: str, x: ArrayLike, y: ArrayLike) -> list[float]:
|
|
29
|
+
"""Estimate starting parameters for ``model_name`` from data. Port of autoGuess."""
|
|
30
|
+
if model_name not in FIT_MODELS:
|
|
31
|
+
raise ValueError(f'Model "{model_name}" not found.')
|
|
32
|
+
p0 = [float(v) for v in FIT_MODELS[model_name]["p0"]]
|
|
33
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
34
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
35
|
+
x_range = float(np.ptp(xv))
|
|
36
|
+
y_range = float(np.ptp(yv))
|
|
37
|
+
y_mean = float(np.mean(yv))
|
|
38
|
+
x_mean = float(np.mean(xv))
|
|
39
|
+
n = xv.size
|
|
40
|
+
y_min = float(np.min(yv))
|
|
41
|
+
y_max = float(np.max(yv))
|
|
42
|
+
|
|
43
|
+
if model_name == "Linear":
|
|
44
|
+
p0[0] = (yv[-1] - yv[0]) / max(x_range, _EPS)
|
|
45
|
+
p0[1] = yv[0] - p0[0] * xv[0]
|
|
46
|
+
elif model_name in ("Quadratic", "Cubic", "Poly 4"):
|
|
47
|
+
p0[-2] = (yv[-1] - yv[0]) / max(x_range, _EPS)
|
|
48
|
+
p0[-1] = y_mean
|
|
49
|
+
elif model_name == "Exponential Decay":
|
|
50
|
+
p0[0], p0[1], p0[2] = y_range, x_range / 3, y_min
|
|
51
|
+
y_norm = (yv - y_min) / max(y_range, _EPS)
|
|
52
|
+
e_idx = _first(y_norm <= math.exp(-1))
|
|
53
|
+
if e_idx >= 0:
|
|
54
|
+
p0[1] = abs(xv[e_idx] - xv[0])
|
|
55
|
+
elif model_name == "Stretched Exponential":
|
|
56
|
+
p0[0], p0[1], p0[2], p0[3] = y_range, x_range / 3, 0.7, y_min
|
|
57
|
+
elif model_name == "Bi-exponential Decay":
|
|
58
|
+
p0[0], p0[1], p0[2], p0[3], p0[4] = (
|
|
59
|
+
y_range * 0.6, x_range / 5, y_range * 0.4, x_range / 1.5, y_min,
|
|
60
|
+
)
|
|
61
|
+
elif model_name == "Exponential Growth":
|
|
62
|
+
p0[0], p0[1], p0[2] = yv[0], x_range / 3, y_min
|
|
63
|
+
elif model_name == "Saturation Growth":
|
|
64
|
+
p0[0], p0[1], p0[2] = y_range, x_range / 3, y_min
|
|
65
|
+
elif model_name in ("Gaussian", "Lorentzian", "Pseudo-Voigt"):
|
|
66
|
+
pk = int(np.argmax(yv))
|
|
67
|
+
p0[0], p0[1] = yv[pk], xv[pk]
|
|
68
|
+
hm_idx = np.flatnonzero(yv >= yv[pk] / 2)
|
|
69
|
+
fwhm = (xv[hm_idx[-1]] - xv[hm_idx[0]]) if hm_idx.size >= 2 else x_range / 10
|
|
70
|
+
p0[2] = fwhm / 2.355 if model_name == "Gaussian" else fwhm / 2
|
|
71
|
+
if model_name == "Pseudo-Voigt":
|
|
72
|
+
p0[3] = 0.5
|
|
73
|
+
elif model_name in ("Power Law", "Allometric"):
|
|
74
|
+
pos = (xv > 0) & (yv > 0)
|
|
75
|
+
if int(pos.sum()) > 2:
|
|
76
|
+
log_x = np.log(xv[pos])
|
|
77
|
+
log_y = np.log(yv[pos])
|
|
78
|
+
slope = (n * np.sum(log_x * log_y) - np.sum(log_x) * np.sum(log_y)) / max(
|
|
79
|
+
n * np.sum(log_x**2) - np.sum(log_x) ** 2, _EPS
|
|
80
|
+
)
|
|
81
|
+
p0[0] = float(np.exp(np.mean(log_y) - slope * np.mean(log_x)))
|
|
82
|
+
p0[1] = float(slope)
|
|
83
|
+
if model_name == "Power Law":
|
|
84
|
+
p0[2] = 0.0
|
|
85
|
+
elif model_name in ("Logistic", "Tanh"):
|
|
86
|
+
p0[0] = y_range
|
|
87
|
+
cross = _first(np.diff(np.sign(yv - y_mean)) != 0)
|
|
88
|
+
p0[2] = xv[cross] if cross >= 0 else x_mean
|
|
89
|
+
if n > 2:
|
|
90
|
+
dy = np.diff(yv) / np.diff(xv)
|
|
91
|
+
steep = int(np.argmax(np.abs(dy)))
|
|
92
|
+
p0[1] = abs(dy[steep]) * 4 / max(y_range, _EPS)
|
|
93
|
+
else:
|
|
94
|
+
p0[1] = 4 / max(x_range, _EPS)
|
|
95
|
+
p0[3] = y_min
|
|
96
|
+
elif model_name == "Langevin":
|
|
97
|
+
p0[0] = float(np.max(np.abs(yv)))
|
|
98
|
+
if n > 2:
|
|
99
|
+
slope0 = abs(yv[1] - yv[0]) / max(abs(xv[1] - xv[0]), _EPS)
|
|
100
|
+
p0[1] = p0[0] / max(3 * slope0, _EPS)
|
|
101
|
+
elif model_name == "Curie-Weiss":
|
|
102
|
+
if np.all(yv > 0):
|
|
103
|
+
inv_y = 1.0 / yv
|
|
104
|
+
slope = (inv_y[-1] - inv_y[0]) / max(x_range, _EPS)
|
|
105
|
+
p0[1] = xv[0] - inv_y[0] / max(slope, _EPS)
|
|
106
|
+
p0[0] = float(np.mean(yv * (xv - p0[1])))
|
|
107
|
+
elif model_name == "Bloch T^3/2":
|
|
108
|
+
p0[0] = y_max
|
|
109
|
+
p0[1] = (1 - y_min / y_max) / max(xv[-1], _EPS) ** 1.5
|
|
110
|
+
elif model_name == "Arrhenius":
|
|
111
|
+
p0[0] = y_max
|
|
112
|
+
pos = (yv > 0) & (xv > 0)
|
|
113
|
+
if int(pos.sum()) > 2:
|
|
114
|
+
inv_x = 1.0 / xv[pos]
|
|
115
|
+
ln_y = np.log(yv[pos])
|
|
116
|
+
slope = (ln_y[-1] - ln_y[0]) / (inv_x[-1] - inv_x[0])
|
|
117
|
+
p0[1] = abs(float(slope))
|
|
118
|
+
elif model_name == "Langmuir":
|
|
119
|
+
p0[0] = y_max
|
|
120
|
+
k_idx = _first(yv >= y_max / 2)
|
|
121
|
+
if k_idx >= 0:
|
|
122
|
+
p0[1] = abs(xv[k_idx])
|
|
123
|
+
elif model_name == "Logarithmic":
|
|
124
|
+
pos = xv > 0
|
|
125
|
+
if int(pos.sum()) > 1:
|
|
126
|
+
log_x = np.log(xv[pos])
|
|
127
|
+
p0[0] = y_range / max(float(np.ptp(log_x)), _EPS)
|
|
128
|
+
p0[1] = y_mean - p0[0] * float(np.mean(log_x))
|
|
129
|
+
elif model_name == "Square Root":
|
|
130
|
+
pos = xv >= 0
|
|
131
|
+
if int(pos.sum()) > 1:
|
|
132
|
+
sqrt_x = np.sqrt(xv[pos])
|
|
133
|
+
p0[0] = y_range / max(float(np.ptp(sqrt_x)), _EPS)
|
|
134
|
+
p0[1] = y_mean - p0[0] * float(np.mean(sqrt_x))
|
|
135
|
+
elif model_name == "Brillouin":
|
|
136
|
+
p0[0], p0[1], p0[2], p0[3] = float(np.max(np.abs(yv))), 0.5, 2, 300
|
|
137
|
+
elif model_name == "Stoner-Wohlfarth":
|
|
138
|
+
p0[0] = float(np.max(np.abs(yv)))
|
|
139
|
+
sign_change = _first(np.diff(np.sign(yv)) != 0)
|
|
140
|
+
p0[1] = abs(xv[sign_change]) if sign_change >= 0 else x_range / 4
|
|
141
|
+
p0[2] = x_range / 2
|
|
142
|
+
elif model_name == "VFT":
|
|
143
|
+
p0[0] = float(np.min(yv[yv > 0]))
|
|
144
|
+
p0[1], p0[2] = 0.05, 0
|
|
145
|
+
elif model_name == "Debye":
|
|
146
|
+
low = xv < xv[-1] * 0.1
|
|
147
|
+
p0[0] = float(np.mean(yv[low] / np.maximum(xv[low], _EPS))) if int(low.sum()) > 1 else 0.0
|
|
148
|
+
p0[1], p0[2] = float(np.max(xv)) * 2, 1
|
|
149
|
+
elif model_name == "Einstein":
|
|
150
|
+
p0[0], p0[1], p0[2] = 0.0, float(np.max(xv)) * 0.5, 1
|
|
151
|
+
elif model_name == "Debye+Einstein":
|
|
152
|
+
low = xv < xv[-1] * 0.1
|
|
153
|
+
p0[0] = float(np.mean(yv[low] / np.maximum(xv[low], _EPS))) if int(low.sum()) > 1 else 0.0
|
|
154
|
+
p0[1], p0[2], p0[3], p0[4] = float(np.max(xv)) * 2, float(np.max(xv)) * 0.5, 0.5, 1
|
|
155
|
+
|
|
156
|
+
return p0
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Bootstrap confidence intervals + MCMC posteriors for curve fits.
|
|
2
|
+
|
|
3
|
+
ORIGIN_GAP_PLAN #29 — honest uncertainty beyond the asymptotic covariance
|
|
4
|
+
Origin reports. ``bootstrap_fit`` resamples (residuals or data pairs) and
|
|
5
|
+
refits; ``fit_posterior`` bridges a fit into :func:`calc.mcmc.mcmc_sample`
|
|
6
|
+
(Gaussian likelihood at the fit's RMSE noise scale, flat priors within the
|
|
7
|
+
bounds). Both are deterministic for a given ``seed``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import ArrayLike, NDArray
|
|
17
|
+
|
|
18
|
+
from quantized.calc.fitting import curve_fit
|
|
19
|
+
from quantized.calc.mcmc import mcmc_sample
|
|
20
|
+
|
|
21
|
+
__all__ = ["bootstrap_fit", "fit_posterior"]
|
|
22
|
+
|
|
23
|
+
ModelFn = Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def bootstrap_fit(
|
|
27
|
+
x: ArrayLike,
|
|
28
|
+
y: ArrayLike,
|
|
29
|
+
model_fcn: ModelFn,
|
|
30
|
+
p0: Sequence[float],
|
|
31
|
+
*,
|
|
32
|
+
n_boot: int = 500,
|
|
33
|
+
method: str = "residual",
|
|
34
|
+
seed: int = 0,
|
|
35
|
+
alpha: float = 0.05,
|
|
36
|
+
lower: Sequence[float] | None = None,
|
|
37
|
+
upper: Sequence[float] | None = None,
|
|
38
|
+
return_samples: bool = False,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
"""Bootstrap parameter uncertainty for a :func:`calc.fitting.curve_fit`.
|
|
41
|
+
|
|
42
|
+
``method='residual'`` resamples the base fit's residuals onto its fitted
|
|
43
|
+
curve (fixed design — right when x is set by the instrument);
|
|
44
|
+
``'pairs'`` resamples (x, y) rows (robust to heteroscedasticity).
|
|
45
|
+
Each replicate refits starting from the base parameters; failed refits
|
|
46
|
+
are dropped and counted. Returns the base fit plus per-parameter
|
|
47
|
+
bootstrap SEs and percentile (1-alpha) CIs.
|
|
48
|
+
|
|
49
|
+
``return_samples=True`` additionally returns the full bootstrap replicate
|
|
50
|
+
matrix as ``boot_samples`` (``[n_kept x P]``) -- opt-in because it can be
|
|
51
|
+
large (``n_boot`` rows) and the summary stats above are usually enough on
|
|
52
|
+
their own; :func:`calc.figure_corner.render_corner_figure` is the main
|
|
53
|
+
consumer, mirroring how :func:`fit_posterior` already returns its chain.
|
|
54
|
+
"""
|
|
55
|
+
if method not in ("residual", "pairs"):
|
|
56
|
+
raise ValueError(f'method must be "residual" or "pairs", got "{method}"')
|
|
57
|
+
if n_boot < 20:
|
|
58
|
+
raise ValueError("n_boot must be >= 20 for meaningful percentiles")
|
|
59
|
+
if not 0 < alpha < 1:
|
|
60
|
+
raise ValueError("alpha must be in (0, 1)")
|
|
61
|
+
|
|
62
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
63
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
64
|
+
base = curve_fit(xv, yv, model_fcn, p0, lower=lower, upper=upper)
|
|
65
|
+
params = np.asarray(base["params"], dtype=float)
|
|
66
|
+
y_fit = np.asarray(base["yFit"], dtype=float)
|
|
67
|
+
resid = yv - y_fit
|
|
68
|
+
n = xv.size
|
|
69
|
+
|
|
70
|
+
rng = np.random.default_rng(seed)
|
|
71
|
+
boots: list[NDArray[np.float64]] = []
|
|
72
|
+
n_failed = 0
|
|
73
|
+
for _ in range(n_boot):
|
|
74
|
+
if method == "residual":
|
|
75
|
+
xb = xv
|
|
76
|
+
yb = y_fit + rng.choice(resid, size=n, replace=True)
|
|
77
|
+
else:
|
|
78
|
+
idx = rng.integers(0, n, size=n)
|
|
79
|
+
xb, yb = xv[idx], yv[idx]
|
|
80
|
+
try:
|
|
81
|
+
fit = curve_fit(
|
|
82
|
+
xb, yb, model_fcn, params.tolist(),
|
|
83
|
+
lower=lower, upper=upper, calc_errors=False,
|
|
84
|
+
)
|
|
85
|
+
boots.append(np.asarray(fit["params"], dtype=float))
|
|
86
|
+
except (ValueError, FloatingPointError):
|
|
87
|
+
n_failed += 1
|
|
88
|
+
if len(boots) < n_boot // 2:
|
|
89
|
+
raise ValueError(f"bootstrap unstable: {n_failed}/{n_boot} refits failed")
|
|
90
|
+
|
|
91
|
+
bmat = np.vstack(boots)
|
|
92
|
+
lo_q, hi_q = 100.0 * alpha / 2.0, 100.0 * (1.0 - alpha / 2.0)
|
|
93
|
+
out: dict[str, Any] = {
|
|
94
|
+
"params": params,
|
|
95
|
+
"boot_mean": np.asarray(bmat.mean(axis=0), dtype=float),
|
|
96
|
+
"boot_se": np.asarray(bmat.std(axis=0, ddof=1), dtype=float),
|
|
97
|
+
"ciLow": np.asarray(np.percentile(bmat, lo_q, axis=0), dtype=float),
|
|
98
|
+
"ciHigh": np.asarray(np.percentile(bmat, hi_q, axis=0), dtype=float),
|
|
99
|
+
"asymptotic_se": np.asarray(base.get("errors", np.full(params.size, np.nan)),
|
|
100
|
+
dtype=float),
|
|
101
|
+
"R2": base["R2"],
|
|
102
|
+
"n_boot": len(boots),
|
|
103
|
+
"n_failed": n_failed,
|
|
104
|
+
"method": method,
|
|
105
|
+
"alpha": alpha,
|
|
106
|
+
"seed": seed,
|
|
107
|
+
}
|
|
108
|
+
if return_samples:
|
|
109
|
+
out["boot_samples"] = bmat
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fit_posterior(
|
|
114
|
+
x: ArrayLike,
|
|
115
|
+
y: ArrayLike,
|
|
116
|
+
model_fcn: ModelFn,
|
|
117
|
+
p0: Sequence[float],
|
|
118
|
+
*,
|
|
119
|
+
num_steps: int = 10000,
|
|
120
|
+
burn_in: int = 1000,
|
|
121
|
+
step_scale: float = 0.02,
|
|
122
|
+
seed: int = 0,
|
|
123
|
+
lower: Sequence[float] | None = None,
|
|
124
|
+
upper: Sequence[float] | None = None,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
"""MCMC posterior for a curve fit's parameters (Gaussian likelihood).
|
|
127
|
+
|
|
128
|
+
Fits first (the chain starts at the optimum), takes the fit RMSE as the
|
|
129
|
+
fixed noise scale, applies flat priors inside [lower, upper], and samples
|
|
130
|
+
with :func:`calc.mcmc.mcmc_sample` (random-walk Metropolis; step size =
|
|
131
|
+
``step_scale`` x |param| floor 1e-6). Returns the mcmc_sample result plus
|
|
132
|
+
the base fit params and per-parameter posterior medians / 68% intervals.
|
|
133
|
+
"""
|
|
134
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
135
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
136
|
+
base = curve_fit(xv, yv, model_fcn, p0, lower=lower, upper=upper)
|
|
137
|
+
params = np.asarray(base["params"], dtype=float)
|
|
138
|
+
sigma = max(float(base["RMSE"]), 1e-300)
|
|
139
|
+
m = params.size
|
|
140
|
+
lb = np.asarray(lower if lower is not None else [-np.inf] * m, dtype=float)
|
|
141
|
+
ub = np.asarray(upper if upper is not None else [np.inf] * m, dtype=float)
|
|
142
|
+
|
|
143
|
+
def log_posterior(p: NDArray[np.float64]) -> float:
|
|
144
|
+
pv = np.asarray(p, dtype=float).ravel()
|
|
145
|
+
if np.any(pv < lb) or np.any(pv > ub):
|
|
146
|
+
return -np.inf
|
|
147
|
+
r = yv - model_fcn(xv, pv)
|
|
148
|
+
return float(-0.5 * np.sum((r / sigma) ** 2))
|
|
149
|
+
|
|
150
|
+
step = np.maximum(step_scale * np.abs(params), 1e-6)
|
|
151
|
+
out = mcmc_sample(
|
|
152
|
+
log_posterior, params.tolist(),
|
|
153
|
+
num_steps=num_steps, burn_in=burn_in, step_size=float(np.mean(step)), seed=seed,
|
|
154
|
+
)
|
|
155
|
+
samples = np.asarray(out["samples"], dtype=float).reshape(-1, m)
|
|
156
|
+
return {
|
|
157
|
+
**out,
|
|
158
|
+
"params": params,
|
|
159
|
+
"noise_sigma": sigma,
|
|
160
|
+
"posterior_median": np.asarray(np.median(samples, axis=0), dtype=float),
|
|
161
|
+
"ci68Low": np.asarray(np.percentile(samples, 16.0, axis=0), dtype=float),
|
|
162
|
+
"ci68High": np.asarray(np.percentile(samples, 84.0, axis=0), dtype=float),
|
|
163
|
+
}
|