Coverage for graphica/core/fit_models.py: 99.3%
222 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""曲線フィットのモデルの表。種類の判定・パラメータ名・関数・初期値の推定をここ 1 か所に持つ。
3種類は表示名(例「ガウシアン (y = …)」)で渡され、保存ファイルと方法の文章にもそのまま入る。判定の順は、
4プラグインの関数(名前の完全一致)→ カスタム数式 → 組み込み(語の部分一致、表の順:「2成分指数」を「指数関数」より、
5「擬似フォークト」を「フォークト」より、「ボルツマン」を「シグモイド」より先に)。プラグインを先に見ないと、
6「二重ガウシアン」のような名前が黙って組み込みのガウシアンになる。
7初期値の式は結果の popt が 1 ビットも変わらないよう、演算の順まで変えないこと。
8"""
9import re
10from dataclasses import dataclass
11from typing import Any, Callable, Mapping
13import numpy as np
14from scipy.special import wofz
16from graphica.core.safe_eval import DEFAULT_FUNCTIONS, safe_eval_formula
18CUSTOM_FORMULA_KEYWORD = "カスタム数式"
19CUSTOM_FORMULA_LABEL = "カスタム数式..."
20CUSTOM_FORMULA_MODEL_ID = "custom_formula"
21PLUGIN_MODEL_ID_PREFIX = "plugin:"
24def is_custom_formula_type(fit_type: str) -> bool:
25 return CUSTOM_FORMULA_KEYWORD in fit_type
28def _linear(x: Any, a: float, b: float) -> Any:
29 return a * x + b
32def _poly2(x: Any, a: float, b: float, c: float) -> Any:
33 return a * x**2 + b * x + c
36def _poly3(x: Any, a: float, b: float, c: float, d: float) -> Any:
37 return a * x**3 + b * x**2 + c * x + d
40def _exponential(x: Any, a: float, b: float) -> Any:
41 return a * np.exp(b * x)
44def _logarithmic(x: Any, a: float, b: float) -> Any:
45 return a * np.log(x) + b
48def _power(x: Any, a: float, b: float) -> Any:
49 return a * np.power(x, b)
52def _gaussian(x: Any, a: float, b: float, c: float, d: float) -> Any:
53 return a * np.exp(-((x - b) ** 2) / (2 * c ** 2)) + d
56def _sigmoid(x: Any, a: float, b: float, c: float) -> Any:
57 return a / (1 + np.exp(-b * (x - c)))
60def _two_exponentials(x: Any, a1: float, b1: float, a2: float, b2: float, c: float) -> Any:
61 return a1 * np.exp(b1 * x) + a2 * np.exp(b2 * x) + c
64def _lorentzian(x: Any, a: float, b: float, c: float, d: float) -> Any:
65 return a / (1 + ((x - b) / c) ** 2) + d
68def _pseudo_voigt(x: Any, a: float, b: float, c: float, eta: float, d: float) -> Any:
69 # 共通の中心 b と FWHM c を持つローレンツ型とガウス型を eta で混ぜる
70 lorentzian_shape = 1 / (1 + ((x - b) / c) ** 2)
71 gaussian_shape = np.exp(-4 * np.log(2) * ((x - b) / c) ** 2)
72 return a * (eta * lorentzian_shape + (1 - eta) * gaussian_shape) + d
75def _voigt(x: Any, a: float, b: float, sigma: float, gamma: float, d: float) -> Any:
76 # Faddeeva 関数による Voigt。この正規化で gamma→0 のとき a がピーク高さになる
77 z = ((x - b) + 1j * gamma) / (sigma * np.sqrt(2))
78 return a * np.real(wofz(z)) / (sigma * np.sqrt(2 * np.pi)) + d
81def _boltzmann_sigmoid(x: Any, a1: float, a2: float, x0: float, dx: float) -> Any:
82 return a2 + (a1 - a2) / (1 + np.exp((x - x0) / dx))
85def _hill(x: Any, vmax: float, k: float, n: float) -> Any:
86 return (vmax * np.power(x, n)) / (np.power(k, n) + np.power(x, n))
89def _estimate_fwhm(x_arr: Any, y_arr: Any, amplitude: float) -> float:
90 """半値を横切る X の幅を FWHM の粗い推定にする(裾の広い形で誤った局所解に落ちないため)。"""
91 half_level = np.nanmin(y_arr) + amplitude / 2
92 above_half = x_arr[y_arr >= half_level]
93 if len(above_half) == 0: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 return (np.nanmax(x_arr) - np.nanmin(x_arr)) / 4 or 1.0
95 return (above_half.max() - above_half.min()) or 1.0
98def _p0_linear(x: Any, y: Any) -> list[Any]:
99 return [1.0, 0.0]
102def _p0_poly2(x: Any, y: Any) -> list[Any]:
103 return [1.0, 1.0, 0.0]
106def _p0_poly3(x: Any, y: Any) -> list[Any]:
107 return [1.0, 1.0, 1.0, 0.0]
110def _p0_two_exponentials(x: Any, y: Any) -> list[Any]:
111 amplitude = (np.nanmax(y) - np.nanmin(y)) / 2 or 1.0
112 x_span = (np.nanmax(x) - np.nanmin(x)) or 1.0
113 # 2成分が同じ初期値だと縮退して収束しないので、符号の違う率から始める
114 rate0 = 2.0 / x_span
115 return [amplitude, rate0, amplitude, -rate0, np.nanmin(y)]
118def _p0_exponential(x: Any, y: Any) -> list[Any]:
119 amplitude = np.nanmean(np.abs(y)) or 1.0
120 return [amplitude, 0.01]
123def _p0_logarithmic(x: Any, y: Any) -> list[Any]:
124 return [1.0, 0.0]
127def _p0_power(x: Any, y: Any) -> list[Any]:
128 return [1.0, 1.0]
131def _peak_amplitude_and_center(x: Any, y: Any) -> tuple[Any, Any]:
132 amplitude = (np.nanmax(y) - np.nanmin(y)) or 1.0
133 center = x[np.nanargmax(y)] if len(x) else 0.0
134 return amplitude, center
137def _p0_gaussian(x: Any, y: Any) -> list[Any]:
138 amplitude, center = _peak_amplitude_and_center(x, y)
139 width = (np.nanmax(x) - np.nanmin(x)) / 4 or 1.0
140 return [amplitude, center, width, np.nanmin(y)]
143def _p0_lorentzian(x: Any, y: Any) -> list[Any]:
144 amplitude, center = _peak_amplitude_and_center(x, y)
145 # c は HWHM なので FWHM の半分
146 fwhm0 = _estimate_fwhm(x, y, amplitude)
147 return [amplitude, center, fwhm0 / 2, np.nanmin(y)]
150def _p0_pseudo_voigt(x: Any, y: Any) -> list[Any]:
151 amplitude, center = _peak_amplitude_and_center(x, y)
152 # この定義の c は FWHM そのもの
153 fwhm0 = _estimate_fwhm(x, y, amplitude)
154 return [amplitude, center, fwhm0, 0.5, np.nanmin(y)]
157def _p0_voigt(x: Any, y: Any) -> list[Any]:
158 amplitude, center = _peak_amplitude_and_center(x, y)
159 fwhm0 = _estimate_fwhm(x, y, amplitude)
160 # FWHM をガウス成分とローレンツ成分に大まかに割り振る経験的な初期値
161 sigma0 = (fwhm0 / 2.355) or 1.0
162 gamma0 = (fwhm0 / 4) or 1.0
163 # _voigt の正規化に合わせ、ピーク高さが振幅に近くなるよう a をスケールする
164 return [amplitude * sigma0 * np.sqrt(2 * np.pi), center, sigma0, gamma0, np.nanmin(y)]
167def _p0_boltzmann_sigmoid(x: Any, y: Any) -> list[Any]:
168 order = np.argsort(x)
169 x_sorted, y_sorted = x[order], y[order]
170 y_start = y_sorted[0]
171 y_end = y_sorted[-1]
172 mid_level = (y_start + y_end) / 2
173 # 遷移の中心は Y の中点を最初に横切る X から推定する(X の平均だと局所解に落ちやすい)
174 crossing_mask = y_sorted < mid_level if y_start >= y_end else y_sorted > mid_level
175 crossing_indices = np.flatnonzero(crossing_mask)
176 x0 = x_sorted[crossing_indices[0]] if len(crossing_indices) else np.nanmean(x)
177 dx0 = (np.nanmax(x) - np.nanmin(x)) / 10 or 1.0
178 return [y_start, y_end, x0, dx0]
181def _p0_sigmoid(x: Any, y: Any) -> list[Any]:
182 amplitude = np.nanmax(y) or 1.0
183 return [amplitude, 1.0, np.nanmean(x)]
186def _p0_hill(x: Any, y: Any) -> list[Any]:
187 vmax0 = np.nanmax(y) or 1.0
188 positive_x = x[x > 0]
189 k0 = np.nanmedian(positive_x) if len(positive_x) else 1.0
190 return [vmax0, k0, 1.0]
193def _require_positive_x(message: str) -> Callable[[Any], None]:
194 def check(x: Any) -> None:
195 if np.any(x <= 0):
196 raise ValueError(message)
197 return check
200def _require_non_negative_x(x: Any) -> None:
201 if np.any(x < 0):
202 raise ValueError("ヒル式は X >= 0 のデータにのみ使用できます。")
205@dataclass(frozen=True)
206class FitModel:
207 model_id: str # 保存用の ID(fit_result['fit_model_id'])。一度決めたら変えない
208 keyword: str # 種類の名前にこの語が含まれればこのモデル
209 menu_label: str # ダイアログの選択肢(= 保存される fit_type)
210 param_names: tuple[str, ...]
211 func: Callable[..., Any]
212 initial_guess: Callable[[Any, Any], list[Any]]
213 check_data: Callable[[Any], None] | None = None # 使えないデータなら ValueError
216# 判定の順。ダイアログの並びは MENU_ORDER。
217BUILTIN_FIT_MODELS: tuple[FitModel, ...] = (
218 FitModel("linear", "線形", "線形 (y = ax + b)", ("a", "b"), _linear, _p0_linear),
219 FitModel("poly2", "2次多項式", "2次多項式 (y = ax^2 + bx + c)", ("a", "b", "c"), _poly2, _p0_poly2),
220 FitModel("poly3", "3次多項式", "3次多項式 (y = ax^3 + bx^2 + cx + d)", ("a", "b", "c", "d"), _poly3, _p0_poly3),
221 FitModel("two_exponentials", "2成分指数", "2成分指数関数 (y = a1*exp(b1*x) + a2*exp(b2*x) + c)", ("a1", "b1", "a2", "b2", "c"),
222 _two_exponentials, _p0_two_exponentials),
223 FitModel("exponential", "指数関数", "指数関数 (y = a * exp(bx))", ("a", "b"), _exponential, _p0_exponential),
224 FitModel("logarithmic", "対数", "対数 (y = a * ln(x) + b)", ("a", "b"), _logarithmic, _p0_logarithmic,
225 _require_positive_x("対数フィットは X > 0 のデータにのみ使用できます。")),
226 FitModel("power", "べき乗", "べき乗 (y = a * x^b)", ("a", "b"), _power, _p0_power,
227 _require_positive_x("べき乗フィットは X > 0 のデータにのみ使用できます。")),
228 FitModel("gaussian", "ガウシアン", "ガウシアン (y = a * exp(-(x-b)^2 / (2c^2)) + d)", ("a", "b", "c", "d"),
229 _gaussian, _p0_gaussian),
230 FitModel("lorentzian", "ローレンツ", "ローレンツ関数 (y = a / (1 + ((x-b)/c)^2) + d)", ("a", "b", "c", "d"),
231 _lorentzian, _p0_lorentzian),
232 FitModel("pseudo_voigt", "擬似フォークト", "擬似フォークト関数 (y = a*(η/(1+((x-b)/c)^2) + (1-η)*exp(-4ln2*((x-b)/c)^2)) + d)",
233 ("a", "b", "c", "eta", "d"), _pseudo_voigt, _p0_pseudo_voigt),
234 FitModel("voigt", "フォークト", "フォークト関数 (y = a*Re[wofz((x-b+iγ)/(σ√2))] / (σ√(2π)) + d)",
235 ("a", "b", "sigma", "gamma", "d"), _voigt, _p0_voigt),
236 FitModel("boltzmann_sigmoid", "ボルツマン", "ボルツマンシグモイド (y = a2 + (a1-a2) / (1 + exp((x-x0)/dx)))",
237 ("a1", "a2", "x0", "dx"), _boltzmann_sigmoid, _p0_boltzmann_sigmoid),
238 FitModel("sigmoid", "シグモイド", "シグモイド (y = a / (1 + exp(-b(x-c))))", ("a", "b", "c"), _sigmoid, _p0_sigmoid),
239 FitModel("hill", "ヒル", "ヒルの式 (y = vmax*x^n / (k^n + x^n))", ("vmax", "k", "n"), _hill, _p0_hill,
240 _require_non_negative_x),
241)
243MENU_ORDER: tuple[str, ...] = (
244 "線形", "2次多項式", "3次多項式", "指数関数", "対数", "べき乗", "ガウシアン", "ローレンツ",
245 "擬似フォークト", "フォークト", "2成分指数", "ボルツマン", "シグモイド", "ヒル",
246)
248# プラグインの関数名がこれらと同じだと、保存済みの組み込みの種類を取り違えるので、登録を断る
249RESERVED_FIT_TYPE_NAMES: tuple[str, ...] = (CUSTOM_FORMULA_KEYWORD,) + tuple(m.keyword for m in BUILTIN_FIT_MODELS)
252def builtin_menu_labels() -> list[str]:
253 by_keyword = {m.keyword: m for m in BUILTIN_FIT_MODELS}
254 return [by_keyword[keyword].menu_label for keyword in MENU_ORDER]
257_RESERVED_FORMULA_NAMES = set(DEFAULT_FUNCTIONS.keys()) | {'x'}
260def extract_formula_params(formula: str) -> list[str]:
261 """x でも既知の関数名でもない識別子を、出現順にパラメータとして取り出す。"""
262 params = []
263 for name in re.findall(r'[a-zA-Z_][a-zA-Z_0-9]*', formula):
264 if name in _RESERVED_FORMULA_NAMES or name in params:
265 continue
266 params.append(name)
267 if not params:
268 raise ValueError("数式にフィットパラメータ(x以外の文字)が見つかりません。")
269 return params
272def build_custom_fit_func(formula: str, param_names: list[str]) -> Callable[..., Any]:
273 def custom_func(x: Any, *params: float) -> Any:
274 variables = {'x': x}
275 variables.update(zip(param_names, params))
276 try:
277 return safe_eval_formula(formula, variables)
278 except Exception as e:
279 raise ValueError(f"数式の評価に失敗しました: {e}") from e
280 return custom_func
283@dataclass
284class ResolvedFitModel:
285 """種類の名前から決まったモデル。param_names はプラグインのときは登録されたリストそのもの。"""
286 param_names: list[str]
287 func: Callable[..., Any]
288 initial_guess: Callable[[Any, Any], list[Any]]
289 check_data: Callable[[Any], None] | None = None
292def _custom_formula_or_raise(custom_formula: str | None) -> str:
293 if not custom_formula or not custom_formula.strip():
294 raise ValueError("カスタム数式が入力されていません。")
295 return custom_formula
298_BUILTIN_BY_ID = {m.model_id: m for m in BUILTIN_FIT_MODELS}
301def _identify(fit_type: str, model_id: str | None,
302 plugin_functions: Mapping[str, dict[str, Any]]) -> tuple[str, Any] | None:
303 """("custom", None) / ("builtin", FitModel) / ("plugin", 名前)。ID が分かればそれを先に使う(表示名が変わっても同じモデル)。"""
304 if model_id:
305 if model_id == CUSTOM_FORMULA_MODEL_ID:
306 return ("custom", None)
307 if model_id in _BUILTIN_BY_ID:
308 return ("builtin", _BUILTIN_BY_ID[model_id])
309 plugin_name = model_id[len(PLUGIN_MODEL_ID_PREFIX):]
310 if model_id.startswith(PLUGIN_MODEL_ID_PREFIX) and plugin_name in plugin_functions:
311 return ("plugin", plugin_name)
312 if fit_type in plugin_functions:
313 return ("plugin", fit_type)
314 if is_custom_formula_type(fit_type):
315 return ("custom", None)
316 for model in BUILTIN_FIT_MODELS:
317 if model.keyword in fit_type:
318 return ("builtin", model)
319 return None
322def fit_model_id(fit_type: str, plugin_functions: Mapping[str, dict[str, Any]]) -> str | None:
323 found = _identify(fit_type, None, plugin_functions)
324 if found is None:
325 return None
326 kind, target = found
327 if kind == "custom":
328 return CUSTOM_FORMULA_MODEL_ID
329 if kind == "builtin":
330 return str(target.model_id)
331 return PLUGIN_MODEL_ID_PREFIX + target
334def fit_type_label(fit_result: Mapping[str, Any], default: Any = None) -> Any:
335 """保存したフィット結果の種類の表示名。組み込みは ID から今の表示名を引く(表示名を変えても古いファイルとそろう)。"""
336 model = _BUILTIN_BY_ID.get(fit_result.get('fit_model_id') or "")
337 return model.menu_label if model is not None else fit_result.get('fit_type', default)
340def resolve_fit_param_names(fit_type: str, custom_formula: str | None,
341 plugin_functions: Mapping[str, dict[str, Any]], model_id: str | None = None) -> list[str]:
342 """パラメータ名だけを決める(カスタム数式の関数は作らない)。"""
343 found = _identify(fit_type, model_id, plugin_functions)
344 if found is None:
345 raise ValueError(f"不明なフィットタイプ: {fit_type}")
346 kind, target = found
347 if kind == "custom":
348 return extract_formula_params(_custom_formula_or_raise(custom_formula))
349 if kind == "builtin":
350 return list(target.param_names)
351 return list(plugin_functions[target]["params"])
354def resolve_fit_model(fit_type: str, custom_formula: str | None,
355 plugin_functions: Mapping[str, dict[str, Any]], model_id: str | None = None) -> ResolvedFitModel:
356 found = _identify(fit_type, model_id, plugin_functions)
357 if found is None:
358 raise ValueError(f"不明なフィットタイプ: {fit_type}")
359 kind, target = found
360 if kind == "custom":
361 params = extract_formula_params(_custom_formula_or_raise(custom_formula))
362 # *params 形式ではパラメータ数を推定できないので p0 で数を伝える
363 return ResolvedFitModel(params, build_custom_fit_func(custom_formula or "", params),
364 lambda x, y: [1.0] * len(params))
365 if kind == "builtin":
366 return ResolvedFitModel(list(target.param_names), target.func, target.initial_guess, target.check_data)
367 entry = plugin_functions[target]
368 return ResolvedFitModel(entry["params"], entry["func"], _plugin_initial_guess(entry))
371def _plugin_initial_guess(entry: dict[str, Any]) -> Callable[[Any, Any], list[Any]]:
372 def initial_guess(x: Any, y: Any) -> list[Any]:
373 plugin_p0 = entry["p0"]
374 if callable(plugin_p0):
375 return list(plugin_p0(x, y))
376 if plugin_p0 is not None:
377 return list(plugin_p0)
378 return [1.0] * len(entry["params"])
379 return initial_guess