quantized-lab 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,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
|
+
}
|