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,258 @@
|
|
|
1
|
+
"""Optional bumps fit engine adapter (GOTO #10).
|
|
2
|
+
|
|
3
|
+
Pure calc layer. `bumps <https://github.com/bumps/bumps>`_ (BSD-3) is an
|
|
4
|
+
OPTIONAL dependency, imported inside functions and guarded — the MATLAB-parity
|
|
5
|
+
fitter (``calc.fitting.curve_fit``, golden-locked) stays the default engine
|
|
6
|
+
everywhere; bumps adds ``amoeba`` / ``lm`` / ``de`` (synchronous, Hessian
|
|
7
|
+
uncertainties) and ``dream`` (posterior sampling — long-running, driven
|
|
8
|
+
through the poll-model job runner ``quantized.jobs`` by the route layer).
|
|
9
|
+
|
|
10
|
+
There is deliberately NO golden parity here: bumps has no ``quantized_matlab``
|
|
11
|
+
counterpart, so its tests are reference-value / invariant tests, never
|
|
12
|
+
``@pytest.mark.golden`` (see ``tests/test_calc_fit_bumps.py``).
|
|
13
|
+
|
|
14
|
+
A registry model name (``calc.fit_models.FIT_MODELS`` — which also holds
|
|
15
|
+
equation-builder custom models once registered) or a raw ``f(x, p)`` callable
|
|
16
|
+
is wrapped into a ``bumps.curve.Curve`` via a synthesized keyword signature
|
|
17
|
+
(bumps introspects parameter names; internal names are ``p1..pn`` to avoid
|
|
18
|
+
collisions with ``Curve`` attributes, and results map back positionally).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import inspect
|
|
24
|
+
import math
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from numpy.typing import ArrayLike, NDArray
|
|
30
|
+
|
|
31
|
+
from .fit_models import FIT_MODELS, evaluate
|
|
32
|
+
|
|
33
|
+
__all__ = ["BUMPS_ENGINES", "bumps_available", "fit_bumps"]
|
|
34
|
+
|
|
35
|
+
ModelFn = Callable[[NDArray[np.float64], NDArray[np.float64]], NDArray[np.float64]]
|
|
36
|
+
ProgressFn = Callable[[float], None]
|
|
37
|
+
AbortFn = Callable[[], bool]
|
|
38
|
+
|
|
39
|
+
#: Supported bumps engine ids (bumps.fitters FITTERS ids).
|
|
40
|
+
BUMPS_ENGINES = ("amoeba", "lm", "de", "dream")
|
|
41
|
+
|
|
42
|
+
_INSTALL_HINT = (
|
|
43
|
+
"bumps is not installed - the optional fit engine needs "
|
|
44
|
+
"'pip install quantized[bumps]' (or: uv sync --extra bumps)"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def bumps_available() -> bool:
|
|
49
|
+
"""True when the optional bumps dependency is importable."""
|
|
50
|
+
try:
|
|
51
|
+
import bumps # noqa: F401
|
|
52
|
+
except ImportError:
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _import_bumps() -> tuple[Any, Any, Any, Any]:
|
|
58
|
+
"""Guarded import -> (Curve, FitProblem, FITTERS, FitDriver)."""
|
|
59
|
+
try:
|
|
60
|
+
from bumps.curve import Curve
|
|
61
|
+
from bumps.fitproblem import FitProblem
|
|
62
|
+
from bumps.fitters import FITTERS, FitDriver
|
|
63
|
+
except ImportError as exc:
|
|
64
|
+
raise ValueError(_INSTALL_HINT) from exc
|
|
65
|
+
return Curve, FitProblem, FITTERS, FitDriver
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _wrap_model(model_fcn: ModelFn, n_params: int) -> tuple[Callable[..., Any], list[str]]:
|
|
69
|
+
"""Wrap ``f(x, p_array)`` for bumps' signature introspection.
|
|
70
|
+
|
|
71
|
+
bumps ``Curve`` reads parameter names from the function signature and
|
|
72
|
+
calls ``fn(x, **params)``; the wrapper carries a synthesized signature
|
|
73
|
+
with safe internal names ``p1..pn`` (guaranteed not to collide with
|
|
74
|
+
``Curve`` attributes such as ``x``/``y``/``name``/``state``).
|
|
75
|
+
"""
|
|
76
|
+
names = [f"p{i + 1}" for i in range(n_params)]
|
|
77
|
+
|
|
78
|
+
def fn(x: Any, **kw: float) -> Any:
|
|
79
|
+
p = np.array([kw[nm] for nm in names], dtype=float)
|
|
80
|
+
return model_fcn(np.asarray(x, dtype=float), p)
|
|
81
|
+
|
|
82
|
+
pos = inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
83
|
+
sig = inspect.Signature(
|
|
84
|
+
[inspect.Parameter("x", pos)] + [inspect.Parameter(nm, pos) for nm in names]
|
|
85
|
+
)
|
|
86
|
+
setattr(fn, "__signature__", sig) # noqa: B010 — plain attr assign confuses mypy
|
|
87
|
+
return fn, names
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _ProgressMonitor:
|
|
91
|
+
"""bumps Monitor -> plain ``progress_callback(fraction)`` bridge.
|
|
92
|
+
|
|
93
|
+
``total_steps`` is the predicted generation count (burn + draw
|
|
94
|
+
generations); the reported fraction is clamped below 1.0 — the caller
|
|
95
|
+
(job runner) owns the terminal 100%.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, total_steps: int, callback: ProgressFn) -> None:
|
|
99
|
+
self._total = max(1, total_steps)
|
|
100
|
+
self._callback = callback
|
|
101
|
+
|
|
102
|
+
def config_history(self, history: Any) -> None:
|
|
103
|
+
history.requires(step=1)
|
|
104
|
+
|
|
105
|
+
def __call__(self, history: Any) -> None:
|
|
106
|
+
step = int(history.step[0])
|
|
107
|
+
self._callback(min(0.99, step / self._total))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_model(
|
|
111
|
+
model: str | ModelFn, param_names: list[str] | None, n_params: int
|
|
112
|
+
) -> tuple[ModelFn, list[str]]:
|
|
113
|
+
"""Registry name or callable -> (f(x, p) callable, display param names)."""
|
|
114
|
+
if callable(model):
|
|
115
|
+
names = list(param_names) if param_names else [f"p{i + 1}" for i in range(n_params)]
|
|
116
|
+
return model, names
|
|
117
|
+
|
|
118
|
+
if model not in FIT_MODELS:
|
|
119
|
+
raise ValueError(f"unknown fit model: {model}")
|
|
120
|
+
spec = FIT_MODELS[model]
|
|
121
|
+
if n_params != int(spec["nParams"]):
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"model '{model}' takes {spec['nParams']} parameters, got {n_params} initial values"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def fcn(x: NDArray[np.float64], p: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
127
|
+
return evaluate(model, x, p)
|
|
128
|
+
|
|
129
|
+
names = list(param_names) if param_names else list(spec["paramNames"])
|
|
130
|
+
return fcn, names
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def fit_bumps(
|
|
134
|
+
x: ArrayLike,
|
|
135
|
+
y: ArrayLike,
|
|
136
|
+
dy: ArrayLike | None = None,
|
|
137
|
+
*,
|
|
138
|
+
model: str | ModelFn,
|
|
139
|
+
p0: list[float],
|
|
140
|
+
lower: list[float] | None = None,
|
|
141
|
+
upper: list[float] | None = None,
|
|
142
|
+
param_names: list[str] | None = None,
|
|
143
|
+
engine: str = "amoeba",
|
|
144
|
+
samples: int = 10_000,
|
|
145
|
+
burn: int = 100,
|
|
146
|
+
pop: int = 10,
|
|
147
|
+
return_samples: bool = False,
|
|
148
|
+
progress_callback: ProgressFn | None = None,
|
|
149
|
+
abort_check: AbortFn | None = None,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
"""Fit (x, y[, dy]) with a bumps engine; returns a plain result dict.
|
|
152
|
+
|
|
153
|
+
``model`` is a ``calc.fit_models`` registry name (covers built-in AND
|
|
154
|
+
saved equation-builder models) or a raw ``f(x, p)`` callable. ``engine``
|
|
155
|
+
is one of ``BUMPS_ENGINES``: amoeba / lm / de report Hessian-derived
|
|
156
|
+
uncertainties (``uncertainty_kind='hessian'``); dream samples the
|
|
157
|
+
posterior (``uncertainty_kind='posterior'``) and additionally returns
|
|
158
|
+
per-parameter medians and central 68% intervals (plus the raw draw
|
|
159
|
+
matrix when ``return_samples`` — corner-plot food).
|
|
160
|
+
|
|
161
|
+
``samples`` / ``burn`` / ``pop`` tune dream only. ``progress_callback``
|
|
162
|
+
(fraction in [0, 1)) and ``abort_check`` (True -> stop sampling early)
|
|
163
|
+
let the job runner drive progress and cancellation; an exception raised
|
|
164
|
+
by ``progress_callback`` (e.g. ``jobs.JobCancelled``) propagates out.
|
|
165
|
+
|
|
166
|
+
Raises ``ValueError`` for a missing bumps install, unknown model/engine,
|
|
167
|
+
or malformed inputs — the route layer maps that to HTTP 422.
|
|
168
|
+
"""
|
|
169
|
+
Curve, FitProblem, FITTERS, FitDriver = _import_bumps()
|
|
170
|
+
|
|
171
|
+
if engine not in BUMPS_ENGINES:
|
|
172
|
+
raise ValueError(f"unknown bumps engine: {engine} (choose from {', '.join(BUMPS_ENGINES)})")
|
|
173
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
174
|
+
yv = np.asarray(y, dtype=float).ravel()
|
|
175
|
+
if xv.size != yv.size:
|
|
176
|
+
raise ValueError(f"x and y must have equal length (got {xv.size} and {yv.size})")
|
|
177
|
+
n_params = len(p0)
|
|
178
|
+
if n_params == 0:
|
|
179
|
+
raise ValueError("p0 must contain at least one initial parameter value")
|
|
180
|
+
if xv.size <= n_params:
|
|
181
|
+
raise ValueError(f"need more points ({xv.size}) than parameters ({n_params})")
|
|
182
|
+
if not all(math.isfinite(v) for v in p0):
|
|
183
|
+
raise ValueError("p0 values must be finite")
|
|
184
|
+
dyv: NDArray[np.float64] | None = None
|
|
185
|
+
if dy is not None:
|
|
186
|
+
dyv = np.asarray(dy, dtype=float).ravel()
|
|
187
|
+
if dyv.size != yv.size:
|
|
188
|
+
raise ValueError(f"dy must match y length (got {dyv.size} and {yv.size})")
|
|
189
|
+
if not np.all(dyv > 0):
|
|
190
|
+
raise ValueError("dy values must all be positive")
|
|
191
|
+
lb = list(lower) if lower is not None else [-math.inf] * n_params
|
|
192
|
+
ub = list(upper) if upper is not None else [math.inf] * n_params
|
|
193
|
+
if len(lb) != n_params or len(ub) != n_params:
|
|
194
|
+
raise ValueError("lower/upper bounds must match the number of parameters")
|
|
195
|
+
|
|
196
|
+
model_fcn, display_names = _resolve_model(model, param_names, n_params)
|
|
197
|
+
fn, internal = _wrap_model(model_fcn, n_params)
|
|
198
|
+
|
|
199
|
+
init = dict(zip(internal, [float(v) for v in p0], strict=True))
|
|
200
|
+
curve = Curve(fn, xv, yv, dyv, name="", **init)
|
|
201
|
+
for nm, lo, hi in zip(internal, lb, ub, strict=True):
|
|
202
|
+
curve.pars[nm].range(float(lo), float(hi))
|
|
203
|
+
problem = FitProblem(curve)
|
|
204
|
+
|
|
205
|
+
fitclass = next(f for f in FITTERS if f.id == engine)
|
|
206
|
+
options: dict[str, Any] = {}
|
|
207
|
+
monitors: list[Any] = []
|
|
208
|
+
if engine == "dream":
|
|
209
|
+
options = {"samples": int(samples), "burn": int(burn), "pop": int(pop)}
|
|
210
|
+
if progress_callback is not None:
|
|
211
|
+
pop_size = int(math.ceil(pop * n_params))
|
|
212
|
+
total = int(burn) + -(-int(samples) // max(1, pop_size))
|
|
213
|
+
monitors.append(_ProgressMonitor(total, progress_callback))
|
|
214
|
+
driver = FitDriver(
|
|
215
|
+
fitclass=fitclass, problem=problem, monitors=monitors, abort_test=abort_check, **options
|
|
216
|
+
)
|
|
217
|
+
driver.clip() # start inside the bounds
|
|
218
|
+
x_best, _fx = driver.fit()
|
|
219
|
+
if x_best is None: # aborted before the first iteration completed
|
|
220
|
+
x_best = problem.getp()
|
|
221
|
+
problem.setp(x_best)
|
|
222
|
+
|
|
223
|
+
# Map fitted values back to input parameter order via problem labels.
|
|
224
|
+
labels = [str(s) for s in problem.labels()]
|
|
225
|
+
order = [labels.index(nm) for nm in internal]
|
|
226
|
+
xb = np.asarray(x_best, dtype=float)
|
|
227
|
+
popt = [float(xb[i]) for i in order]
|
|
228
|
+
try:
|
|
229
|
+
dx = np.asarray(driver.stderr(), dtype=float)
|
|
230
|
+
uncertainties = [float(dx[i]) for i in order]
|
|
231
|
+
except Exception: # noqa: BLE001 — e.g. aborted dream with a near-empty state
|
|
232
|
+
uncertainties = [float("nan")] * n_params
|
|
233
|
+
|
|
234
|
+
y_fit = np.asarray(model_fcn(xv, np.asarray(popt, dtype=float)), dtype=float)
|
|
235
|
+
result: dict[str, Any] = {
|
|
236
|
+
"engine": engine,
|
|
237
|
+
"popt": popt,
|
|
238
|
+
"uncertainties": uncertainties,
|
|
239
|
+
"chisq": float(problem.chisq()),
|
|
240
|
+
"uncertainty_kind": "posterior" if engine == "dream" else "hessian",
|
|
241
|
+
"paramNames": display_names,
|
|
242
|
+
"yFit": y_fit,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if engine == "dream":
|
|
246
|
+
draw = driver.fitter.state.draw()
|
|
247
|
+
pts = np.asarray(draw.points, dtype=float)[:, order]
|
|
248
|
+
lo68, med, hi68 = (
|
|
249
|
+
np.asarray(np.percentile(pts, q, axis=0), dtype=float) for q in (16.0, 50.0, 84.0)
|
|
250
|
+
)
|
|
251
|
+
result["posterior"] = {
|
|
252
|
+
"medians": [float(v) for v in med],
|
|
253
|
+
"interval68": [[float(a), float(b)] for a, b in zip(lo68, hi68, strict=True)],
|
|
254
|
+
"n_draws": int(pts.shape[0]),
|
|
255
|
+
}
|
|
256
|
+
if return_samples:
|
|
257
|
+
result["samples"] = pts
|
|
258
|
+
return result
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Constraint expansion for linked/constrained curve-fit parameters.
|
|
2
|
+
|
|
3
|
+
Port of ``fitting.applyConstraints``. Pure: free-parameter values + per-parameter
|
|
4
|
+
constraint expressions → the full parameter vector. Constraint expressions are
|
|
5
|
+
evaluated with the safe equation parser (``calc.fit_equation.parse_equation``, no
|
|
6
|
+
``eval``). A parameter is *free* when its constraint string is empty, else it is
|
|
7
|
+
*computed* from the free parameters.
|
|
8
|
+
|
|
9
|
+
Within an expression, free parameters may be referenced as ``p1..pK`` (the 1-based
|
|
10
|
+
position among ALL parameters, rewritten to free-local indices here) or by name.
|
|
11
|
+
|
|
12
|
+
Faithful-to-MATLAB note: ``parse_equation`` indexes parameters by order of
|
|
13
|
+
appearance, and ``applyConstraints`` rewrites references with two sequential regex
|
|
14
|
+
passes (named, then positional). Both behaviours are replicated exactly, so the
|
|
15
|
+
result matches MATLAB even in the corner cases its rewrite does not fully
|
|
16
|
+
normalise (mixed/reindexed references).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from numpy.typing import ArrayLike, NDArray
|
|
25
|
+
|
|
26
|
+
from .fit_equation import parse_equation
|
|
27
|
+
|
|
28
|
+
__all__ = ["apply_constraints"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _rewrite_constraint_expr(expr: str, all_names: list[str], free_idx: list[int]) -> str:
|
|
32
|
+
"""Rewrite param references to free-local ``p1..pK`` indices (port of
|
|
33
|
+
rewriteConstraintExpr): named refs first (longest first), then positional
|
|
34
|
+
``p<global>``. Sequential, like MATLAB — do not reorder."""
|
|
35
|
+
m = len(all_names)
|
|
36
|
+
g2l = {gi: li + 1 for li, gi in enumerate(free_idx)} # global → local (1-based)
|
|
37
|
+
|
|
38
|
+
# 1) named references, longest names first (stable for ties, as MATLAB sort)
|
|
39
|
+
for gi in sorted(range(m), key=lambda i: len(all_names[i]), reverse=True):
|
|
40
|
+
if gi not in g2l or not all_names[gi]:
|
|
41
|
+
continue
|
|
42
|
+
pat = r"(?<![A-Za-z0-9_])" + re.escape(all_names[gi]) + r"(?![A-Za-z0-9_])"
|
|
43
|
+
expr = re.sub(pat, f"p{g2l[gi]}", expr)
|
|
44
|
+
|
|
45
|
+
# 2) positional p<global 1-based> → p<local> (after names, ascending global)
|
|
46
|
+
for gi in range(m):
|
|
47
|
+
if gi not in g2l:
|
|
48
|
+
continue
|
|
49
|
+
pat = r"(?<![A-Za-z0-9_])p" + str(gi + 1) + r"(?![0-9])"
|
|
50
|
+
expr = re.sub(pat, f"p{g2l[gi]}", expr)
|
|
51
|
+
|
|
52
|
+
return expr
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def apply_constraints(
|
|
56
|
+
p_free: ArrayLike,
|
|
57
|
+
constraints: list[str],
|
|
58
|
+
all_param_names: list[str],
|
|
59
|
+
) -> tuple[NDArray[np.float64], list[int]]:
|
|
60
|
+
"""Expand free parameters to the full vector using constraint expressions.
|
|
61
|
+
|
|
62
|
+
Port of ``fitting.applyConstraints``. ``constraints[i]`` is ``""`` for a free
|
|
63
|
+
parameter or an expression for a computed one. Returns ``(p_full, free_idx)``
|
|
64
|
+
where ``free_idx`` are the 0-based positions of the free parameters (MATLAB
|
|
65
|
+
returns 1-based). Raises ``ValueError`` on size mismatch, when a constraint
|
|
66
|
+
references another constrained parameter, or on parse/eval failure.
|
|
67
|
+
"""
|
|
68
|
+
pf = np.asarray(p_free, dtype=float).ravel()
|
|
69
|
+
m = len(constraints)
|
|
70
|
+
if len(all_param_names) != m:
|
|
71
|
+
raise ValueError("constraints and all_param_names must have the same length")
|
|
72
|
+
|
|
73
|
+
is_constrained = [bool(c.strip()) for c in constraints]
|
|
74
|
+
free_idx = [i for i in range(m) if not is_constrained[i]]
|
|
75
|
+
k = len(free_idx)
|
|
76
|
+
if k == 0 and pf.size:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"all {m} parameters are constrained — p_free must be empty when nothing is free"
|
|
79
|
+
)
|
|
80
|
+
if pf.size != k:
|
|
81
|
+
raise ValueError(f"p_free has {pf.size} elements but {k} free parameters found")
|
|
82
|
+
|
|
83
|
+
p_full = np.full(m, np.nan)
|
|
84
|
+
for li, gi in enumerate(free_idx):
|
|
85
|
+
p_full[gi] = pf[li]
|
|
86
|
+
|
|
87
|
+
constrained_names = [all_param_names[i] for i in range(m) if is_constrained[i]]
|
|
88
|
+
for kk in range(m):
|
|
89
|
+
expr = constraints[kk].strip()
|
|
90
|
+
if not expr:
|
|
91
|
+
continue
|
|
92
|
+
rewritten = _rewrite_constraint_expr(expr, all_param_names, free_idx)
|
|
93
|
+
|
|
94
|
+
# A residual constrained-name reference means a constraint depends on a
|
|
95
|
+
# non-free parameter — unsupported (would be circular).
|
|
96
|
+
for nm in constrained_names:
|
|
97
|
+
if not nm:
|
|
98
|
+
continue
|
|
99
|
+
if re.search(r"(?<![A-Za-z0-9_])" + re.escape(nm) + r"(?![A-Za-z0-9_])", rewritten):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f'constraint for "{all_param_names[kk]}" references "{nm}" which is '
|
|
102
|
+
f"itself constrained; only free parameters may appear in constraints"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
fcn, _ = parse_equation(rewritten)
|
|
107
|
+
val = fcn(0.0, pf)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f'failed to evaluate constraint for "{all_param_names[kk]}" ("{expr}"): {exc}'
|
|
111
|
+
) from exc
|
|
112
|
+
p_full[kk] = float(np.asarray(val, dtype=float).ravel()[0])
|
|
113
|
+
|
|
114
|
+
return p_full, free_idx
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Safe equation-string parser for custom fit models. Port of fitting.parseEquation.
|
|
2
|
+
|
|
3
|
+
Pure calc layer. Tokenizes an expression (e.g. ``a*exp(-x/b)+c``), converts to RPN
|
|
4
|
+
via the shunting-yard algorithm, and evaluates by **interpreting the RPN stack** —
|
|
5
|
+
NO eval/exec/str2func (per the no-eval rule; this is safer than the MATLAB source
|
|
6
|
+
which compiled via str2func). Parameters are the free identifiers (not x / known
|
|
7
|
+
functions / constants), returned in order of first appearance.
|
|
8
|
+
|
|
9
|
+
``equation_model`` / ``default_guesses`` bridge a parsed equation into the
|
|
10
|
+
standard bounded-NLLS fit path (``calc.fitting.curve_fit``) for the custom
|
|
11
|
+
fit-model builder (GOTO #1).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from numpy.typing import ArrayLike, NDArray
|
|
21
|
+
from scipy.special import erf, erfc
|
|
22
|
+
|
|
23
|
+
__all__ = ["default_guesses", "equation_model", "parse_equation"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _coth(a: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
27
|
+
return np.asarray(1.0 / np.tanh(a), dtype=float)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _matlab_round(a: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
31
|
+
return np.asarray(np.sign(a) * np.floor(np.abs(a) + 0.5), dtype=float)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_FUNCS: dict[str, Callable[[Any], Any]] = {
|
|
35
|
+
"exp": np.exp, "log": np.log, "log10": np.log10, "sqrt": np.sqrt, "abs": np.abs,
|
|
36
|
+
"sin": np.sin, "cos": np.cos, "tan": np.tan, "asin": np.arcsin, "acos": np.arccos,
|
|
37
|
+
"atan": np.arctan, "sinh": np.sinh, "cosh": np.cosh, "tanh": np.tanh, "coth": _coth,
|
|
38
|
+
"erf": erf, "erfc": erfc, "sign": np.sign, "floor": np.floor, "ceil": np.ceil,
|
|
39
|
+
"round": _matlab_round,
|
|
40
|
+
}
|
|
41
|
+
_CONSTS = {"pi": float(np.pi), "e": float(np.e)}
|
|
42
|
+
_PREC = {"+": 1, "-": 1, "*": 2, "/": 2, "^": 3}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tokenize(s: str) -> tuple[list[dict[str, Any]], list[str]]:
|
|
46
|
+
tokens: list[dict[str, Any]] = []
|
|
47
|
+
params: list[str] = []
|
|
48
|
+
pos, n = 0, len(s)
|
|
49
|
+
prev = "start"
|
|
50
|
+
while pos < n:
|
|
51
|
+
ch = s[pos]
|
|
52
|
+
if ch in " \t":
|
|
53
|
+
pos += 1
|
|
54
|
+
continue
|
|
55
|
+
if ch.isdigit() or (ch == "." and pos + 1 < n and s[pos + 1].isdigit()):
|
|
56
|
+
start = pos
|
|
57
|
+
while pos < n and (s[pos].isdigit() or s[pos] == "."):
|
|
58
|
+
pos += 1
|
|
59
|
+
if pos < n and s[pos] in "eE":
|
|
60
|
+
pos += 1
|
|
61
|
+
if pos < n and s[pos] in "+-":
|
|
62
|
+
pos += 1
|
|
63
|
+
while pos < n and s[pos].isdigit():
|
|
64
|
+
pos += 1
|
|
65
|
+
tokens.append({"type": "number", "value": float(s[start:pos])})
|
|
66
|
+
prev = "value"
|
|
67
|
+
continue
|
|
68
|
+
if ch.isalpha() or ch == "_":
|
|
69
|
+
start = pos
|
|
70
|
+
while pos < n and (s[pos].isalnum() or s[pos] == "_"):
|
|
71
|
+
pos += 1
|
|
72
|
+
name = s[start:pos]
|
|
73
|
+
if name in _FUNCS:
|
|
74
|
+
tokens.append({"type": "function", "value": name})
|
|
75
|
+
prev = "function"
|
|
76
|
+
continue
|
|
77
|
+
# A non-function identifier directly followed by "(" is a call of
|
|
78
|
+
# an unknown symbol (there is no implicit multiplication in this
|
|
79
|
+
# grammar) -- reject it with a clear message instead of letting it
|
|
80
|
+
# fail the arity check with a generic one.
|
|
81
|
+
look = pos
|
|
82
|
+
while look < n and s[look] in " \t":
|
|
83
|
+
look += 1
|
|
84
|
+
if look < n and s[look] == "(":
|
|
85
|
+
if name in _CONSTS or name == "x":
|
|
86
|
+
raise ValueError(f'"{name}" cannot be called as a function')
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f'Unknown function "{name}". Known functions: '
|
|
89
|
+
+ ", ".join(sorted(_FUNCS))
|
|
90
|
+
)
|
|
91
|
+
if name in _CONSTS:
|
|
92
|
+
tokens.append({"type": "number", "value": _CONSTS[name]})
|
|
93
|
+
prev = "value"
|
|
94
|
+
elif name == "x":
|
|
95
|
+
tokens.append({"type": "x", "value": "x"})
|
|
96
|
+
prev = "value"
|
|
97
|
+
else:
|
|
98
|
+
if name not in params:
|
|
99
|
+
params.append(name)
|
|
100
|
+
tokens.append({"type": "param", "value": params.index(name)})
|
|
101
|
+
prev = "value"
|
|
102
|
+
continue
|
|
103
|
+
if ch in "+-*/^()":
|
|
104
|
+
if ch == "(":
|
|
105
|
+
tokens.append({"type": "lparen", "value": "("})
|
|
106
|
+
prev = "lparen"
|
|
107
|
+
elif ch == ")":
|
|
108
|
+
tokens.append({"type": "rparen", "value": ")"})
|
|
109
|
+
prev = "value"
|
|
110
|
+
elif ch == "-" and prev in ("start", "lparen", "operator"):
|
|
111
|
+
tokens.append({"type": "number", "value": 0.0})
|
|
112
|
+
tokens.append({"type": "operator", "value": "-"})
|
|
113
|
+
prev = "operator"
|
|
114
|
+
elif ch == "+" and prev in ("start", "lparen", "operator"):
|
|
115
|
+
prev = "operator"
|
|
116
|
+
else:
|
|
117
|
+
tokens.append({"type": "operator", "value": ch})
|
|
118
|
+
prev = "operator"
|
|
119
|
+
pos += 1
|
|
120
|
+
continue
|
|
121
|
+
raise ValueError(f'Unexpected character "{ch}" at position {pos}')
|
|
122
|
+
return tokens, params
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _should_pop(stack_op: str, new_op: str) -> bool:
|
|
126
|
+
if stack_op not in _PREC:
|
|
127
|
+
return False
|
|
128
|
+
sp, np_ = _PREC[stack_op], _PREC[new_op]
|
|
129
|
+
return sp > np_ if new_op == "^" else sp >= np_
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _to_rpn(tokens: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
133
|
+
rpn: list[dict[str, Any]] = []
|
|
134
|
+
op_stack: list[dict[str, Any]] = []
|
|
135
|
+
for tok in tokens:
|
|
136
|
+
ttype = tok["type"]
|
|
137
|
+
if ttype in ("number", "x", "param"):
|
|
138
|
+
rpn.append(tok)
|
|
139
|
+
elif ttype == "function":
|
|
140
|
+
op_stack.append(tok)
|
|
141
|
+
elif ttype == "operator":
|
|
142
|
+
while (op_stack and op_stack[-1]["type"] == "operator"
|
|
143
|
+
and _should_pop(op_stack[-1]["value"], tok["value"])):
|
|
144
|
+
rpn.append(op_stack.pop())
|
|
145
|
+
op_stack.append(tok)
|
|
146
|
+
elif ttype == "lparen":
|
|
147
|
+
op_stack.append(tok)
|
|
148
|
+
elif ttype == "rparen":
|
|
149
|
+
while op_stack and op_stack[-1]["type"] != "lparen":
|
|
150
|
+
rpn.append(op_stack.pop())
|
|
151
|
+
if not op_stack:
|
|
152
|
+
raise ValueError("Mismatched parentheses.")
|
|
153
|
+
op_stack.pop() # discard '('
|
|
154
|
+
if op_stack and op_stack[-1]["type"] == "function":
|
|
155
|
+
rpn.append(op_stack.pop())
|
|
156
|
+
while op_stack:
|
|
157
|
+
if op_stack[-1]["type"] == "lparen":
|
|
158
|
+
raise ValueError("Mismatched parentheses.")
|
|
159
|
+
rpn.append(op_stack.pop())
|
|
160
|
+
return rpn
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_arity(rpn: list[dict[str, Any]]) -> None:
|
|
164
|
+
"""Reject RPN that would under/overflow the eval stack (e.g. ``a +`` or
|
|
165
|
+
``a b``). MATLAB's parseEquation compiled via str2func, so malformed input
|
|
166
|
+
errored there at compile time; erroring at parse time matches that intent
|
|
167
|
+
(the interpreter would otherwise crash or silently drop operands)."""
|
|
168
|
+
depth = 0
|
|
169
|
+
for tok in rpn:
|
|
170
|
+
ttype = tok["type"]
|
|
171
|
+
if ttype in ("number", "x", "param"):
|
|
172
|
+
depth += 1
|
|
173
|
+
elif ttype == "operator":
|
|
174
|
+
if depth < 2:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f'operator "{tok["value"]}" is missing an operand'
|
|
177
|
+
)
|
|
178
|
+
depth -= 1
|
|
179
|
+
elif ttype == "function":
|
|
180
|
+
if depth < 1:
|
|
181
|
+
raise ValueError(f'function "{tok["value"]}" is missing its argument')
|
|
182
|
+
if depth != 1:
|
|
183
|
+
raise ValueError("malformed expression (check operators and parentheses)")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _eval_rpn(rpn: list[dict[str, Any]], x: NDArray[np.float64], p: NDArray[np.float64]) -> Any:
|
|
187
|
+
stack: list[Any] = []
|
|
188
|
+
for tok in rpn:
|
|
189
|
+
ttype = tok["type"]
|
|
190
|
+
if ttype == "number":
|
|
191
|
+
stack.append(tok["value"])
|
|
192
|
+
elif ttype == "x":
|
|
193
|
+
stack.append(x)
|
|
194
|
+
elif ttype == "param":
|
|
195
|
+
stack.append(p[tok["value"]])
|
|
196
|
+
elif ttype == "operator":
|
|
197
|
+
b = stack.pop()
|
|
198
|
+
a = stack.pop()
|
|
199
|
+
op = tok["value"]
|
|
200
|
+
if op == "+":
|
|
201
|
+
stack.append(a + b)
|
|
202
|
+
elif op == "-":
|
|
203
|
+
stack.append(a - b)
|
|
204
|
+
elif op == "*":
|
|
205
|
+
stack.append(a * b)
|
|
206
|
+
elif op == "/":
|
|
207
|
+
stack.append(a / b)
|
|
208
|
+
else: # ^
|
|
209
|
+
stack.append(a**b)
|
|
210
|
+
elif ttype == "function":
|
|
211
|
+
stack.append(_FUNCS[tok["value"]](stack.pop()))
|
|
212
|
+
return stack[0]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_equation(
|
|
216
|
+
eqn_str: str,
|
|
217
|
+
) -> tuple[Callable[[ArrayLike, ArrayLike], NDArray[np.float64]], list[str]]:
|
|
218
|
+
"""Parse ``eqn_str`` into ``(fcn, param_names)``. Port of fitting.parseEquation.
|
|
219
|
+
|
|
220
|
+
``fcn(x, p)`` evaluates the expression (``p`` indexed in ``param_names`` order).
|
|
221
|
+
Strips a leading ``y =`` / ``f(x) =``. Safe: RPN is interpreted, never eval'd.
|
|
222
|
+
"""
|
|
223
|
+
import re
|
|
224
|
+
|
|
225
|
+
expr = re.sub(r"^\s*(y|f\(x\))\s*=\s*", "", eqn_str.strip())
|
|
226
|
+
if not expr:
|
|
227
|
+
raise ValueError("Equation string is empty.")
|
|
228
|
+
tokens, param_names = _tokenize(expr)
|
|
229
|
+
rpn = _to_rpn(tokens)
|
|
230
|
+
_check_arity(rpn)
|
|
231
|
+
|
|
232
|
+
def fcn(x: ArrayLike, p: ArrayLike) -> NDArray[np.float64]:
|
|
233
|
+
xv = np.asarray(x, dtype=float).ravel()
|
|
234
|
+
pv = np.asarray(p, dtype=float).ravel()
|
|
235
|
+
y = _eval_rpn(rpn, xv, pv)
|
|
236
|
+
out = np.broadcast_to(np.asarray(y, dtype=float), xv.shape)
|
|
237
|
+
return np.asarray(out, dtype=float).copy()
|
|
238
|
+
|
|
239
|
+
return fcn, param_names
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def equation_model(
|
|
243
|
+
eqn_str: str,
|
|
244
|
+
) -> tuple[Callable[[ArrayLike, ArrayLike], NDArray[np.float64]], list[str]]:
|
|
245
|
+
"""Parse ``eqn_str`` and vet it as a *fit model*: ``(model_fcn, param_names)``.
|
|
246
|
+
|
|
247
|
+
On top of ``parse_equation`` this rejects parameter names that start with
|
|
248
|
+
an underscore (MATLAB identifiers cannot, and it shuts the door on dunder
|
|
249
|
+
junk like ``__import__`` ever naming a parameter). The returned callable
|
|
250
|
+
plugs straight into ``calc.fitting.curve_fit`` as ``model_fcn``.
|
|
251
|
+
"""
|
|
252
|
+
fcn, param_names = parse_equation(eqn_str)
|
|
253
|
+
for name in param_names:
|
|
254
|
+
if name.startswith("_"):
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f'invalid parameter name "{name}": parameters must start with a letter'
|
|
257
|
+
)
|
|
258
|
+
return fcn, param_names
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def default_guesses(param_names: Sequence[str]) -> list[float]:
|
|
262
|
+
"""Default starting guesses for an equation model: 1.0 per parameter
|
|
263
|
+
(the conventional neutral start when nothing is known about scale)."""
|
|
264
|
+
return [1.0] * len(param_names)
|