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,90 @@
1
+ r"""Chemical formula → element counts / molar mass (DiraCulator helpers).
2
+
3
+ Pure parser, **no eval**: a regex token stream + a parenthesis stack handles
4
+ nested groups with multipliers, e.g. ``Ca(OH)2``, ``Sr(TiO3)``, ``Al2O3``. Counts
5
+ may be fractional (``Fe0.95O``). Molar mass reuses the golden ``element_data``
6
+ table. Used by the Crystal calculator (theoretical density from a formula) and is
7
+ the natural home for any future formula→property helper (e.g. SLD-from-formula).
8
+
9
+ Reference: ``formula_mass("H2O") ≈ 18.015 g/mol``; ``"NaCl" ≈ 58.44``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from quantized.calc.element_data import by_symbol
17
+
18
+ __all__ = ["formula_mass", "parse_formula"]
19
+
20
+ # An element symbol, a (possibly fractional) count, or a parenthesis.
21
+ _TOKEN_RE = re.compile(r"([A-Z][a-z]?)|(\d+\.?\d*)|(\()|(\))")
22
+
23
+
24
+ def parse_formula(formula: str) -> dict[str, float]:
25
+ """Parse a chemical formula into a ``{symbol: count}`` map.
26
+
27
+ Supports nested groups with multipliers (``Ca(OH)2`` → ``{Ca:1, O:2, H:2}``)
28
+ and fractional counts. Raises ``ValueError`` on stray characters, unbalanced
29
+ parentheses, a leading number, or an empty formula.
30
+
31
+ >>> parse_formula("Al2O3")
32
+ {'Al': 2.0, 'O': 3.0}
33
+ """
34
+ text = formula.strip()
35
+ if not text:
36
+ raise ValueError("empty chemical formula")
37
+ # Stack of count maps, one per open group; the bottom is the whole formula.
38
+ stack: list[dict[str, float]] = [{}]
39
+ # What a following number multiplies: ("el", symbol) | ("group", map) | None.
40
+ last: tuple[str, object] | None = None
41
+ pos = 0
42
+ for m in _TOKEN_RE.finditer(text):
43
+ if m.start() != pos:
44
+ raise ValueError(f"unexpected character in formula: {text[pos : m.start()]!r}")
45
+ pos = m.end()
46
+ sym, num, lpar, rpar = m.groups()
47
+ if sym is not None:
48
+ stack[-1][sym] = stack[-1].get(sym, 0.0) + 1.0
49
+ last = ("el", sym)
50
+ elif num is not None:
51
+ n = float(num)
52
+ if last is None:
53
+ raise ValueError("a number must follow an element or group")
54
+ if last[0] == "el":
55
+ # Replace the implicit ×1 already added with ×n.
56
+ stack[-1][str(last[1])] += n - 1.0
57
+ else:
58
+ group = last[1]
59
+ assert isinstance(group, dict)
60
+ for s, cnt in group.items():
61
+ stack[-1][s] = stack[-1].get(s, 0.0) + cnt * (n - 1.0)
62
+ last = None
63
+ elif lpar is not None:
64
+ stack.append({})
65
+ last = None
66
+ else: # rpar
67
+ if len(stack) < 2:
68
+ raise ValueError("unbalanced ')' in formula")
69
+ group = stack.pop()
70
+ for s, cnt in group.items():
71
+ stack[-1][s] = stack[-1].get(s, 0.0) + cnt
72
+ last = ("group", group)
73
+ if pos != len(text):
74
+ raise ValueError(f"unexpected trailing characters in formula: {text[pos:]!r}")
75
+ if len(stack) != 1:
76
+ raise ValueError("unbalanced '(' in formula")
77
+ counts = stack[0]
78
+ if not counts:
79
+ raise ValueError("no elements found in formula")
80
+ return counts
81
+
82
+
83
+ def formula_mass(formula: str) -> float:
84
+ """Molar mass (g/mol) of a chemical formula, summed from ``element_data``
85
+ atomic masses. Raises ``ValueError`` for a bad formula or unknown symbol."""
86
+ counts = parse_formula(formula)
87
+ total = 0.0
88
+ for sym, n in counts.items():
89
+ total += float(by_symbol(sym)["mass"]) * n
90
+ return total
@@ -0,0 +1,305 @@
1
+ """Global curve fitting with named per-group shared parameters. Port of
2
+ fitting.globalCurveFit.
3
+
4
+ Pure calc layer. Richer than ``global_fit`` (which shares a parameter across *all*
5
+ datasets via a boolean mask): here each *constraint* names a parameter and the
6
+ *subset* of datasets that share it, so parameter X can be shared among datasets
7
+ {0,1} while dataset 2 keeps its own X. Per-dataset models/bounds are allowed.
8
+
9
+ Method (matches MATLAB): builds a super-parameter vector
10
+ ``[shared_group_1..G, free per-(dataset,param) slots]``, optimises the summed
11
+ weighted residuals with Nelder-Mead over the logit/log bound transform (the same
12
+ machinery as ``curveFit``), and derives errors from a central-difference Hessian
13
+ of the global cost scaled by the global reduced chi-squared. Beechem, Methods
14
+ Enzymol. 210, 37 (1992).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from collections.abc import Callable, Sequence
21
+ from typing import Any
22
+
23
+ import numpy as np
24
+ from numpy.typing import NDArray
25
+ from scipy.optimize import minimize
26
+
27
+ from quantized.datastruct import DataStruct
28
+
29
+ from .fitting import (
30
+ _bound_jacobian,
31
+ _bound_to_free,
32
+ _free_to_bound,
33
+ _numerical_hessian,
34
+ )
35
+
36
+ __all__ = ["global_curve_fit"]
37
+
38
+ _EPS = float(np.finfo(float).eps)
39
+ ModelFn = Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
40
+
41
+ # ASCII Greek-letter name aliases (port of globalCurveFit greekAliases).
42
+ _GREEK = {
43
+ "sigma": "σ", "mu": "μ", "gamma": "γ", "eta": "η", "tau": "τ",
44
+ "lambda": "λ", "alpha": "α", "beta": "β", "phi": "φ", "theta": "θ",
45
+ "omega": "ω", "pi": "π", "delta": "δ", "epsilon": "ε", "kappa": "κ",
46
+ "rho": "ρ", "chi": "χ", "psi": "ψ", "nu": "ν", "xi": "ξ",
47
+ }
48
+
49
+
50
+ def _greek_aliases(name: str) -> list[str]:
51
+ out: list[str] = []
52
+ low = name.lower()
53
+ for ascii_name, glyph in _GREEK.items():
54
+ if low == ascii_name:
55
+ out.append(glyph)
56
+ elif name == glyph:
57
+ out.append(ascii_name)
58
+ return out
59
+
60
+
61
+ def _resolve_param(name: str, param_names: Sequence[str]) -> int:
62
+ if name in param_names:
63
+ return list(param_names).index(name)
64
+ for alias in _greek_aliases(name):
65
+ if alias in param_names:
66
+ return list(param_names).index(alias)
67
+ raise ValueError(f'parameter "{name}" not found in model param names')
68
+
69
+
70
+ def _extract_xy(
71
+ ds: DataStruct | tuple[Any, Any] | list[Any], channel: int = 0
72
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
73
+ if isinstance(ds, DataStruct):
74
+ x = np.asarray(ds.time, dtype=float).ravel()
75
+ values = np.asarray(ds.values, dtype=float)
76
+ y = values[:, channel] if values.ndim == 2 else values.ravel()
77
+ return x, np.asarray(y, dtype=float).ravel()
78
+ if isinstance(ds, (tuple, list)) and len(ds) >= 2:
79
+ return np.asarray(ds[0], dtype=float).ravel(), np.asarray(ds[1], dtype=float).ravel()
80
+ raise ValueError("each dataset must be a (x, y) pair or a DataStruct")
81
+
82
+
83
+ def _per_dataset(
84
+ arg: Sequence[Any] | None, k: int, p: int, default: NDArray[np.float64]
85
+ ) -> list[NDArray[np.float64]]:
86
+ """Normalise a per-dataset vector argument: None -> default broadcast; a single
87
+ length-P vector -> broadcast; a list of K vectors -> as-is."""
88
+ if arg is None:
89
+ return [default.copy() for _ in range(k)]
90
+ arr = np.asarray(arg, dtype=float)
91
+ if arr.ndim == 1: # single vector broadcasts to all datasets
92
+ return [arr.astype(float).copy() for _ in range(k)]
93
+ if arr.shape[0] != k:
94
+ raise ValueError(f"expected {k} per-dataset vectors, got {arr.shape[0]}")
95
+ return [np.asarray(arr[i], dtype=float) for i in range(k)]
96
+
97
+
98
+ def global_curve_fit(
99
+ datasets: list[Any],
100
+ model_fcn: ModelFn | Sequence[ModelFn],
101
+ param_names: Sequence[str],
102
+ constraints: Sequence[dict[str, Any]] | None,
103
+ *,
104
+ init_guess: Sequence[Any],
105
+ lower: Sequence[Any] | None = None,
106
+ upper: Sequence[Any] | None = None,
107
+ weights: Sequence[Any] | None = None,
108
+ max_iter: int = 20000,
109
+ tol_fun: float = 1e-12,
110
+ tol_x: float = 1e-10,
111
+ channel: int = 0,
112
+ ) -> dict[str, Any]:
113
+ """Fit a model to ``datasets`` with named per-group shared parameters.
114
+
115
+ Port of fitting.globalCurveFit. ``model_fcn`` is one callable (broadcast) or a
116
+ list of K callables. ``param_names`` is the length-P parameter list (shared by
117
+ all models). ``constraints`` is a list of ``{"param_name": str, "datasets":
118
+ [i, ...]}`` (0-based dataset indices); only groups of ≥2 datasets share.
119
+ ``init_guess`` is a list of K length-P start vectors. ``lower``/``upper`` are a
120
+ single length-P vector (broadcast) or a list of K vectors. ``weights`` is an
121
+ optional list of K length-N weight vectors. Returns a dict with per-dataset
122
+ ``params``/``errors``/``residuals``/``yFit``/``R2``/``RMSE``, a ``shared``
123
+ summary list, ``chiSqRed``, ``covar``, ``nTotal``, ``nFree``, ``exitFlag``.
124
+ """
125
+ k = len(datasets)
126
+ if k < 1:
127
+ raise ValueError("need at least one dataset")
128
+ p = len(param_names)
129
+
130
+ fcns: list[ModelFn] = [model_fcn] * k if callable(model_fcn) else list(model_fcn)
131
+ if len(fcns) != k:
132
+ raise ValueError(f"model_fcn list must have {k} elements (one per dataset)")
133
+
134
+ x_all: list[NDArray[np.float64]] = []
135
+ y_all: list[NDArray[np.float64]] = []
136
+ w_all: list[NDArray[np.float64]] = []
137
+ n_pts: list[int] = []
138
+ for i, ds in enumerate(datasets):
139
+ xi, yi = _extract_xy(ds, channel)
140
+ x_all.append(xi)
141
+ y_all.append(yi)
142
+ n_pts.append(xi.size)
143
+ if weights is not None and i < len(weights) and weights[i] is not None:
144
+ wi = np.asarray(weights[i], dtype=float).ravel()
145
+ if wi.size != xi.size:
146
+ raise ValueError(f"weights[{i}] must have {xi.size} elements")
147
+ w_all.append(wi)
148
+ else:
149
+ w_all.append(np.ones(xi.size))
150
+ n_total = int(sum(n_pts))
151
+
152
+ inf_p = np.full(p, math.inf)
153
+ p0_cell = _per_dataset(init_guess, k, p, np.zeros(p))
154
+ lb_cell = _per_dataset(lower, k, p, -inf_p)
155
+ ub_cell = _per_dataset(upper, k, p, inf_p)
156
+ # Clamp p0 to bounds (MATLAB clamps after assembling).
157
+ p0_cell = [np.clip(p0_cell[i], lb_cell[i], ub_cell[i]) for i in range(k)]
158
+
159
+ # ── sharing groups from constraints ──────────────────────────────────────
160
+ sharing: list[dict[str, Any]] = []
161
+ for c in constraints or []:
162
+ p_idx = _resolve_param(str(c["param_name"]), param_names)
163
+ ds_list = sorted({int(d) for d in c["datasets"]})
164
+ if any(d < 0 or d >= k for d in ds_list):
165
+ raise ValueError(f"constraint dataset indices must be in [0, {k - 1}]")
166
+ if len(ds_list) < 2:
167
+ continue # only meaningful when 2+ datasets share
168
+ sharing.append({"param_idx": p_idx, "param_name": str(c["param_name"]),
169
+ "datasets": ds_list})
170
+ n_groups = len(sharing)
171
+
172
+ is_shared = np.zeros((k, p), dtype=bool)
173
+ super_idx = np.zeros((k, p), dtype=int)
174
+ for g, grp in enumerate(sharing):
175
+ for ki in grp["datasets"]:
176
+ is_shared[ki, grp["param_idx"]] = True
177
+ super_idx[ki, grp["param_idx"]] = g # shared slots take positions 0..G-1
178
+ nxt = n_groups
179
+ for ki in range(k):
180
+ for pi in range(p):
181
+ if not is_shared[ki, pi]:
182
+ super_idx[ki, pi] = nxt
183
+ nxt += 1
184
+ n_super = nxt
185
+
186
+ super_p0 = np.zeros(n_super)
187
+ super_lb = np.full(n_super, -math.inf)
188
+ super_ub = np.full(n_super, math.inf)
189
+ for g, grp in enumerate(sharing):
190
+ pi = grp["param_idx"]
191
+ vals = [p0_cell[ki][pi] for ki in grp["datasets"]]
192
+ lbs = [lb_cell[ki][pi] for ki in grp["datasets"]]
193
+ ubs = [ub_cell[ki][pi] for ki in grp["datasets"]]
194
+ super_lb[g] = max(lbs) # tightest bounds win
195
+ super_ub[g] = min(ubs)
196
+ super_p0[g] = min(max(float(np.mean(vals)), super_lb[g]), super_ub[g])
197
+ for ki in range(k):
198
+ for pi in range(p):
199
+ if not is_shared[ki, pi]:
200
+ si = super_idx[ki, pi]
201
+ super_p0[si] = p0_cell[ki][pi]
202
+ super_lb[si] = lb_cell[ki][pi]
203
+ super_ub[si] = ub_cell[ki][pi]
204
+
205
+ sqrt_w = [np.sqrt(w_all[i]) for i in range(k)]
206
+
207
+ def from_free_all(pf: NDArray[np.float64]) -> NDArray[np.float64]:
208
+ return np.array([_free_to_bound(float(pf[s]), super_lb[s], super_ub[s])
209
+ for s in range(n_super)])
210
+
211
+ def expand(sp: NDArray[np.float64]) -> list[NDArray[np.float64]]:
212
+ return [np.array([sp[super_idx[ki, pi]] for pi in range(p)]) for ki in range(k)]
213
+
214
+ def cost(pf: NDArray[np.float64]) -> float:
215
+ sp = from_free_all(pf)
216
+ plist = expand(sp)
217
+ total = 0.0
218
+ for ki in range(k):
219
+ resid = (y_all[ki] - fcns[ki](x_all[ki], plist[ki])) * sqrt_w[ki]
220
+ total += float(np.sum(resid**2))
221
+ return total
222
+
223
+ pf0 = np.array([_bound_to_free(float(super_p0[s]), super_lb[s], super_ub[s])
224
+ for s in range(n_super)])
225
+ if n_super > 0:
226
+ res = minimize(
227
+ cost, pf0, method="Nelder-Mead",
228
+ options={"maxiter": max_iter, "maxfev": max_iter * 4,
229
+ "xatol": tol_x, "fatol": tol_fun},
230
+ )
231
+ pf_opt = np.asarray(res.x, dtype=float)
232
+ exit_flag = 1 if res.success else 0
233
+ else:
234
+ pf_opt = pf0
235
+ exit_flag = 1
236
+
237
+ sp_opt = from_free_all(pf_opt)
238
+ plist_opt = expand(sp_opt)
239
+
240
+ ss_res_total = 0.0
241
+ for ki in range(k):
242
+ resid = (y_all[ki] - fcns[ki](x_all[ki], plist_opt[ki])) * sqrt_w[ki]
243
+ ss_res_total += float(np.sum(resid**2))
244
+ dof = n_total - n_super
245
+ chi_sq_red = ss_res_total / max(dof, 1)
246
+
247
+ super_err = np.full(n_super, np.nan)
248
+ covar: NDArray[np.float64] | None = None
249
+ if n_super > 0 and dof > 0:
250
+ hess = _numerical_hessian(cost, pf_opt)
251
+ try:
252
+ cov_free = np.asarray(np.linalg.inv(hess / 2) * chi_sq_red, dtype=float)
253
+ if np.all(np.diag(cov_free) >= 0):
254
+ se_free = np.sqrt(np.diag(cov_free))
255
+ jac = np.array([_bound_jacobian(float(pf_opt[s]), super_lb[s], super_ub[s])
256
+ for s in range(n_super)])
257
+ super_err = se_free * np.abs(jac)
258
+ jmat = np.diag(jac)
259
+ covar = np.asarray(jmat @ cov_free @ jmat.T, dtype=float)
260
+ except np.linalg.LinAlgError:
261
+ pass
262
+
263
+ params: list[list[float]] = []
264
+ errors: list[list[float]] = []
265
+ residuals: list[NDArray[np.float64]] = []
266
+ y_fit: list[NDArray[np.float64]] = []
267
+ r2 = np.zeros(k)
268
+ rmse = np.zeros(k)
269
+ for ki in range(k):
270
+ params.append([float(v) for v in plist_opt[ki]])
271
+ errors.append([float(super_err[super_idx[ki, pi]]) for pi in range(p)])
272
+ ym = np.asarray(fcns[ki](x_all[ki], plist_opt[ki]), dtype=float)
273
+ y_fit.append(ym)
274
+ res_k = y_all[ki] - ym
275
+ residuals.append(np.asarray(res_k, dtype=float))
276
+ ss_res = float(np.sum(w_all[ki] * res_k**2))
277
+ w_mean = float(np.sum(w_all[ki] * y_all[ki]) / np.sum(w_all[ki]))
278
+ ss_tot = float(np.sum(w_all[ki] * (y_all[ki] - w_mean) ** 2))
279
+ r2[ki] = 1 - ss_res / max(ss_tot, _EPS)
280
+ rmse[ki] = math.sqrt(ss_res / n_pts[ki])
281
+
282
+ shared_out: list[dict[str, Any]] = []
283
+ for g, grp in enumerate(sharing):
284
+ shared_out.append({
285
+ "name": grp["param_name"],
286
+ "paramIdx": grp["param_idx"],
287
+ "datasets": grp["datasets"],
288
+ "value": float(sp_opt[g]),
289
+ "error": float(super_err[g]),
290
+ })
291
+
292
+ return {
293
+ "params": params,
294
+ "errors": errors,
295
+ "shared": shared_out,
296
+ "residuals": residuals,
297
+ "yFit": y_fit,
298
+ "R2": r2,
299
+ "RMSE": rmse,
300
+ "chiSqRed": float(chi_sq_red),
301
+ "covar": covar,
302
+ "nTotal": n_total,
303
+ "nFree": n_super,
304
+ "exitFlag": exit_flag,
305
+ }
@@ -0,0 +1,181 @@
1
+ """Global (shared-parameter) fitting across datasets. Port of fitting.globalFit.
2
+
3
+ Pure calc layer. Fits one model to N datasets in a single optimization where some
4
+ parameters are shared (one value across all datasets) and the rest are free per
5
+ dataset. Builds a super-parameter vector ``[shared…, ds0 free…, ds1 free…, …]``,
6
+ a joint objective over the concatenated data, and the bounded ``curve_fit`` solves
7
+ it; results are unpacked back into per-dataset full parameter vectors. The classic
8
+ global-analysis framework (Beechem, Methods Enzymol. 210, 37 (1992)).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Sequence
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+ from numpy.typing import NDArray
18
+
19
+ from quantized.datastruct import DataStruct
20
+
21
+ from .fitting import curve_fit
22
+
23
+ __all__ = ["global_fit"]
24
+
25
+ _EPS = float(np.finfo(float).eps)
26
+ ModelFn = Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
27
+
28
+
29
+ def _extract_xy(
30
+ ds: DataStruct | tuple[Any, Any] | list[Any], channel: int
31
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
32
+ if isinstance(ds, DataStruct):
33
+ x = np.asarray(ds.time, dtype=float).ravel()
34
+ values = np.asarray(ds.values, dtype=float)
35
+ if values.ndim == 2:
36
+ y = values[:, min(channel, values.shape[1] - 1)]
37
+ else:
38
+ y = values.ravel()
39
+ return x, np.asarray(y, dtype=float).ravel()
40
+ if isinstance(ds, (tuple, list)) and len(ds) >= 2:
41
+ return np.asarray(ds[0], dtype=float).ravel(), np.asarray(ds[1], dtype=float).ravel()
42
+ return np.empty(0), np.empty(0)
43
+
44
+
45
+ def global_fit(
46
+ datasets: list[Any],
47
+ model_fcn: ModelFn,
48
+ p0: Sequence[float],
49
+ shared_mask: Sequence[bool],
50
+ *,
51
+ lower: Sequence[float] | None = None,
52
+ upper: Sequence[float] | None = None,
53
+ channel: int = 0,
54
+ weights: str = "none",
55
+ ) -> dict[str, Any]:
56
+ """Fit ``datasets`` simultaneously with shared/per-dataset parameters.
57
+
58
+ Port of fitting.globalFit. ``shared_mask[j]`` True → parameter ``j`` is shared
59
+ across all datasets; False → free per dataset. ``p0``/``lower``/``upper`` are
60
+ length-M (one dataset). ``weights`` ∈ {``none``, ``1/y``, ``1/y2``}. Returns a
61
+ dict with ``sharedParams``/``sharedErrors``, ``perDataset``/``perErrors`` (N×M),
62
+ per-dataset ``R2`` + ``residuals``, ``R2global``, ``chiSqRed``, ``exitFlag``,
63
+ ``nParams``, ``nDatasets``, ``sharedMask``.
64
+ """
65
+ if weights not in ("none", "1/y", "1/y2"):
66
+ raise ValueError(f'weights must be "none", "1/y", or "1/y2", got "{weights}"')
67
+ m = len(p0)
68
+ n = len(datasets)
69
+ if len(shared_mask) != m:
70
+ raise ValueError(f"shared_mask must have {m} elements")
71
+
72
+ mask = np.asarray(shared_mask, dtype=bool)
73
+ shared_idx = np.flatnonzero(mask)
74
+ free_idx = np.flatnonzero(~mask)
75
+ n_shared = int(shared_idx.size)
76
+ n_free = int(free_idx.size)
77
+ p0v = np.asarray(p0, dtype=float)
78
+
79
+ x_all: list[NDArray[np.float64]] = []
80
+ y_all: list[NDArray[np.float64]] = []
81
+ w_all: list[NDArray[np.float64]] = []
82
+ n_pts: list[int] = []
83
+ for ds in datasets:
84
+ xi, yi = _extract_xy(ds, channel)
85
+ x_all.append(xi)
86
+ y_all.append(yi)
87
+ n_pts.append(xi.size)
88
+ if weights == "1/y":
89
+ w_all.append(1.0 / np.maximum(np.abs(yi), _EPS))
90
+ elif weights == "1/y2":
91
+ w_all.append(1.0 / np.maximum(yi**2, _EPS))
92
+ else:
93
+ w_all.append(np.ones(xi.size))
94
+ total_pts = int(sum(n_pts))
95
+
96
+ # Super-parameter vector: [shared…, ds0 free…, ds1 free…, …]
97
+ n_super = n_shared + n * n_free
98
+ super_p0 = np.zeros(n_super)
99
+ super_p0[:n_shared] = p0v[shared_idx]
100
+ for i in range(n):
101
+ off = n_shared + i * n_free
102
+ super_p0[off : off + n_free] = p0v[free_idx]
103
+
104
+ super_lb = np.full(n_super, -np.inf)
105
+ super_ub = np.full(n_super, np.inf)
106
+ if lower is not None:
107
+ lo = np.asarray(lower, dtype=float)
108
+ super_lb[:n_shared] = lo[shared_idx]
109
+ for i in range(n):
110
+ off = n_shared + i * n_free
111
+ super_lb[off : off + n_free] = lo[free_idx]
112
+ if upper is not None:
113
+ up = np.asarray(upper, dtype=float)
114
+ super_ub[:n_shared] = up[shared_idx]
115
+ for i in range(n):
116
+ off = n_shared + i * n_free
117
+ super_ub[off : off + n_free] = up[free_idx]
118
+
119
+ def _full_params(sp: NDArray[np.float64], di: int) -> NDArray[np.float64]:
120
+ p_full = np.zeros(m)
121
+ p_full[shared_idx] = sp[:n_shared]
122
+ off = n_shared + di * n_free
123
+ p_full[free_idx] = sp[off : off + n_free]
124
+ return p_full
125
+
126
+ def super_model(_x: NDArray[np.float64], sp: NDArray[np.float64]) -> NDArray[np.float64]:
127
+ # _x is the concatenated grid (ignored — each segment uses its own x_all).
128
+ y_pred = np.zeros(total_pts)
129
+ pos = 0
130
+ for di in range(n):
131
+ y_pred[pos : pos + n_pts[di]] = model_fcn(x_all[di], _full_params(sp, di))
132
+ pos += n_pts[di]
133
+ return y_pred
134
+
135
+ x_concat = np.concatenate(x_all) if x_all else np.empty(0)
136
+ y_concat = np.concatenate(y_all) if y_all else np.empty(0)
137
+ w_concat = np.concatenate(w_all) if w_all else np.empty(0)
138
+
139
+ fit = curve_fit(
140
+ x_concat, y_concat, super_model, super_p0.tolist(),
141
+ lower=super_lb.tolist(), upper=super_ub.tolist(), weights=w_concat, calc_errors=True,
142
+ )
143
+ sp_opt = np.asarray(fit["params"], dtype=float)
144
+ sp_err = np.asarray(fit["errors"], dtype=float)
145
+
146
+ shared_params = sp_opt[:n_shared]
147
+ shared_errors = sp_err[:n_shared]
148
+ per_dataset = np.zeros((n, m))
149
+ per_errors = np.zeros((n, m))
150
+ residuals: list[NDArray[np.float64]] = []
151
+ r2_per = np.zeros(n)
152
+ for i in range(n):
153
+ p_full = np.zeros(m)
154
+ e_full = np.zeros(m)
155
+ p_full[shared_idx] = shared_params
156
+ e_full[shared_idx] = shared_errors
157
+ off = n_shared + i * n_free
158
+ p_full[free_idx] = sp_opt[off : off + n_free]
159
+ e_full[free_idx] = sp_err[off : off + n_free]
160
+ per_dataset[i, :] = p_full
161
+ per_errors[i, :] = e_full
162
+ res = y_all[i] - model_fcn(x_all[i], p_full)
163
+ residuals.append(np.asarray(res, dtype=float))
164
+ ss_tot = float(np.sum((y_all[i] - np.mean(y_all[i])) ** 2))
165
+ ss_res = float(np.sum(res**2))
166
+ r2_per[i] = 1.0 - ss_res / max(ss_tot, _EPS)
167
+
168
+ return {
169
+ "sharedParams": shared_params,
170
+ "sharedErrors": shared_errors,
171
+ "perDataset": per_dataset,
172
+ "perErrors": per_errors,
173
+ "R2": r2_per,
174
+ "R2global": float(fit["R2"]),
175
+ "chiSqRed": float(fit["chiSqRed"]),
176
+ "residuals": residuals,
177
+ "nDatasets": n,
178
+ "sharedMask": mask.tolist(),
179
+ "exitFlag": int(fit["exitFlag"]),
180
+ "nParams": n_super,
181
+ }