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,219 @@
1
+ """Sibson natural-neighbour interpolation (pure; scipy.spatial only, no GPL).
2
+
3
+ MATLAB ``scatteredInterpolant`` 'natural' (and 'cubic', which it aliases to
4
+ 'natural') use Sibson's natural-neighbour interpolation. scipy has no
5
+ equivalent, so this implements it directly from the Delaunay triangulation via
6
+ the local Bowyer-Watson "stolen area" construction:
7
+
8
+ Inserting a query point ``q`` into the Voronoi diagram of the data carves a
9
+ new cell out of the existing ones. The Sibson coordinate of data point ``p``
10
+ is the area ``q``'s new cell steals from ``p``'s old cell, normalised by the
11
+ total new-cell area. The interpolated value is the weighted sum
12
+ ``sum_i lambda_i * z_i``.
13
+
14
+ Sibson coordinates are *geometrically* unique (they depend only on the point
15
+ configuration, not on any tie-breaking), so this matches MATLAB's 'natural' to
16
+ ~1e-9 at non-degenerate interior points. The interpolant has **linear
17
+ precision** — it reproduces any affine ``z = a*x + b*y + c`` exactly — which the
18
+ tests assert to machine precision.
19
+
20
+ The work per query is *local* (only the few triangles whose circumcircle
21
+ contains ``q``, found by a BFS over Delaunay adjacency), so cost scales with the
22
+ query count, not N^2. The in-circle test reduces to a squared-distance compare
23
+ against precomputed circumradii; ``find_simplex`` is vectorised over all queries.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import numpy as np
29
+ from numpy.typing import NDArray
30
+ from scipy.spatial import Delaunay
31
+ from scipy.spatial._qhull import QhullError
32
+
33
+ __all__ = ["sibson_interpolate"]
34
+
35
+ _COINCIDE_TOL2 = 1e-18 # (distance)^2 below which a query is "at" a data node
36
+
37
+
38
+ def _circumcenters(
39
+ a: NDArray[np.float64], b: NDArray[np.float64], c: NDArray[np.float64]
40
+ ) -> NDArray[np.float64]:
41
+ """Vectorised circumcentres for triangle vertex arrays (each ``(T, 2)``)."""
42
+ ax, ay = a[:, 0], a[:, 1]
43
+ bx, by = b[:, 0], b[:, 1]
44
+ cx, cy = c[:, 0], c[:, 1]
45
+ d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
46
+ a2 = ax * ax + ay * ay
47
+ b2 = bx * bx + by * by
48
+ c2 = cx * cx + cy * cy
49
+ ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d
50
+ uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d
51
+ return np.column_stack([ux, uy])
52
+
53
+
54
+ def _circumcenter1(
55
+ a: NDArray[np.float64], b: NDArray[np.float64], q: NDArray[np.float64]
56
+ ) -> NDArray[np.float64]:
57
+ """Circumcentre of a single triangle (a, b, q) — for new (inserted) triangles."""
58
+ ax, ay = float(a[0]), float(a[1])
59
+ bx, by = float(b[0]), float(b[1])
60
+ qx, qy = float(q[0]), float(q[1])
61
+ d = 2.0 * (ax * (by - qy) + bx * (qy - ay) + qx * (ay - by))
62
+ a2 = ax * ax + ay * ay
63
+ b2 = bx * bx + by * by
64
+ q2 = qx * qx + qy * qy
65
+ ux = (a2 * (by - qy) + b2 * (qy - ay) + q2 * (ay - by)) / d
66
+ uy = (a2 * (qx - bx) + b2 * (ax - qx) + q2 * (bx - ax)) / d
67
+ return np.array([ux, uy], dtype=float)
68
+
69
+
70
+ def _poly_area(pts: NDArray[np.float64]) -> float:
71
+ """Area of a convex polygon given its (unordered) vertices.
72
+
73
+ The stolen region is convex, so ordering vertices by angle about their
74
+ centroid recovers the boundary; the shoelace formula then gives the area.
75
+ """
76
+ if pts.shape[0] < 3:
77
+ return 0.0
78
+ cen = pts.mean(axis=0)
79
+ ang = np.arctan2(pts[:, 1] - cen[1], pts[:, 0] - cen[0])
80
+ p = pts[np.argsort(ang)]
81
+ x, y = p[:, 0], p[:, 1]
82
+ return 0.5 * abs(float(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))))
83
+
84
+
85
+ def _linear_bary(
86
+ q: NDArray[np.float64],
87
+ simplex: NDArray[np.intp],
88
+ points: NDArray[np.float64],
89
+ values: NDArray[np.float64],
90
+ ) -> float:
91
+ """Barycentric (piecewise-linear) value in one triangle — degenerate fallback."""
92
+ a, b, c = points[simplex[0]], points[simplex[1]], points[simplex[2]]
93
+ mat = np.array([[a[0] - c[0], b[0] - c[0]], [a[1] - c[1], b[1] - c[1]]], dtype=float)
94
+ try:
95
+ lam = np.linalg.solve(mat, q - c)
96
+ except np.linalg.LinAlgError:
97
+ return float("nan")
98
+ l0, l1 = float(lam[0]), float(lam[1])
99
+ l2 = 1.0 - l0 - l1
100
+ return (
101
+ l0 * float(values[simplex[0]])
102
+ + l1 * float(values[simplex[1]])
103
+ + l2 * float(values[simplex[2]])
104
+ )
105
+
106
+
107
+ def _sibson_one(
108
+ q: NDArray[np.float64],
109
+ s0: int,
110
+ simplices: NDArray[np.intp],
111
+ neighbors: NDArray[np.intp],
112
+ points: NDArray[np.float64],
113
+ values: NDArray[np.float64],
114
+ centers: NDArray[np.float64],
115
+ radii2: NDArray[np.float64],
116
+ ) -> float:
117
+ """Sibson-interpolated value at one query point (NaN outside the hull)."""
118
+ if s0 < 0:
119
+ return float("nan") # outside convex hull → extrapolation 'none'
120
+
121
+ # Query coincident with a data node → return that node's value exactly
122
+ # (Sibson's new cell degenerates to zero area here, as at any data site).
123
+ for vi in simplices[s0]:
124
+ dx0 = q[0] - points[vi, 0]
125
+ dy0 = q[1] - points[vi, 1]
126
+ if dx0 * dx0 + dy0 * dy0 <= _COINCIDE_TOL2:
127
+ return float(values[vi])
128
+
129
+ # Cavity = triangles whose circumcircle contains q (squared-distance test).
130
+ cavity: set[int] = set()
131
+ stack = [s0]
132
+ while stack:
133
+ t = stack.pop()
134
+ if t < 0 or t in cavity:
135
+ continue
136
+ if t != s0:
137
+ dx = q[0] - centers[t, 0]
138
+ dy = q[1] - centers[t, 1]
139
+ if dx * dx + dy * dy >= radii2[t]:
140
+ continue
141
+ cavity.add(t)
142
+ for nb in neighbors[t]:
143
+ if nb >= 0 and nb not in cavity:
144
+ stack.append(int(nb))
145
+
146
+ try:
147
+ # New Voronoi vertices: circumcentre of (edge, q) per cavity boundary edge.
148
+ cc_new: dict[tuple[int, int], NDArray[np.float64]] = {}
149
+ for t in cavity:
150
+ tv = simplices[t]
151
+ nbrs = neighbors[t]
152
+ for i in range(3):
153
+ if int(nbrs[i]) in cavity:
154
+ continue
155
+ a_idx = int(tv[(i + 1) % 3])
156
+ b_idx = int(tv[(i + 2) % 3])
157
+ cc_new[(a_idx, b_idx)] = _circumcenter1(points[a_idx], points[b_idx], q)
158
+
159
+ neighbours: set[int] = set()
160
+ for t in cavity:
161
+ neighbours.update(int(v) for v in simplices[t])
162
+
163
+ total = 0.0
164
+ weighted = 0.0
165
+ for p_idx in neighbours:
166
+ verts: list[NDArray[np.float64]] = [
167
+ centers[t] for t in cavity if p_idx in simplices[t]
168
+ ]
169
+ for (a_idx, b_idx), cc in cc_new.items():
170
+ if p_idx in (a_idx, b_idx):
171
+ verts.append(cc)
172
+ if len(verts) < 3:
173
+ continue
174
+ stolen = _poly_area(np.asarray(verts, dtype=float))
175
+ total += stolen
176
+ weighted += stolen * float(values[p_idx])
177
+ except ZeroDivisionError:
178
+ total = 0.0 # collinear new triangle → fall through to the linear fallback
179
+
180
+ if total <= 0.0: # degenerate (q on a circumcircle / collinear cavity)
181
+ return _linear_bary(q, simplices[s0], points, values)
182
+ return weighted / total
183
+
184
+
185
+ def sibson_interpolate(
186
+ xv: NDArray[np.float64],
187
+ yv: NDArray[np.float64],
188
+ zv: NDArray[np.float64],
189
+ xqv: NDArray[np.float64],
190
+ yqv: NDArray[np.float64],
191
+ ) -> NDArray[np.float64]:
192
+ """Sibson natural-neighbour interpolation of scattered (xv, yv, zv) at queries.
193
+
194
+ Returns NaN outside the convex hull (matching MATLAB ``Extrapolation='none'``)
195
+ and for collinear/degenerate inputs that cannot be triangulated.
196
+ """
197
+ pts = np.column_stack([xv, yv])
198
+ try:
199
+ tri = Delaunay(pts)
200
+ except QhullError:
201
+ return np.full(xqv.size, np.nan, dtype=float)
202
+
203
+ simplices = tri.simplices
204
+ neighbors = tri.neighbors
205
+ a = pts[simplices[:, 0]]
206
+ b = pts[simplices[:, 1]]
207
+ c = pts[simplices[:, 2]]
208
+ centers = _circumcenters(a, b, c)
209
+ radii2 = np.asarray(((a - centers) ** 2).sum(axis=1), dtype=float)
210
+
211
+ values = np.asarray(zv, dtype=float)
212
+ queries = np.column_stack([xqv.ravel(), yqv.ravel()])
213
+ loc = tri.find_simplex(queries) # vectorised hull/containment query
214
+ out = np.empty(queries.shape[0], dtype=float)
215
+ for k in range(queries.shape[0]):
216
+ out[k] = _sibson_one(
217
+ queries[k], int(loc[k]), simplices, neighbors, pts, values, centers, radii2
218
+ )
219
+ return out
@@ -0,0 +1,159 @@
1
+ """Multi-dataset aggregation utilities (confidence / spread bands).
2
+
3
+ Pure calc layer: operates on :class:`DataStruct` inputs, no fastapi/pydantic
4
+ imports. Distinct from ``calc.stats`` (single-vector statistics) — these
5
+ functions combine *several* datasets onto a shared grid.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ from scipy.interpolate import PchipInterpolator
14
+
15
+ from ..datastruct import DataStruct
16
+ from .resample import _interp_column, _sanitize_xy
17
+
18
+ __all__ = ["confidence_band", "dataset_algebra"]
19
+
20
+
21
+ def confidence_band(
22
+ datasets: list[DataStruct],
23
+ *,
24
+ method: str = "mean",
25
+ channel: int = 0,
26
+ n_points: int = 0,
27
+ ) -> dict[str, Any]:
28
+ """Pointwise confidence/spread band across N datasets. Port of confidenceBand.
29
+
30
+ Each dataset is pchip-interpolated onto a shared grid spanning the
31
+ *overlapping* x-range (``max`` of the per-set minima to ``min`` of the
32
+ maxima), then reduced column-wise:
33
+
34
+ - ``method='mean'`` : center = nanmean, band = center +/- sample std (ddof=1)
35
+ - ``method='median'``: center = nanmedian, band = [p25, p75], spread = IQR/2
36
+
37
+ ``channel`` is 0-based (MATLAB ``Channel`` is 1-based) and clamped to the last
38
+ channel. Percentiles use the Hazen plotting position ``(i-0.5)/n`` to match
39
+ MATLAB ``prctile``. ``n_points=0`` uses the longest input length.
40
+ """
41
+ if method not in ("mean", "median"):
42
+ raise ValueError("method must be mean/median")
43
+ n_sets = len(datasets)
44
+ if n_sets < 2:
45
+ raise ValueError(f"need at least 2 datasets, got {n_sets}")
46
+
47
+ x_min, x_max, max_len = -np.inf, np.inf, 0
48
+ for ds in datasets:
49
+ xi = np.asarray(ds.time, dtype=float)
50
+ if not np.isfinite(xi).any():
51
+ continue # a fully non-finite x axis contributes no overlap
52
+ x_min = max(x_min, float(np.nanmin(xi)))
53
+ x_max = min(x_max, float(np.nanmax(xi)))
54
+ max_len = max(max_len, int(xi.size))
55
+ if not np.isfinite([x_min, x_max]).all() or x_min >= x_max:
56
+ raise ValueError("datasets have no overlapping x-range")
57
+
58
+ n_pts = n_points if n_points > 0 else max_len
59
+ x_common = np.linspace(x_min, x_max, n_pts)
60
+ y_matrix = np.full((n_pts, n_sets), np.nan)
61
+ for i, ds in enumerate(datasets):
62
+ xi = np.asarray(ds.time, dtype=float)
63
+ vals = np.asarray(ds.values, dtype=float)
64
+ ch = min(channel, vals.shape[1] - 1)
65
+ # Sanitize first (drop non-finite, sort, average duplicate x): pchip
66
+ # rejects NaN and non-strictly-increasing x. No-op on clean data.
67
+ xs, ys = _sanitize_xy(xi, vals[:, ch])
68
+ if xs.size < 2:
69
+ continue # fewer than 2 finite points — leave this set's column NaN
70
+ interp = PchipInterpolator(xs, ys, extrapolate=False)
71
+ y_matrix[:, i] = interp(x_common)
72
+
73
+ if method == "mean":
74
+ center = np.nanmean(y_matrix, axis=1)
75
+ spread = np.nanstd(y_matrix, axis=1, ddof=1)
76
+ upper = center + spread
77
+ lower = center - spread
78
+ else: # median
79
+ center = np.nanmedian(y_matrix, axis=1)
80
+ q25 = np.nanpercentile(y_matrix, 25, axis=1, method="hazen")
81
+ q75 = np.nanpercentile(y_matrix, 75, axis=1, method="hazen")
82
+ upper, lower = q75, q25
83
+ spread = (q75 - q25) / 2.0
84
+
85
+ return {
86
+ "x": x_common,
87
+ "center": center,
88
+ "upper": upper,
89
+ "lower": lower,
90
+ "spread": spread,
91
+ "method": method,
92
+ "nSets": n_sets,
93
+ }
94
+
95
+
96
+ _ALGEBRA_OPS = ("A+B", "A-B", "A*B", "A/B", "(A-B)/(A+B)")
97
+
98
+
99
+ def _safe_label(ds: DataStruct, ch: int) -> str:
100
+ return ds.labels[ch] if ch < len(ds.labels) else f"ch{ch + 1}"
101
+
102
+
103
+ def _safe_unit(ds: DataStruct, ch: int) -> str:
104
+ return ds.units[ch] if ch < len(ds.units) else ""
105
+
106
+
107
+ def dataset_algebra(
108
+ ds_a: DataStruct,
109
+ ds_b: DataStruct,
110
+ operation: str,
111
+ *,
112
+ interp_method: str = "pchip",
113
+ channel_a: int = 0,
114
+ channel_b: int = 0,
115
+ ) -> DataStruct:
116
+ """Combine two datasets pointwise on A's x-grid. Port of utilities.datasetAlgebra.
117
+
118
+ B is interpolated onto A's time axis (``interp_method`` = pchip/linear/spline,
119
+ NaN outside B's range) and combined via ``operation`` (``A+B``, ``A-B``,
120
+ ``A*B``, ``A/B``, ``(A-B)/(A+B)``). Division/asymmetry guard zeros with NaN.
121
+ ``channel_a``/``channel_b`` are 0-based and clamped to the last channel.
122
+ """
123
+ if operation not in _ALGEBRA_OPS:
124
+ raise ValueError(f"operation must be one of {_ALGEBRA_OPS}")
125
+ xa = np.asarray(ds_a.time, dtype=float).ravel()
126
+ xb = np.asarray(ds_b.time, dtype=float).ravel()
127
+ va = np.asarray(ds_a.values, dtype=float)
128
+ vb = np.asarray(ds_b.values, dtype=float)
129
+ ch_a = min(channel_a, va.shape[1] - 1)
130
+ ch_b = min(channel_b, vb.shape[1] - 1)
131
+ ya = va[:, ch_a]
132
+ yb = _interp_column(xb, vb[:, ch_b], xa, interp_method, False)
133
+
134
+ la, lb = _safe_label(ds_a, ch_a), _safe_label(ds_b, ch_b)
135
+ unit_a = _safe_unit(ds_a, ch_a)
136
+ if operation == "A+B":
137
+ y_result, label, unit = ya + yb, f"{la} + {lb}", unit_a
138
+ elif operation == "A-B":
139
+ y_result, label, unit = ya - yb, f"{la} - {lb}", unit_a
140
+ elif operation == "A*B":
141
+ y_result, label, unit = ya * yb, f"{la} × {lb}", f"{unit_a}²"
142
+ elif operation == "A/B":
143
+ with np.errstate(divide="ignore", invalid="ignore"):
144
+ y_result = ya / yb
145
+ y_result[yb == 0] = np.nan
146
+ label, unit = f"{la} / {lb}", "ratio"
147
+ else: # (A-B)/(A+B)
148
+ denom = ya + yb
149
+ with np.errstate(divide="ignore", invalid="ignore"):
150
+ y_result = (ya - yb) / denom
151
+ y_result[denom == 0] = np.nan
152
+ label = f"({la} - {lb}) / ({la} + {lb})"
153
+ unit = "asymmetry"
154
+
155
+ meta: dict[str, Any] = {}
156
+ if "source" in ds_a.metadata:
157
+ meta["source"] = ds_a.metadata["source"]
158
+ meta["operation"] = operation
159
+ return DataStruct.create(xa, y_result, labels=[label], units=[unit], metadata=meta)