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,214 @@
1
+ """Statistical-plot primitives: box/whisker, violin KDE, Q-Q, histogram.
2
+
3
+ ORIGIN_GAP_PLAN #16 (calc math). Pure numpy/scipy — the same numbers drive
4
+ the interactive (uPlot / Canvas) stage and the matplotlib publication export,
5
+ so the two are guaranteed identical.
6
+
7
+ Design choices pinned to standard references so the tests can use exact
8
+ oracles:
9
+
10
+ - ``box_stats`` reproduces ``matplotlib.cbook.boxplot_stats`` (linear-
11
+ interpolation quartiles + Tukey 1.5*IQR whisker rule + fliers), so an
12
+ interactive box and an exported box show the same whiskers and outliers.
13
+ - ``histogram`` delegates bin selection to ``numpy.histogram``'s documented
14
+ rules (``fd`` / ``sturges`` / ``scott`` / ``rice`` / ``sqrt`` / ``auto``).
15
+ - ``qq_plot`` uses Blom plotting positions; its fitted reference line is
16
+ cross-checked against ``scipy.stats.probplot``.
17
+ - ``violin_kde`` wraps ``scipy.stats.gaussian_kde`` (Scott / Silverman).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+ from numpy.typing import NDArray
26
+ from scipy import stats as sps
27
+
28
+ __all__ = [
29
+ "box_stats",
30
+ "grouped_box_stats",
31
+ "histogram",
32
+ "qq_plot",
33
+ "violin_kde",
34
+ ]
35
+
36
+
37
+ def _finite(x: NDArray[np.float64] | list[float]) -> NDArray[np.float64]:
38
+ v = np.asarray(x, dtype=float).ravel()
39
+ return np.asarray(v[np.isfinite(v)], dtype=float)
40
+
41
+
42
+ def box_stats(
43
+ data: NDArray[np.float64] | list[float],
44
+ *,
45
+ whis: float | str = 1.5,
46
+ ) -> dict[str, Any]:
47
+ """Box-and-whisker statistics matching ``matplotlib.cbook.boxplot_stats``.
48
+
49
+ Quartiles use linear interpolation (numpy default / matplotlib boxplot).
50
+ ``whis`` is either the IQR multiplier for Tukey whiskers (default 1.5 —
51
+ whiskers reach the most extreme datum within ``Q1 - whis*IQR`` ..
52
+ ``Q3 + whis*IQR``, points beyond are fliers/outliers) or the string
53
+ ``"range"`` for min/max whiskers (no outliers).
54
+ """
55
+ v = _finite(data)
56
+ if v.size == 0:
57
+ raise ValueError("box_stats needs at least one finite value")
58
+ q1, med, q3 = (float(x) for x in np.percentile(v, [25, 50, 75]))
59
+ iqr = q3 - q1
60
+ if whis == "range":
61
+ whislo, whishi = float(v.min()), float(v.max())
62
+ else:
63
+ k = float(whis)
64
+ lo_fence, hi_fence = q1 - k * iqr, q3 + k * iqr
65
+ below, above = v[v >= lo_fence], v[v <= hi_fence]
66
+ whislo = float(below.min()) if below.size else q1
67
+ whishi = float(above.max()) if above.size else q3
68
+ fliers = v[(v < whislo) | (v > whishi)]
69
+ return {
70
+ "q1": q1, "median": med, "q3": q3, "iqr": iqr,
71
+ "whislo": whislo, "whishi": whishi,
72
+ "mean": float(v.mean()),
73
+ "n": int(v.size),
74
+ "fliers": [float(x) for x in np.sort(fliers)],
75
+ "whis": whis,
76
+ }
77
+
78
+
79
+ def grouped_box_stats(
80
+ groups: list[NDArray[np.float64]] | list[list[float]],
81
+ *,
82
+ labels: list[str] | None = None,
83
+ whis: float | str = 1.5,
84
+ ) -> dict[str, Any]:
85
+ """``box_stats`` for each group; the payload a grouped box plot consumes."""
86
+ if labels is not None and len(labels) != len(groups):
87
+ raise ValueError("labels length must match the number of groups")
88
+ boxes = []
89
+ for i, g in enumerate(groups):
90
+ stats = box_stats(np.asarray(g, dtype=float), whis=whis)
91
+ stats["label"] = labels[i] if labels else f"group {i + 1}"
92
+ boxes.append(stats)
93
+ return {"boxes": boxes, "n_groups": len(boxes)}
94
+
95
+
96
+ def violin_kde(
97
+ data: NDArray[np.float64] | list[float],
98
+ *,
99
+ bw_method: str | float = "scott",
100
+ n_points: int = 128,
101
+ cut: float = 2.0,
102
+ ) -> dict[str, Any]:
103
+ """Gaussian-KDE density for a violin plot.
104
+
105
+ Evaluates ``scipy.stats.gaussian_kde`` on a grid spanning the data
106
+ extended by ``cut`` bandwidths on each side (seaborn's convention). The
107
+ returned ``density`` integrates to ~1 over the grid; the caller mirrors
108
+ it about the category axis to draw the violin.
109
+ """
110
+ v = _finite(data)
111
+ if v.size < 2:
112
+ raise ValueError("violin_kde needs at least 2 finite values")
113
+ if float(np.ptp(v)) == 0.0:
114
+ raise ValueError("violin_kde needs non-constant data")
115
+ kde = sps.gaussian_kde(v, bw_method=bw_method)
116
+ bw = float(np.sqrt(kde.covariance[0, 0])) # effective bandwidth (std units)
117
+ lo, hi = float(v.min()) - cut * bw, float(v.max()) + cut * bw
118
+ grid = np.linspace(lo, hi, int(n_points))
119
+ density = np.asarray(kde(grid), dtype=float)
120
+ return {
121
+ "x": [float(x) for x in grid],
122
+ "density": [float(d) for d in density],
123
+ "bandwidth": bw,
124
+ "quartiles": [float(x) for x in np.percentile(v, [25, 50, 75])],
125
+ "n": int(v.size),
126
+ }
127
+
128
+
129
+ def _blom_positions(n: int) -> NDArray[np.float64]:
130
+ """Blom plotting positions (i - 3/8)/(n + 1/4): the normal-QQ standard."""
131
+ i = np.arange(1, n + 1, dtype=float)
132
+ return np.asarray((i - 0.375) / (n + 0.25), dtype=float)
133
+
134
+
135
+ def qq_plot(
136
+ data: NDArray[np.float64] | list[float],
137
+ *,
138
+ dist: str = "norm",
139
+ ) -> dict[str, Any]:
140
+ """Quantile-quantile / probability-plot coordinates against ``dist``.
141
+
142
+ Sorts the sample (``sample_quantiles``) and pairs it with the theoretical
143
+ quantiles (``theoretical_quantiles``) of ``dist`` at Blom plotting
144
+ positions. Fits a reference line by least squares; for a perfect fit the
145
+ points fall on it. ``dist`` is any location-scale ``scipy.stats``
146
+ continuous distribution name (``norm``, ``logistic``, ``laplace``, ...).
147
+ """
148
+ v = _finite(data)
149
+ if v.size < 3:
150
+ raise ValueError("qq_plot needs at least 3 finite values")
151
+ rv = getattr(sps, dist, None)
152
+ if not isinstance(rv, sps.rv_continuous):
153
+ # A name like 'kstest' resolves to a plain function, not a distribution;
154
+ # it would otherwise blow up on rv.ppf(...) with AttributeError -> 500.
155
+ raise ValueError(f"unknown distribution '{dist}'")
156
+ ordered = np.sort(v)
157
+ theo = np.asarray(rv.ppf(_blom_positions(v.size)), dtype=float)
158
+ slope, intercept, r, _, _ = sps.linregress(theo, ordered)
159
+ return {
160
+ "theoretical_quantiles": [float(x) for x in theo],
161
+ "sample_quantiles": [float(x) for x in ordered],
162
+ "slope": float(slope),
163
+ "intercept": float(intercept),
164
+ "r_squared": float(r) ** 2,
165
+ "dist": dist,
166
+ "n": int(v.size),
167
+ }
168
+
169
+
170
+ def histogram(
171
+ data: NDArray[np.float64] | list[float],
172
+ *,
173
+ bins: str | int = "fd",
174
+ density: bool = False,
175
+ fit: str | None = None,
176
+ ) -> dict[str, Any]:
177
+ """Histogram with a data-driven bin rule and an optional fit overlay.
178
+
179
+ ``bins`` is a numpy bin rule (``"fd"``, ``"sturges"``, ``"scott"``,
180
+ ``"rice"``, ``"sqrt"``, ``"auto"``) or an explicit integer count.
181
+ ``fit="norm"`` (or another location-scale ``scipy.stats`` name) adds a
182
+ fitted PDF sampled on a fine grid over the data range, for the
183
+ distribution-fit overlay.
184
+ """
185
+ v = _finite(data)
186
+ if v.size < 2:
187
+ raise ValueError("histogram needs at least 2 finite values")
188
+ counts, edges = np.histogram(v, bins=bins, density=density)
189
+ centers = 0.5 * (edges[:-1] + edges[1:])
190
+ out: dict[str, Any] = {
191
+ "counts": [float(c) for c in counts],
192
+ "edges": [float(e) for e in edges],
193
+ "centers": [float(c) for c in centers],
194
+ "n_bins": int(counts.size),
195
+ "n": int(v.size),
196
+ "density": density,
197
+ "bins": bins,
198
+ }
199
+ if fit is not None:
200
+ rv = getattr(sps, fit, None)
201
+ if not isinstance(rv, sps.rv_continuous):
202
+ # A name like 'kstest' resolves to a plain function, not a
203
+ # distribution; it would blow up on rv.fit(...) with AttributeError.
204
+ raise ValueError(f"unknown distribution '{fit}'")
205
+ params = rv.fit(v)
206
+ grid = np.linspace(float(v.min()), float(v.max()), 256)
207
+ pdf = np.asarray(rv.pdf(grid, *params), dtype=float)
208
+ out["fit"] = {
209
+ "dist": fit,
210
+ "params": [float(p) for p in params],
211
+ "x": [float(x) for x in grid],
212
+ "pdf": [float(p) for p in pdf],
213
+ }
214
+ return out
@@ -0,0 +1,399 @@
1
+ """Pure statistics utilities (no Statistics Toolbox). Ports of MATLAB +utilities.
2
+
3
+ All use MATLAB conventions: sample std/var (ddof=1), bias-corrected sample
4
+ skewness/kurtosis (excess), quartiles by linear interpolation on 1..n.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+ from scipy.special import beta as _beta
15
+ from scipy.special import betainc
16
+
17
+ __all__ = ["anova1", "descriptive_stats", "lin_regress", "pca_analysis", "t_test"]
18
+
19
+ _EPS = float(np.finfo(float).eps)
20
+
21
+
22
+ def _t_cdf(t: NDArray[np.float64], nu: float) -> NDArray[np.float64]:
23
+ """Student-t CDF via regularized incomplete beta (matches MATLAB tcdf_local)."""
24
+ tv = np.asarray(t, dtype=float)
25
+ x = nu / (nu + tv**2)
26
+ p = 1.0 - 0.5 * betainc(nu / 2.0, 0.5, x)
27
+ return np.asarray(np.where(tv < 0, 1.0 - p, p), dtype=float)
28
+
29
+
30
+ def _f_cdf(f: float, d1: float, d2: float) -> float:
31
+ """F-distribution CDF via regularized incomplete beta (matches fcdf_builtin)."""
32
+ x = d1 * f / (d1 * f + d2)
33
+ return float(betainc(d1 / 2.0, d2 / 2.0, x))
34
+
35
+
36
+ def _norminv_approx(p: float) -> float:
37
+ """Rational approx to the standard-normal quantile (Abramowitz-Stegun 26.2.23)."""
38
+ t = math.sqrt(-2.0 * math.log(min(p, 1.0 - p)))
39
+ c0, c1, c2 = 2.515517, 0.802853, 0.010328
40
+ d1, d2, d3 = 1.432788, 0.189269, 0.001308
41
+ z = t - (c0 + c1 * t + c2 * t**2) / (1.0 + d1 * t + d2 * t**2 + d3 * t**3)
42
+ return -z if p < 0.5 else z
43
+
44
+
45
+ def _t_inv(p: float, nu: float) -> float:
46
+ """Student-t quantile: normal-approx seed + Newton refinement (matches tinv_builtin)."""
47
+ t = _norminv_approx(p)
48
+ for _ in range(10):
49
+ cp = float(_t_cdf(np.asarray(t, dtype=float), nu))
50
+ pdf = (1.0 + t**2 / nu) ** (-(nu + 1.0) / 2.0) / (math.sqrt(nu) * _beta(nu / 2.0, 0.5))
51
+ t = t - (cp - p) / max(pdf, _EPS)
52
+ return t
53
+
54
+
55
+ def descriptive_stats(x: NDArray[np.float64]) -> dict[str, Any]:
56
+ """Descriptive statistics of a 1-D array (NaNs dropped). Port of descriptiveStats."""
57
+ arr = np.asarray(x, dtype=float).ravel()
58
+ arr = arr[~np.isnan(arr)]
59
+ n = int(arr.size)
60
+ nan = float("nan")
61
+ if n == 0:
62
+ keys = ["mean", "median", "std", "sem", "var", "min", "max", "range",
63
+ "q1", "q3", "iqr", "skewness", "kurtosis"]
64
+ return {"N": 0, **{k: nan for k in keys}}
65
+
66
+ mean = float(np.mean(arr))
67
+ std = float(np.std(arr, ddof=1)) if n > 1 else nan
68
+ s: dict[str, Any] = {
69
+ "N": n,
70
+ "mean": mean,
71
+ "median": float(np.median(arr)),
72
+ "std": std,
73
+ "sem": (std / math.sqrt(max(n, 1))) if not math.isnan(std) else nan,
74
+ "var": float(np.var(arr, ddof=1)) if n > 1 else nan,
75
+ "min": float(np.min(arr)),
76
+ "max": float(np.max(arr)),
77
+ }
78
+ s["range"] = s["max"] - s["min"]
79
+
80
+ if n >= 4:
81
+ xs = np.sort(arr)
82
+ idx = np.arange(1, n + 1, dtype=float)
83
+ s["q1"] = float(np.interp(0.25 * (n + 1), idx, xs))
84
+ s["q3"] = float(np.interp(0.75 * (n + 1), idx, xs))
85
+ s["iqr"] = s["q3"] - s["q1"]
86
+ else:
87
+ s["q1"] = s["q3"] = s["iqr"] = nan
88
+
89
+ if n >= 3 and not math.isnan(std) and std > 0:
90
+ m3 = float(np.mean((arr - mean) ** 3))
91
+ s["skewness"] = m3 / std**3 * (n**2 / ((n - 1) * (n - 2)))
92
+ else:
93
+ s["skewness"] = nan
94
+
95
+ if n >= 4 and not math.isnan(std) and std > 0:
96
+ m4 = float(np.mean((arr - mean) ** 4))
97
+ raw_kurt = m4 / std**4
98
+ s["kurtosis"] = ((n + 1) * raw_kurt - 3 * (n - 1)) * (n - 1) / ((n - 2) * (n - 3))
99
+ else:
100
+ s["kurtosis"] = nan
101
+
102
+ return s
103
+
104
+
105
+ def lin_regress(
106
+ x: NDArray[np.float64],
107
+ y: NDArray[np.float64],
108
+ *,
109
+ order: int = 1,
110
+ alpha: float = 0.05,
111
+ ) -> dict[str, Any]:
112
+ """Polynomial least-squares regression with inference. Port of utilities.linRegress.
113
+
114
+ Returns coefficients (low→high power), standard errors, t-stats and p-values,
115
+ R^2 / adjusted R^2, the F-statistic and its p-value, RMSE, residuals and the
116
+ fitted curve. The MATLAB ``confBand``/``predBand`` function handles are not
117
+ ported (call sites recompute bands directly when needed).
118
+
119
+ p-values use the regularized incomplete beta (``scipy.special.betainc``), which
120
+ matches the MATLAB local ``tcdf``/``fcdf`` implementations exactly.
121
+ """
122
+ if order < 1:
123
+ raise ValueError("order must be a positive integer")
124
+ xv = np.asarray(x, dtype=float).ravel()
125
+ yv = np.asarray(y, dtype=float).ravel()
126
+ if xv.size != yv.size:
127
+ raise ValueError(f"x and y must have the same length (got {xv.size} vs {yv.size})")
128
+ n = xv.size
129
+ k = order + 1
130
+ if n < k + 1:
131
+ raise ValueError(f"need at least {k + 1} points for order-{order} regression")
132
+
133
+ # Vandermonde with increasing powers: columns x^0, x^1, ..., x^order.
134
+ xmat = np.vander(xv, k, increasing=True)
135
+ xtx = xmat.T @ xmat
136
+ try:
137
+ coeffs = np.linalg.solve(xtx, xmat.T @ yv)
138
+ except np.linalg.LinAlgError as exc:
139
+ # Singular normal equations: duplicate x-values, a constant predictor, or
140
+ # collinear columns. MATLAB's backslash returns Inf with a warning; we
141
+ # surface a clean ValueError instead of an opaque LinAlgError (→ HTTP 422).
142
+ raise ValueError(
143
+ f"order-{order} regression is singular — x has too few distinct values "
144
+ f"or is collinear; use a lower order or more varied x"
145
+ ) from exc
146
+ y_fit = xmat @ coeffs
147
+ residuals = yv - y_fit
148
+ df = n - k
149
+
150
+ ss_res = float(np.sum(residuals**2))
151
+ ss_tot = float(np.sum((yv - np.mean(yv)) ** 2))
152
+ ss_reg = ss_tot - ss_res
153
+ r2 = 1.0 - ss_res / max(ss_tot, _EPS)
154
+ # Constant y -> ss_tot = 0; MATLAB yields NaN for adjusted R^2 (0/0), Python
155
+ # float division would raise ZeroDivisionError, so guard explicitly.
156
+ _adj_denom = ss_tot / (n - 1)
157
+ r2_adj = 1.0 - (ss_res / df) / _adj_denom if _adj_denom != 0.0 else float("nan")
158
+ mse = ss_res / df
159
+ rmse = math.sqrt(mse)
160
+ f_stat = (ss_reg / order) / max(mse, _EPS)
161
+ f_pvalue = 1.0 - _f_cdf(f_stat, order, df)
162
+
163
+ cov_b = mse * np.linalg.inv(xtx)
164
+ se = np.sqrt(np.maximum(np.diag(cov_b), 0.0))
165
+ t_stats = coeffs / np.maximum(se, _EPS)
166
+ p_values = 2.0 * (1.0 - _t_cdf(np.abs(t_stats), df))
167
+
168
+ return {
169
+ "coeffs": coeffs,
170
+ "se": se,
171
+ "tStats": t_stats,
172
+ "pValues": p_values,
173
+ "R2": r2,
174
+ "R2adj": r2_adj,
175
+ "fStat": f_stat,
176
+ "fPvalue": f_pvalue,
177
+ "RMSE": rmse,
178
+ "residuals": residuals,
179
+ "yFit": y_fit,
180
+ "N": n,
181
+ "df": df,
182
+ }
183
+
184
+
185
+ def t_test(
186
+ x: NDArray[np.float64],
187
+ y: NDArray[np.float64] | None = None,
188
+ *,
189
+ mu: float = 0.0,
190
+ paired: bool = False,
191
+ alpha: float = 0.05,
192
+ tail: str = "both",
193
+ ) -> dict[str, Any]:
194
+ """Student's t-test (one-sample / paired / Welch two-sample). Port of utilities.tTest.
195
+
196
+ Returns ``testType``, ``meanDiff``, ``tStat``, ``df``, ``pValue``, ``ci``
197
+ (a 2-element CI at level ``alpha``), ``reject`` and ``se``. NaNs are dropped
198
+ per vector. Two-sample uses Welch's unequal-variance df.
199
+ """
200
+ if tail not in ("both", "left", "right"):
201
+ raise ValueError("tail must be both/left/right")
202
+ xv = np.asarray(x, dtype=float).ravel()
203
+ xv = xv[~np.isnan(xv)]
204
+
205
+ if y is None:
206
+ test_type = "one-sample"
207
+ n = xv.size
208
+ if n < 2:
209
+ raise ValueError(f"t_test requires at least 2 finite observations (got {n})")
210
+ s = float(np.std(xv, ddof=1))
211
+ se = s / math.sqrt(n)
212
+ mean_diff = float(np.mean(xv)) - mu
213
+ df = float(n - 1)
214
+ elif paired:
215
+ yv = np.asarray(y, dtype=float).ravel()
216
+ yv = yv[~np.isnan(yv)]
217
+ if xv.size != yv.size:
218
+ raise ValueError("paired test requires equal-length vectors")
219
+ test_type = "paired"
220
+ d = xv - yv
221
+ n = d.size
222
+ if n < 2:
223
+ raise ValueError(f"paired t_test requires at least 2 finite pairs (got {n})")
224
+ s = float(np.std(d, ddof=1))
225
+ se = s / math.sqrt(n)
226
+ mean_diff = float(np.mean(d))
227
+ df = float(n - 1)
228
+ else:
229
+ yv = np.asarray(y, dtype=float).ravel()
230
+ yv = yv[~np.isnan(yv)]
231
+ test_type = "two-sample"
232
+ n1, n2 = xv.size, yv.size
233
+ if n1 < 2 or n2 < 2:
234
+ raise ValueError(
235
+ f"two-sample t_test requires >= 2 observations per group (got {n1}, {n2})"
236
+ )
237
+ s1, s2 = float(np.std(xv, ddof=1)), float(np.std(yv, ddof=1))
238
+ v1, v2 = s1**2 / n1, s2**2 / n2
239
+ se = math.sqrt(v1 + v2)
240
+ mean_diff = float(np.mean(xv)) - float(np.mean(yv))
241
+ df = (v1 + v2) ** 2 / (v1**2 / (n1 - 1) + v2**2 / (n2 - 1))
242
+
243
+ t_stat = mean_diff / se if se != 0 else float("nan")
244
+ if se == 0 or math.isnan(t_stat):
245
+ if abs(mean_diff) < _EPS:
246
+ p_value, t_stat = 1.0, 0.0
247
+ else:
248
+ p_value = 0.0
249
+ elif tail == "both":
250
+ p_value = 2.0 * (1.0 - float(_t_cdf(np.asarray(abs(t_stat)), df)))
251
+ elif tail == "right":
252
+ p_value = 1.0 - float(_t_cdf(np.asarray(t_stat), df))
253
+ else: # left
254
+ p_value = float(_t_cdf(np.asarray(t_stat), df))
255
+
256
+ t_crit = _t_inv(1.0 - alpha / 2.0, df)
257
+ ci = np.array([mean_diff - t_crit * se, mean_diff + t_crit * se])
258
+ return {
259
+ "testType": test_type,
260
+ "meanDiff": mean_diff,
261
+ "tStat": t_stat,
262
+ "df": df,
263
+ "pValue": p_value,
264
+ "ci": ci,
265
+ "reject": bool(p_value < alpha),
266
+ "se": se,
267
+ }
268
+
269
+
270
+ def anova1(
271
+ groups: list[NDArray[np.float64]],
272
+ *,
273
+ alpha: float = 0.05,
274
+ ) -> dict[str, Any]:
275
+ """One-way ANOVA on a list of group vectors. Port of utilities.anova1.
276
+
277
+ Returns the F-statistic and its p-value, the between/within/total sums of
278
+ squares and mean squares, per-group means and counts, the grand mean and the
279
+ reject flag. NaNs are dropped; empty groups are removed before the test.
280
+ """
281
+ cleaned: list[NDArray[np.float64]] = []
282
+ for g in groups:
283
+ gv = np.asarray(g, dtype=float).ravel()
284
+ gv = gv[~np.isnan(gv)]
285
+ if gv.size >= 1:
286
+ cleaned.append(gv)
287
+ k = len(cleaned)
288
+ if k < 2:
289
+ raise ValueError(f"ANOVA requires at least 2 non-empty groups (got {k})")
290
+
291
+ group_n = np.array([g.size for g in cleaned], dtype=float)
292
+ group_means = np.array([float(np.mean(g)) for g in cleaned])
293
+ total_n = int(group_n.sum())
294
+ grand_mean = float(np.sum(group_means * group_n) / total_n)
295
+ ss_between = float(np.sum(group_n * (group_means - grand_mean) ** 2))
296
+ ss_within = float(
297
+ sum(float(np.sum((g - m) ** 2)) for g, m in zip(cleaned, group_means, strict=True))
298
+ )
299
+ ss_total = ss_between + ss_within
300
+ df1 = k - 1
301
+ df2 = total_n - k
302
+ if df2 < 1:
303
+ raise ValueError(f"need more observations than groups (N={total_n}, k={k})")
304
+ ms_between = ss_between / df1
305
+ ms_within = ss_within / df2
306
+ if ms_within == 0:
307
+ f_stat = 0.0 if ms_between == 0 else float("inf")
308
+ p_value = 1.0 if ms_between == 0 else 0.0
309
+ else:
310
+ f_stat = ms_between / ms_within
311
+ p_value = 1.0 - _f_cdf(f_stat, df1, df2)
312
+
313
+ return {
314
+ "fStat": f_stat,
315
+ "df1": df1,
316
+ "df2": df2,
317
+ "pValue": p_value,
318
+ "ssBetween": ss_between,
319
+ "ssWithin": ss_within,
320
+ "ssTotal": ss_total,
321
+ "msBetween": ms_between,
322
+ "msWithin": ms_within,
323
+ "groupMeans": group_means,
324
+ "groupN": group_n,
325
+ "grandMean": grand_mean,
326
+ "reject": bool(p_value < alpha),
327
+ }
328
+
329
+
330
+ def pca_analysis(
331
+ x: NDArray[np.float64],
332
+ *,
333
+ center: bool = True,
334
+ scale: bool = False,
335
+ num_components: int = 0,
336
+ ) -> dict[str, Any]:
337
+ """Principal component analysis via SVD. Port of utilities.pcaAnalysis.
338
+
339
+ Returns the loadings (``coeff``, p×k), ``score`` (n×k), eigenvalues
340
+ (``latent``), percent/cumulative variance ``explained``/``cumulative``, the
341
+ centering ``mu`` and scaling ``sigma``, and the ``singular`` values. Each
342
+ component's sign is fixed so its largest-magnitude loading is positive,
343
+ making the decomposition deterministic (resolves SVD sign ambiguity).
344
+ """
345
+ xa = np.asarray(x, dtype=float)
346
+ if xa.size == 0:
347
+ raise ValueError("X must be non-empty")
348
+ if xa.ndim != 2:
349
+ raise ValueError("X must be a 2-D matrix (observations x variables)")
350
+ n, p = xa.shape
351
+ if n < 2:
352
+ raise ValueError(f"need at least 2 observations (rows), got {n}")
353
+ if not np.all(np.isfinite(xa)):
354
+ # NaN/Inf makes np.linalg.svd raise an opaque "SVD did not converge".
355
+ raise ValueError("X must contain only finite values (found NaN or Inf)")
356
+
357
+ mu = xa.mean(axis=0) if center else np.zeros(p)
358
+ xc = xa - mu
359
+ if scale:
360
+ sigma = xc.std(axis=0, ddof=1)
361
+ sigma[sigma == 0] = 1.0
362
+ xc = xc / sigma
363
+ else:
364
+ sigma = np.ones(p)
365
+
366
+ u, sv, vt = np.linalg.svd(xc, full_matrices=False)
367
+ v = vt.T
368
+ latent = sv**2 / max(n - 1, 1)
369
+ total_var = float(latent.sum())
370
+ explained = np.zeros_like(latent) if total_var == 0 else 100.0 * latent / total_var
371
+ score = u * sv # U .* sv' (column scaling), avoids forming the diagonal
372
+
373
+ for j in range(v.shape[1]):
374
+ idx = int(np.argmax(np.abs(v[:, j])))
375
+ if v[idx, j] < 0:
376
+ v[:, j] = -v[:, j]
377
+ score[:, j] = -score[:, j]
378
+
379
+ k = latent.size
380
+ if num_components > 0:
381
+ k = min(num_components, k)
382
+ v, score, latent, explained, sv = (
383
+ v[:, :k],
384
+ score[:, :k],
385
+ latent[:k],
386
+ explained[:k],
387
+ sv[:k],
388
+ )
389
+
390
+ return {
391
+ "coeff": v,
392
+ "score": score,
393
+ "latent": latent,
394
+ "explained": explained,
395
+ "cumulative": np.cumsum(explained),
396
+ "mu": mu,
397
+ "sigma": sigma,
398
+ "singular": sv,
399
+ }