quantized-lab 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,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
+ }