Coverage for graphica/core/analysis.py: 95.8%
744 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
1from typing import Any, Callable
2import numpy as np
3from scipy import sparse
4from scipy.sparse.linalg import spsolve
5from scipy.integrate import simpson, cumulative_trapezoid, cumulative_simpson
6from scipy.interpolate import CubicSpline
7from scipy.ndimage import uniform_filter1d, median_filter, gaussian_filter1d
8from scipy.optimize import curve_fit
9from scipy.signal import find_peaks, peak_widths, savgol_filter, correlate, correlation_lags
10from scipy.special import wofz
11from scipy.stats import gaussian_kde
13from graphica.core.fit_models import RESERVED_FIT_TYPE_NAMES, fit_model_id, resolve_fit_model, resolve_fit_param_names
15CURVE_FIT_MAX_ITERATIONS = 5000
17# プラグインのフィット関数。{name: {"func": f(x, *params), "params": [...], "p0": list | callable | None}}
18_PLUGIN_FIT_FUNCTIONS: dict[str, dict[str, Any]] = {}
21def register_fit_function(name: str, func: Callable[..., Any], param_names: list[str],
22 p0: list[float] | Callable[..., Any] | None = None) -> None:
23 """p0 は初期値のリストか (x_data, y_data) -> list を返す関数。省略時は全て 1.0。"""
24 if not name or not name.strip(): 24 ↛ 25line 24 didn't jump to line 25 because the condition on line 24 was never true
25 raise ValueError("フィット関数名が空です。")
26 if name in RESERVED_FIT_TYPE_NAMES:
27 raise ValueError(f"'{name}' は組み込みのフィットタイプ名と衝突します。")
28 if name in _PLUGIN_FIT_FUNCTIONS:
29 raise ValueError(f"フィット関数 '{name}' は既に登録されています。")
30 if not param_names:
31 raise ValueError("param_names が空です。")
32 _PLUGIN_FIT_FUNCTIONS[name] = {"func": func, "params": list(param_names), "p0": p0}
35def get_plugin_fit_type_names() -> list[str]:
36 return list(_PLUGIN_FIT_FUNCTIONS.keys())
39def get_fit_param_names(fit_type: str, custom_formula: str | None = None, model_id: str | None = None) -> list[str]:
40 """フィットせずにパラメータ名を返す(フィットの前に入力欄を組み立てるため)。model_id が分かればそれを優先する。"""
41 return resolve_fit_param_names(fit_type, custom_formula, _PLUGIN_FIT_FUNCTIONS, model_id)
44def get_fit_model_id(fit_type: str) -> str | None:
45 """保存用の安定した ID(組み込みは 'gaussian' など、カスタム数式、'plugin:<名前>')。分からなければ None。"""
46 return fit_model_id(fit_type, _PLUGIN_FIT_FUNCTIONS)
49_ROBUST_LOSS_FUNCTIONS = ('linear', 'soft_l1', 'huber')
52def _run_curve_fit_with_overrides(fit_func: Callable[..., Any], params_info: list[str], p0: Any, x_data: Any, y_data: Any,
53 sigma: Any, p0_overrides: dict[str, float] | None, fixed_params: dict[str, float] | None,
54 bounds: dict[str, tuple[float, float]] | None, fit_type_label: str,
55 loss: str = 'linear') -> tuple[np.ndarray, np.ndarray]:
56 """p0 の上書き・固定・範囲拘束を適用して curve_fit を実行する。
58 popt と pcov は固定パラメータも含めたフルサイズで返す(固定分の pcov は 0)。
59 """
60 if loss not in _ROBUST_LOSS_FUNCTIONS:
61 raise ValueError(f"未知の損失関数です(loss): '{loss}' (使用可能: {_ROBUST_LOSS_FUNCTIONS})")
62 if len(x_data) < len(params_info):
63 raise ValueError(
64 f"データ点数 ({len(x_data)}) がフィットに必要なパラメータ数 "
65 f"({len(params_info)}) より少ないため、フィッティングできません。"
66 )
68 p0_overrides = p0_overrides or {}
69 fixed_params = fixed_params or {}
70 bounds = bounds or {}
72 for name_dict, label in (
73 (p0_overrides, "p0_overrides"), (fixed_params, "fixed_params"), (bounds, "bounds"),
74 ):
75 for pname in name_dict:
76 if pname not in params_info:
77 raise ValueError(
78 f"未知のパラメータ名です({label}): '{pname}' "
79 f"(このフィットタイプのパラメータ: {params_info})"
80 )
82 if fixed_params and len(fixed_params) >= len(params_info):
83 raise ValueError(
84 "すべてのパラメータを固定することはできません"
85 "(最適化する自由パラメータが1つも残りません)。"
86 )
88 p0 = list(p0)
89 for i, name in enumerate(params_info):
90 if name in p0_overrides:
91 p0[i] = float(p0_overrides[name])
93 free_indices = [i for i, name in enumerate(params_info) if name not in fixed_params]
94 fixed_indices = [i for i, name in enumerate(params_info) if name in fixed_params]
95 fixed_values = {i: float(fixed_params[params_info[i]]) for i in fixed_indices}
97 if fixed_indices:
98 # curve_fit には自由パラメータだけを渡し、固定値は関数の中で元の位置に挿し込む
99 original_fit_func = fit_func
101 def fit_func_for_curve_fit(x: Any, *free_args: float) -> Any:
102 full_params: list[float | None] = [None] * len(params_info)
103 for i in fixed_indices:
104 full_params[i] = fixed_values[i]
105 for idx, i in enumerate(free_indices):
106 full_params[i] = free_args[idx]
107 return original_fit_func(x, *full_params)
109 p0_for_curve_fit = [p0[i] for i in free_indices]
110 else:
111 fit_func_for_curve_fit = fit_func
112 p0_for_curve_fit = p0
114 curve_fit_kwargs = {
115 "p0": p0_for_curve_fit,
116 "sigma": sigma,
117 "absolute_sigma": sigma is not None,
118 }
119 if bounds:
120 lower, upper = [], []
121 for i in free_indices:
122 lo, hi = bounds.get(params_info[i], (-np.inf, np.inf))
123 lower.append(lo)
124 upper.append(hi)
125 # bounds は p0 が境界の厳密に内側にあることを要求する(等しいだけで例外)。
126 # 「初期値=下限」のような自然な入力で落ちないよう、わずかに内側へずらす。
127 for idx in range(len(p0_for_curve_fit)):
128 lo, hi = lower[idx], upper[idx]
129 val = p0_for_curve_fit[idx]
130 if val <= lo or val >= hi:
131 span = hi - lo
132 nudge = span * 1e-6 if np.isfinite(span) and span > 0 else max(abs(val), 1.0) * 1e-6 or 1e-9
133 p0_for_curve_fit[idx] = min(max(val, lo + nudge), hi - nudge)
134 curve_fit_kwargs["bounds"] = (lower, upper)
135 # bounds 付きは trf 法になり、maxfev ではなく max_nfev しか受け付けない
136 curve_fit_kwargs["max_nfev"] = CURVE_FIT_MAX_ITERATIONS
137 else:
138 curve_fit_kwargs["maxfev"] = CURVE_FIT_MAX_ITERATIONS
140 if loss != 'linear':
141 # 既定の 'lm' は loss を受け付けないので trf にし、maxfev も max_nfev に付け替える
142 curve_fit_kwargs["loss"] = loss
143 curve_fit_kwargs.setdefault("method", "trf")
144 if "maxfev" in curve_fit_kwargs:
145 curve_fit_kwargs["max_nfev"] = curve_fit_kwargs.pop("maxfev")
147 try:
148 popt_free, pcov_free = curve_fit(fit_func_for_curve_fit, x_data, y_data, **curve_fit_kwargs)
149 except RuntimeError as e:
150 raise RuntimeError(
151 f"フィッティングが収束しませんでした({fit_type_label})。データの分布が"
152 f"このモデルに適していない可能性があります。詳細: {e}"
153 ) from e
155 if fixed_indices:
156 popt = np.empty(len(params_info))
157 for i in fixed_indices:
158 popt[i] = fixed_values[i]
159 for idx, i in enumerate(free_indices):
160 popt[i] = popt_free[idx]
162 pcov = np.zeros((len(params_info), len(params_info)))
163 for row_idx, i in enumerate(free_indices):
164 for col_idx, j in enumerate(free_indices):
165 pcov[i, j] = pcov_free[row_idx, col_idx]
166 else:
167 popt, pcov = popt_free, pcov_free
169 return popt, pcov
172def evaluate_saved_fit(fit_result: dict[str, Any], x: Any) -> Any:
173 """保存したフィット結果(Dataset.fit_result)の曲線の値を x で計算する。式が分からない・計算できないときは None。
175 Args:
176 fit_result: calculate_curve_fit の結果から作った、データセットに保存する形の辞書。
177 x: 値を求める X(数値の並び)。
179 Returns:
180 x と同じ長さの float の ndarray、または None。
181 """
182 try:
183 model = resolve_fit_model(fit_result.get('fit_type', ''), fit_result.get('custom_formula'),
184 _PLUGIN_FIT_FUNCTIONS, fit_result.get('fit_model_id'))
185 x_values = np.asarray(x, dtype=float)
186 values = np.asarray(model.func(x_values, *fit_result.get('params', [])), dtype=float)
187 return np.broadcast_to(values, x_values.shape).copy()
188 except (ValueError, TypeError, ArithmeticError, KeyError, IndexError):
189 return None
192def calculate_curve_fit(x_data: Any, y_data: Any, fit_type: str, custom_formula: str | None = None, sigma: Any = None,
193 x_range: tuple[float, float] | None = None, p0_overrides: dict[str, float] | None = None,
194 fixed_params: dict[str, float] | None = None,
195 bounds: dict[str, tuple[float, float]] | None = None, loss: str = 'linear',
196 model_id: str | None = None) -> dict[str, Any]:
197 """曲線フィット。popt / pcov / perr / x_fit・y_fit / r_squared / residuals などの dict を返す。
199 x_range は両端を含み、範囲外の点は p0 の推定にも使わない。sigma は absolute_sigma=True で渡す。
200 """
201 x_data = np.asarray(x_data)
202 y_data = np.asarray(y_data)
203 if sigma is not None:
204 sigma = np.asarray(sigma)
205 if x_range is not None:
206 x_min, x_max = x_range
207 range_mask = (x_data >= x_min) & (x_data <= x_max)
208 x_data, y_data = x_data[range_mask], y_data[range_mask]
209 if sigma is not None:
210 sigma = sigma[range_mask]
212 # NaN が残ると curve_fit は収束失敗ではない ValueError を出し、p0 の推定も落ちる
213 nan_mask = np.isnan(x_data) | np.isnan(y_data)
214 if sigma is not None:
215 nan_mask |= np.isnan(sigma)
216 if nan_mask.any():
217 x_data, y_data = x_data[~nan_mask], y_data[~nan_mask]
218 if sigma is not None: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 sigma = sigma[~nan_mask]
221 if len(x_data) == 0:
222 raise ValueError("有効なデータ点がありません(すべて欠損値です)。フィッティングできません。")
224 model = resolve_fit_model(fit_type, custom_formula, _PLUGIN_FIT_FUNCTIONS, model_id)
225 if model.check_data is not None:
226 model.check_data(x_data)
227 fit_func, params_info = model.func, model.param_names
228 p0 = model.initial_guess(x_data, y_data)
230 popt, pcov = _run_curve_fit_with_overrides(
231 fit_func, params_info, p0, x_data, y_data, sigma,
232 p0_overrides, fixed_params, bounds, fit_type, loss=loss,
233 )
235 # 退化したフィットでは pcov の対角が負や inf になる。例外にせず nan / inf のまま返す
236 perr = np.sqrt(np.diag(pcov))
238 x_fit = np.linspace(x_data.min(), x_data.max(), 200)
239 y_fit = fit_func(x_fit, *popt)
241 residuals = y_data - fit_func(x_data, *popt)
242 ss_res = np.sum(residuals ** 2)
243 ss_tot = np.sum((y_data - np.mean(y_data)) ** 2)
244 r_squared = 1.0 if ss_tot == 0 else 1.0 - (ss_res / ss_tot)
246 return {
247 'popt': popt,
248 'pcov': pcov,
249 'perr': perr,
250 'param_names': params_info,
251 # 信頼帯の計算用。保存はしない(Dataset.fit_result には入れない)
252 'fit_func': fit_func,
253 'x_fit': x_fit,
254 'y_fit': y_fit,
255 'r_squared': r_squared,
256 'residuals': residuals,
257 'x_data_used': x_data,
258 'y_data_used': y_data,
259 'loss': loss,
260 }
263# 多峰分離の成分関数。ベースラインを全成分で共有するので、単峰版と違いオフセットを持たない。
265def _gaussian_component(x: Any, a: float, b: float, c: float) -> Any:
266 return a * np.exp(-((x - b) ** 2) / (2 * c ** 2))
269def _lorentzian_component(x: Any, a: float, b: float, c: float) -> Any:
270 return a / (1 + ((x - b) / c) ** 2)
273def _pseudo_voigt_component(x: Any, a: float, b: float, c: float, eta: float) -> Any:
274 lorentzian_shape = 1 / (1 + ((x - b) / c) ** 2)
275 gaussian_shape = np.exp(-4 * np.log(2) * ((x - b) / c) ** 2)
276 return a * (eta * lorentzian_shape + (1 - eta) * gaussian_shape)
279def _voigt_component(x: Any, a: float, b: float, sigma: float, gamma: float) -> Any:
280 z = ((x - b) + 1j * gamma) / (sigma * np.sqrt(2))
281 return a * np.real(wofz(z)) / (sigma * np.sqrt(2 * np.pi))
284def _constant_baseline(x: Any, c: float) -> Any:
285 return np.full_like(np.asarray(x, dtype=float), c)
288def _linear_baseline(x: Any, m: float, b: float) -> Any:
289 return m * np.asarray(x, dtype=float) + b
292_MULTI_PEAK_COMPONENT_TYPES: dict[str, dict[str, Any]] = {
293 'gaussian': {'label': 'ガウシアン', 'func': _gaussian_component, 'param_names': ['a', 'b', 'c']},
294 'lorentzian': {'label': 'ローレンツ', 'func': _lorentzian_component, 'param_names': ['a', 'b', 'c']},
295 'pseudo_voigt': {'label': '擬似フォークト', 'func': _pseudo_voigt_component, 'param_names': ['a', 'b', 'c', 'eta']},
296 'voigt': {'label': 'フォークト', 'func': _voigt_component, 'param_names': ['a', 'b', 'sigma', 'gamma']},
297}
300def calculate_multi_peak_fit(x_data: Any, y_data: Any, component_type: str, initial_guesses: list[dict[str, float]],
301 baseline_type: str = 'constant', x_range: tuple[float, float] | None = None,
302 sigma: Any = None, p0_overrides: dict[str, float] | None = None,
303 fixed_params: dict[str, float] | None = None,
304 bounds: dict[str, tuple[float, float]] | None = None) -> dict[str, Any]:
305 """同じ種類の N 成分とベースラインを同時にフィットする。
307 initial_guesses は [{'center', 'height', 'width'(FWHM)}, ...]。戻り値は calculate_curve_fit() の
308 dict に component_type / n_components / baseline_type / components を足したもの。
309 """
310 if component_type not in _MULTI_PEAK_COMPONENT_TYPES:
311 raise ValueError(f"不明な成分タイプ: {component_type}")
312 if not initial_guesses:
313 raise ValueError("少なくとも1つのピークの初期値が必要です。")
314 if baseline_type not in ('none', 'constant', 'linear'):
315 raise ValueError(f"不明なベースラインタイプ: {baseline_type}")
317 x_data = np.asarray(x_data, dtype=float)
318 y_data = np.asarray(y_data, dtype=float)
319 if sigma is not None: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 sigma = np.asarray(sigma, dtype=float)
321 if x_range is not None:
322 x_min, x_max = x_range
323 range_mask = (x_data >= x_min) & (x_data <= x_max)
324 x_data, y_data = x_data[range_mask], y_data[range_mask]
325 if sigma is not None: 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true
326 sigma = sigma[range_mask]
328 # calculate_curve_fit() と同じ理由で NaN を除く
329 nan_mask = np.isnan(x_data) | np.isnan(y_data)
330 if sigma is not None: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 nan_mask |= np.isnan(sigma)
332 if nan_mask.any():
333 x_data, y_data = x_data[~nan_mask], y_data[~nan_mask]
334 if sigma is not None: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 sigma = sigma[~nan_mask]
337 if len(x_data) == 0: 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true
338 raise ValueError("有効なデータ点がありません(すべて欠損値です)。フィッティングできません。")
340 component_info = _MULTI_PEAK_COMPONENT_TYPES[component_type]
341 component_func = component_info['func']
342 component_param_names = component_info['param_names']
343 n_component_params = len(component_param_names)
344 n_components = len(initial_guesses)
346 params_info: list[str] = []
347 p0: list[float] = []
348 for i, guess in enumerate(initial_guesses, start=1):
349 height = float(guess['height'])
350 center = float(guess['center'])
351 width = float(guess.get('width') or 1.0) or 1.0
352 params_info.extend(f"{p}{i}" for p in component_param_names)
353 if component_type == 'gaussian':
354 p0.extend([height, center, width])
355 elif component_type == 'lorentzian':
356 # width は FWHM、c は HWHM
357 p0.extend([height, center, (width / 2) or 1.0])
358 elif component_type == 'pseudo_voigt':
359 # この定義の c は FWHM そのもの
360 p0.extend([height, center, width, 0.5])
361 else: # voigt
362 # 単峰のフォークトと同じ初期値の割り振り
363 sigma0 = (width / 2.355) or 1.0
364 gamma0 = (width / 4) or 1.0
365 amplitude0 = height * sigma0 * np.sqrt(2 * np.pi)
366 p0.extend([amplitude0, center, sigma0, gamma0])
368 baseline_func: Callable[..., Any] | None
369 if baseline_type == 'constant':
370 params_info.append('baseline_c')
371 p0.append(float(np.nanmin(y_data)))
372 baseline_func = _constant_baseline
373 elif baseline_type == 'linear':
374 params_info.extend(['baseline_m', 'baseline_b'])
375 p0.extend([0.0, float(np.nanmin(y_data))])
376 baseline_func = _linear_baseline
377 else:
378 baseline_func = None
380 def fit_func(x: Any, *params: float) -> Any:
381 total = np.zeros_like(np.asarray(x, dtype=float))
382 for i in range(n_components):
383 comp_params = params[i * n_component_params:(i + 1) * n_component_params]
384 total = total + component_func(x, *comp_params)
385 if baseline_func is not None:
386 baseline_params = params[n_components * n_component_params:]
387 total = total + baseline_func(x, *baseline_params)
388 return total
390 fit_type_label = f"多峰分離({component_info['label']} x{n_components})"
391 popt, pcov = _run_curve_fit_with_overrides(
392 fit_func, params_info, p0, x_data, y_data, sigma,
393 p0_overrides, fixed_params, bounds, fit_type_label,
394 )
396 perr = np.sqrt(np.diag(pcov))
397 x_fit = np.linspace(x_data.min(), x_data.max(), 200)
398 y_fit = fit_func(x_fit, *popt)
399 residuals = y_data - fit_func(x_data, *popt)
400 ss_res = np.sum(residuals ** 2)
401 ss_tot = np.sum((y_data - np.mean(y_data)) ** 2)
402 r_squared = 1.0 if ss_tot == 0 else 1.0 - (ss_res / ss_tot)
404 components = []
405 for i in range(n_components):
406 start = i * n_component_params
407 end = start + n_component_params
408 components.append({
409 'type': component_type,
410 'param_names': list(params_info[start:end]),
411 'params': [float(v) for v in popt[start:end]],
412 })
414 return {
415 'popt': popt,
416 'pcov': pcov,
417 'perr': perr,
418 'param_names': params_info,
419 'fit_func': fit_func,
420 'x_fit': x_fit,
421 'y_fit': y_fit,
422 'r_squared': r_squared,
423 'residuals': residuals,
424 'x_data_used': x_data,
425 'y_data_used': y_data,
426 'component_type': component_type,
427 'n_components': n_components,
428 'baseline_type': baseline_type,
429 'components': components,
430 }
433def fit_curve_task(x_data: Any, y_data: Any, fit_type: str, custom_formula: str | None = None, sigma: Any = None,
434 x_range: tuple[float, float] | None = None, p0_overrides: dict[str, float] | None = None,
435 fixed_params: dict[str, float] | None = None,
436 bounds: dict[str, tuple[float, float]] | None = None, loss: str = 'linear',
437 report_progress: Callable[..., Any] | None = None,
438 is_cancelled: Callable[[], bool] | None = None) -> dict[str, Any]:
439 """TaskRunner 用。curve_fit は進捗も中断もできないので、report_progress と is_cancelled は受け取るだけ。"""
440 return calculate_curve_fit(
441 x_data, y_data, fit_type, custom_formula=custom_formula, sigma=sigma, x_range=x_range,
442 p0_overrides=p0_overrides, fixed_params=fixed_params, bounds=bounds, loss=loss,
443 )
446def multi_peak_fit_task(x_data: Any, y_data: Any, component_type: str, initial_guesses: list[dict[str, float]],
447 baseline_type: str = 'constant', x_range: tuple[float, float] | None = None,
448 sigma: Any = None, p0_overrides: dict[str, float] | None = None,
449 fixed_params: dict[str, float] | None = None,
450 bounds: dict[str, tuple[float, float]] | None = None,
451 report_progress: Callable[..., Any] | None = None,
452 is_cancelled: Callable[[], bool] | None = None) -> dict[str, Any]:
453 """TaskRunner 用。fit_curve_task() と同じく進捗と中断は受け取るだけ。"""
454 return calculate_multi_peak_fit(
455 x_data, y_data, component_type, initial_guesses, baseline_type=baseline_type,
456 x_range=x_range, sigma=sigma, p0_overrides=p0_overrides, fixed_params=fixed_params, bounds=bounds,
457 )
460_INTEGRAL_METHODS = ("trapezoid", "simpson")
463def _trapezoid_integrate(y: Any, x: Any) -> float:
464 """NumPy 2.0 で trapz が trapezoid になった。古い版でも動くようにする。"""
465 trapezoid_func = getattr(np, "trapezoid", None)
466 if trapezoid_func is None: 466 ↛ 467line 466 didn't jump to line 467 because the condition on line 466 was never true
467 trapezoid_func = np.trapz
468 return float(trapezoid_func(y, x))
471def calculate_interval_integral(x_data: Any, y_data: Any, x_range: tuple[float, float], method: str = "trapezoid",
472 subtract_baseline: bool = False) -> dict[str, Any]:
473 """x_range(両端を含む)で y を x について積分する。
475 subtract_baseline は範囲の両端を結ぶ直線を引いてから積分する。両端の Y は、その X 位置で補間した値。
476 """
477 if method not in _INTEGRAL_METHODS:
478 raise ValueError(f"未知の積分方法です: {method}")
479 if x_range is None or len(x_range) != 2: 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true
480 raise ValueError("積分範囲(x_range)を指定してください。")
482 x_min, x_max = float(x_range[0]), float(x_range[1])
483 if x_min >= x_max:
484 raise ValueError("積分範囲の最小値は最大値より小さい値である必要があります。")
486 x_data = np.asarray(x_data, dtype=float)
487 y_data = np.asarray(y_data, dtype=float)
489 # NaN があると積分値が nan になる
490 nan_mask = np.isnan(x_data) | np.isnan(y_data)
491 if nan_mask.any():
492 x_data, y_data = x_data[~nan_mask], y_data[~nan_mask]
494 if len(x_data) == 0:
495 raise ValueError("有効なデータ点がありません(すべて欠損値です)。積分できません。")
497 # trapezoid / simpson は X が単調増加であることを前提にする
498 order = np.argsort(x_data)
499 x_sorted, y_sorted = x_data[order], y_data[order]
501 x_min_data, x_max_data = float(x_sorted[0]), float(x_sorted[-1])
502 if x_min < x_min_data or x_max > x_max_data:
503 raise ValueError(
504 f"積分範囲はデータのX範囲({x_min_data:.6g} 〜 {x_max_data:.6g})内で指定してください。"
505 )
507 range_mask = (x_sorted >= x_min) & (x_sorted <= x_max)
508 x_in, y_in = x_sorted[range_mask], y_sorted[range_mask]
510 if len(x_in) < 2:
511 raise ValueError(f"積分範囲内に十分なデータ点がありません(最低2点必要、現在{len(x_in)}点)。")
513 baseline_used = None
514 y_for_integration = y_in
515 if subtract_baseline:
516 y_at_min = float(np.interp(x_min, x_sorted, y_sorted))
517 y_at_max = float(np.interp(x_max, x_sorted, y_sorted))
518 baseline_used = y_at_min + (y_at_max - y_at_min) * (x_in - x_min) / (x_max - x_min)
519 y_for_integration = y_in - baseline_used
521 if method == "trapezoid":
522 integral = _trapezoid_integrate(y_for_integration, x_in)
523 else:
524 integral = float(simpson(y_for_integration, x=x_in))
526 return {
527 'integral': integral,
528 'method': method,
529 'x_range': (x_min, x_max),
530 'subtract_baseline': subtract_baseline,
531 'x_used': x_in,
532 'y_used': y_for_integration,
533 'y_raw_used': y_in,
534 'baseline_used': baseline_used,
535 'n_points': len(x_in),
536 }
539def calculate_cumulative_integral(x_data: Any, y_data: Any, method: str = "trapezoid") -> dict[str, Any]:
540 """各点までの累積積分 ∫[x_min, x_i] y dx を返す(先頭は 0)。"""
541 if method not in _INTEGRAL_METHODS:
542 raise ValueError(f"未知の積分方法です: {method}")
544 x_data = np.asarray(x_data, dtype=float)
545 y_data = np.asarray(y_data, dtype=float)
547 nan_mask = np.isnan(x_data) | np.isnan(y_data)
548 if nan_mask.any():
549 x_data, y_data = x_data[~nan_mask], y_data[~nan_mask]
551 if len(x_data) < 2:
552 raise ValueError("有効なデータ点が不足しています(最低2点必要です)。")
554 order = np.argsort(x_data)
555 x_sorted, y_sorted = x_data[order], y_data[order]
557 if method == "trapezoid":
558 y_cumulative = cumulative_trapezoid(y_sorted, x_sorted, initial=0.0)
559 else:
560 y_cumulative = cumulative_simpson(y_sorted, x=x_sorted, initial=0.0)
562 return {
563 'x_used': x_sorted,
564 'y_cumulative': y_cumulative,
565 'method': method,
566 'n_points': len(x_sorted),
567 }
570def _peak_detection_signal_and_kwargs(x_data: Any, y_data: Any, peak_type: str,
571 settings: dict[str, Any]) -> tuple[np.ndarray, dict[str, Any]]:
572 """calculate_peaks と calculate_peak_quantification の共通の前処理。
574 谷は -y_data で探すので height も符号を反転する(利用者は谷でも「Y < -10」を -10 と入力する)。
575 height=None は高さで絞らない。
576 """
577 y_data_to_find = y_data
578 height = settings["height"]
580 if "下に凸" in peak_type:
581 y_data_to_find = -y_data
582 if height is not None:
583 height = -height
585 kwargs = {"height": height}
587 if settings["prominence"] is not None:
588 kwargs["prominence"] = settings["prominence"]
590 if len(x_data) > 1 and settings["distance_x"] > 0:
591 sorted_x = np.sort(x_data)
592 avg_x_diff = np.mean(np.diff(sorted_x))
593 if avg_x_diff > 0: 593 ↛ 596line 593 didn't jump to line 596 because the condition on line 593 was always true
594 kwargs["distance"] = max(1, int(np.ceil(settings["distance_x"] / avg_x_diff)))
595 else:
596 kwargs["distance"] = 1
597 else:
598 kwargs["distance"] = 1
600 return y_data_to_find, kwargs
603def calculate_peaks(x_data: Any, y_data: Any, peak_type: str, settings: dict[str, Any]) -> tuple[np.ndarray, np.ndarray]:
604 y_data_to_find, kwargs = _peak_detection_signal_and_kwargs(x_data, y_data, peak_type, settings)
606 peak_indices, _ = find_peaks(y_data_to_find, **kwargs)
608 if len(peak_indices) == 0:
609 return np.array([]), np.array([])
611 return x_data[peak_indices], y_data[peak_indices]
614def calculate_peak_quantification(x_data: Any, y_data: Any, peak_type: str, settings: dict[str, Any]) -> dict[str, Any]:
615 """ピーク/谷の位置に加え、FWHM・面積・重心を返す。
617 谷は反転した信号の上で計算するので、面積は谷でも正の値(検出方向への突出量)になる。
618 ローカル基線は peak_widths(rel_height=1.0) の左右の裾を結ぶ直線(裾の高さが違う
619 非対称なピークでも面積と重心が定まる)。重心の重みは生の Y ではなく基線からの高さ
620 (大きなオフセットの上のピークで重心が引っ張られない)。x_data の並びがそのまま
621 ピークの前後関係になるので、ソートは呼び出し側で行う。
622 """
623 x_data = np.asarray(x_data, dtype=float)
624 y_data = np.asarray(y_data, dtype=float)
626 y_signal, kwargs = _peak_detection_signal_and_kwargs(x_data, y_data, peak_type, settings)
627 peak_indices, _ = find_peaks(y_signal, **kwargs)
629 empty = np.array([])
630 if len(peak_indices) == 0:
631 return {'peak_x': empty, 'peak_y': empty, 'fwhm': empty, 'area': empty, 'centroid': empty}
633 idx_axis = np.arange(len(x_data))
635 # 幅はインデックス単位で出るので、x_data に補間して X の単位にする(等間隔でなくてよい)
636 _, _, left_ips_half, right_ips_half = peak_widths(y_signal, peak_indices, rel_height=0.5)
637 x_left_half = np.interp(left_ips_half, idx_axis, x_data)
638 x_right_half = np.interp(right_ips_half, idx_axis, x_data)
639 fwhm = x_right_half - x_left_half
641 # rel_height=1.0 は prominence を測った高さでの幅、つまりピークの裾
642 _, _, left_ips_full, right_ips_full = peak_widths(y_signal, peak_indices, rel_height=1.0)
644 area = np.empty(len(peak_indices))
645 centroid = np.empty(len(peak_indices))
647 for i, pk in enumerate(peak_indices):
648 li, ri = left_ips_full[i], right_ips_full[i]
650 x_left = np.interp(li, idx_axis, x_data)
651 x_right = np.interp(ri, idx_axis, x_data)
652 y_left = np.interp(li, idx_axis, y_signal)
653 y_right = np.interp(ri, idx_axis, y_signal)
655 li_idx, ri_idx = int(np.floor(li)), int(np.ceil(ri))
656 inner_idx = np.arange(max(li_idx, 0), min(ri_idx, len(x_data) - 1) + 1)
657 xs_inner, ys_inner = x_data[inner_idx], y_signal[inner_idx]
658 inner_mask = (xs_inner >= x_left) & (xs_inner <= x_right)
659 xs_inner, ys_inner = xs_inner[inner_mask], ys_inner[inner_mask]
661 xs = np.concatenate(([x_left], xs_inner, [x_right]))
662 ys = np.concatenate(([y_left], ys_inner, [y_right]))
663 xs, unique_order = np.unique(xs, return_index=True)
664 ys = ys[unique_order]
666 baseline = np.interp(xs, [x_left, x_right], [y_left, y_right])
667 y_above = ys - baseline
669 area[i] = np.trapezoid(y_above, xs)
671 weights = np.clip(y_above, 0, None)
672 total_weight = np.sum(weights)
673 if total_weight > 0: 673 ↛ 676line 673 didn't jump to line 676 because the condition on line 673 was always true
674 centroid[i] = np.sum(xs * weights) / total_weight
675 else:
676 centroid[i] = x_data[pk]
678 return {
679 'peak_x': x_data[peak_indices],
680 'peak_y': y_data[peak_indices],
681 'fwhm': fwhm,
682 'area': area,
683 'centroid': centroid,
684 }
687def calculate_savgol(x_data: Any, y_data: Any, window_length: int, polyorder: int,
688 deriv: int = 0) -> tuple[np.ndarray, np.ndarray]:
689 """Savitzky-Golay による平滑化(deriv=0)または微分(deriv=1, 2)。
691 delta に X の間隔の中央値を渡すので、微分は dy/dx の大きさになる(X がほぼ等間隔である前提)。
692 """
693 if window_length % 2 == 0:
694 raise ValueError("窓幅(window_length)は奇数である必要があります。")
695 if polyorder >= window_length:
696 raise ValueError("多項式の次数は窓幅より小さくする必要があります。")
697 if window_length > len(y_data):
698 raise ValueError(f"窓幅({window_length})がデータ点数({len(y_data)})を超えています。")
700 order = np.argsort(x_data)
701 x_sorted, y_sorted = x_data[order], y_data[order]
702 diffs = np.diff(x_sorted)
703 dx = float(np.median(diffs)) if len(diffs) > 0 and np.median(diffs) > 0 else 1.0
705 y_result = savgol_filter(y_sorted, window_length, polyorder, deriv=deriv, delta=dx)
706 return x_sorted, y_result
709# 線の平滑化(Dataset.smoothing_method)。描画用で元データは変えない。
710# どれも X でソートして実データ点のまま返す(CubicSpline と違い点を補間で増やさない)。
712def calculate_moving_average_smooth(x_data: Any, y_data: Any, window: int = 5) -> tuple[np.ndarray, np.ndarray]:
713 """window は点数。データ点数を超えたら切り詰める。端は端の値を延長する。"""
714 x_data = np.asarray(x_data, dtype=float)
715 y_data = np.asarray(y_data, dtype=float)
716 order = np.argsort(x_data)
717 x_sorted, y_sorted = x_data[order], y_data[order]
719 n = len(y_sorted)
720 w = max(1, min(int(window), n)) if n > 0 else 1
721 if w <= 1:
722 return x_sorted, y_sorted.copy()
723 return x_sorted, uniform_filter1d(y_sorted, size=w, mode='nearest')
726def calculate_median_smooth(x_data: Any, y_data: Any, window: int = 5) -> tuple[np.ndarray, np.ndarray]:
727 x_data = np.asarray(x_data, dtype=float)
728 y_data = np.asarray(y_data, dtype=float)
729 order = np.argsort(x_data)
730 x_sorted, y_sorted = x_data[order], y_data[order]
732 n = len(y_sorted)
733 w = max(1, min(int(window), n)) if n > 0 else 1
734 if w <= 1: 734 ↛ 735line 734 didn't jump to line 735 because the condition on line 734 was never true
735 return x_sorted, y_sorted.copy()
736 return x_sorted, median_filter(y_sorted, size=w, mode='nearest')
739def calculate_gaussian_smooth(x_data: Any, y_data: Any, sigma: float = 2.0) -> tuple[np.ndarray, np.ndarray]:
740 """sigma はデータ点のインデックス単位(X の間隔ではない)。"""
741 x_data = np.asarray(x_data, dtype=float)
742 y_data = np.asarray(y_data, dtype=float)
743 order = np.argsort(x_data)
744 x_sorted, y_sorted = x_data[order], y_data[order]
746 if len(y_sorted) < 2 or sigma <= 0:
747 return x_sorted, y_sorted.copy()
748 return x_sorted, gaussian_filter1d(y_sorted, sigma=float(sigma), mode='nearest')
751# ベースライン補正。どの手法も (ソート済みの x, ベースライン, 差し引いた y) を返す。
753def _sort_xy_for_baseline(x_data: Any, y_data: Any) -> tuple[np.ndarray, np.ndarray]:
754 x_data = np.asarray(x_data, dtype=float)
755 y_data = np.asarray(y_data, dtype=float)
756 order = np.argsort(x_data)
757 return x_data[order], y_data[order]
760def calculate_baseline_als(x_data: Any, y_data: Any, lam: float = 1e5, p: float = 0.01,
761 niter: int = 10) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
762 """Asymmetric Least Squares (Eilers & Boelens 2005)。
764 lam が大きいほど滑らか、p が小さいほどピークを避けて下側を通る(目安 0.001〜0.1)。
765 """
766 if lam <= 0:
767 raise ValueError("lam(平滑化パラメータ)は正の値である必要があります。")
768 if not (0 < p < 1):
769 raise ValueError("p(非対称重み)は0より大きく1より小さい値である必要があります。")
770 if niter < 1:
771 raise ValueError("反復回数(niter)は1以上である必要があります。")
773 x_sorted, y_sorted = _sort_xy_for_baseline(x_data, y_data)
774 n_points = len(y_sorted)
775 if n_points < 3:
776 raise ValueError(f"ALSベースライン補正には少なくとも3点のデータが必要です(現在{n_points}点)。")
778 # 2階差分の二乗和へのペナルティ
779 diff_matrix = sparse.diags([1, -2, 1], [0, -1, -2], shape=(n_points, n_points - 2))
780 penalty = lam * (diff_matrix @ diff_matrix.transpose())
782 weights = np.ones(n_points)
783 baseline = y_sorted.copy()
784 for _ in range(niter):
785 weight_matrix = sparse.diags(weights, 0, shape=(n_points, n_points))
786 baseline = spsolve((weight_matrix + penalty).tocsc(), weights * y_sorted)
787 weights = p * (y_sorted > baseline) + (1 - p) * (y_sorted <= baseline)
789 return x_sorted, baseline, y_sorted - baseline
792def calculate_baseline_polynomial(x_data: Any, y_data: Any, degree: int = 3,
793 iterations: int = 10) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
794 """反復多項式フィット(Lieber & Mahadevan-Jansen 2003 の ModPoly)。
796 フィット曲線を上回る点をフィット値で置き換えて再フィットし、ピークを少しずつ削る。
797 """
798 if degree < 0:
799 raise ValueError("多項式の次数は0以上である必要があります。")
800 if iterations < 1:
801 raise ValueError("反復回数(iterations)は1以上である必要があります。")
803 x_sorted, y_sorted = _sort_xy_for_baseline(x_data, y_data)
804 if degree >= len(y_sorted):
805 raise ValueError(f"多項式の次数({degree})がデータ点数({len(y_sorted)})以上です。")
807 work_y = y_sorted.copy()
808 baseline = work_y
809 for _ in range(iterations):
810 coeffs = np.polyfit(x_sorted, work_y, degree)
811 baseline = np.polyval(coeffs, x_sorted)
812 work_y = np.minimum(work_y, baseline)
814 return x_sorted, baseline, y_sorted - baseline
817def calculate_baseline_rubberband(x_data: Any, y_data: Any) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
818 """ラバーバンド法。下側凸包の頂点を区分線形でつないだものをベースラインにする。"""
819 x_sorted, y_sorted = _sort_xy_for_baseline(x_data, y_data)
820 n_points = len(x_sorted)
821 if n_points < 3:
822 raise ValueError(f"ラバーバンド法には少なくとも3点のデータが必要です(現在{n_points}点)。")
824 # Andrew の monotone chain の下側だけ
825 hull_indices: list[int] = []
826 for i in range(n_points):
827 while len(hull_indices) >= 2:
828 o, a = hull_indices[-2], hull_indices[-1]
829 cross = ((x_sorted[a] - x_sorted[o]) * (y_sorted[i] - y_sorted[o])
830 - (y_sorted[a] - y_sorted[o]) * (x_sorted[i] - x_sorted[o]))
831 if cross <= 0:
832 hull_indices.pop()
833 else:
834 break
835 hull_indices.append(i)
837 x_hull = x_sorted[hull_indices]
838 y_hull = y_sorted[hull_indices]
839 baseline = np.interp(x_sorted, x_hull, y_hull)
841 return x_sorted, baseline, y_sorted - baseline
844def calculate_baseline_manual(x_data: Any, y_data: Any, anchor_x: Any,
845 method: str = "linear") -> tuple[np.ndarray, np.ndarray, np.ndarray]:
846 """指定した X 位置でデータを補間した点を、線形("linear")か 3 次スプライン("spline")で結ぶ。"""
847 if method not in ("linear", "spline"):
848 raise ValueError(f"未知の補間方法です: {method}")
850 x_sorted, y_sorted = _sort_xy_for_baseline(x_data, y_data)
852 anchor_x = np.unique(np.asarray(anchor_x, dtype=float))
853 if len(anchor_x) < 2:
854 raise ValueError("アンカー点は重複しない値で2点以上指定してください。")
855 if method == "spline" and len(anchor_x) < 3:
856 raise ValueError("スプライン補間には3点以上のアンカー点が必要です。")
858 x_min, x_max = x_sorted[0], x_sorted[-1]
859 if anchor_x[0] < x_min or anchor_x[-1] > x_max:
860 raise ValueError(
861 f"アンカー点はデータのX範囲({x_min:.6g} 〜 {x_max:.6g})内で指定してください。"
862 )
864 anchor_y = np.interp(anchor_x, x_sorted, y_sorted)
866 if method == "linear":
867 baseline = np.interp(x_sorted, anchor_x, anchor_y)
868 else:
869 baseline = CubicSpline(anchor_x, anchor_y)(x_sorted)
871 return x_sorted, baseline, y_sorted - baseline
874def calculate_confidence_band(x_eval: Any, fit_func: Callable[..., Any], popt: Any, pcov: Any, residuals: Any,
875 confidence: float = 0.95, band_type: str = "confidence") -> dict[str, Any]:
876 """非線形回帰の信頼帯・予測帯をデルタ法(線形化)で近似する。
878 各点で J(x) @ pcov @ J(x).T を分散とする。予測帯はさらに残差の平均二乗誤差を足す。
879 固定パラメータは pcov の行と列が 0 なので不確かさに寄与しない。
880 """
881 if band_type not in ("confidence", "prediction"):
882 raise ValueError(f"未知のband_typeです: {band_type}")
883 if not (0.0 < confidence < 1.0):
884 raise ValueError("confidence(信頼水準)は0より大きく1より小さい値である必要があります。")
886 x_eval = np.asarray(x_eval, dtype=float)
887 popt = np.asarray(popt, dtype=float)
888 pcov = np.asarray(pcov, dtype=float)
889 residuals = np.asarray(residuals, dtype=float)
891 n_params = len(popt)
892 n_data = len(residuals)
893 dof = n_data - n_params
894 if dof < 1:
895 raise ValueError(
896 f"自由度(データ点数{n_data} - パラメータ数{n_params})が1未満のため、"
897 "信頼帯・予測帯を計算できません。"
898 )
900 from scipy.stats import t as _t_dist
901 t_value = _t_dist.ppf(1.0 - (1.0 - confidence) / 2.0, dof)
903 y_center = fit_func(x_eval, *popt)
904 jacobian = np.empty((len(x_eval), n_params))
905 for i in range(n_params):
906 # 刻みはパラメータの大きさに比例させる(極端な値でも数値誤差が出にくい)
907 step = max(abs(popt[i]), 1.0) * 1e-6
908 popt_plus, popt_minus = popt.copy(), popt.copy()
909 popt_plus[i] += step
910 popt_minus[i] -= step
911 jacobian[:, i] = (fit_func(x_eval, *popt_plus) - fit_func(x_eval, *popt_minus)) / (2 * step)
913 # diag(J @ pcov @ J.T) だけを求める
914 var_yhat = np.einsum('ij,jk,ik->i', jacobian, pcov, jacobian)
915 # 数値誤差でわずかに負になることがある
916 var_yhat = np.clip(var_yhat, 0.0, None)
918 if band_type == "prediction":
919 mse = np.sum(residuals ** 2) / dof
920 variance = var_yhat + mse
921 else:
922 variance = var_yhat
924 margin = t_value * np.sqrt(variance)
926 return {
927 'y_center': y_center,
928 'y_lower': y_center - margin,
929 'y_upper': y_center + margin,
930 'confidence': confidence,
931 'band_type': band_type,
932 }
935def calculate_resample_to_grid(x_data: Any, y_data: Any, target_x: Any, method: str = "linear",
936 extrapolate: bool = False) -> np.ndarray:
937 """(x_data, y_data) を target_x の格子に補間する。
939 extrapolate=False(既定)は元の X の範囲外を NaN にする。True のとき "linear" は両端の傾きで
940 外挿する(np.interp は端の値で止めるだけで外挿しない)。
941 """
942 if method not in ("linear", "cubic"):
943 raise ValueError(f"未知の補間方法です: {method}")
945 x_data = np.asarray(x_data, dtype=float)
946 y_data = np.asarray(y_data, dtype=float)
947 target_x = np.asarray(target_x, dtype=float)
949 nan_mask = np.isnan(x_data) | np.isnan(y_data)
950 if nan_mask.any():
951 x_data, y_data = x_data[~nan_mask], y_data[~nan_mask]
953 if len(x_data) == 0:
954 raise ValueError("有効なデータ点がありません(すべて欠損値です)。リサンプリングできません。")
956 order = np.argsort(x_data)
957 x_sorted, y_sorted = x_data[order], y_data[order]
958 # 補間は X が厳密に単調増加であることを要求するので、同じ X は最後の値を残してまとめる。
959 # np.unique は最初の出現を返すので、反転してから除く。
960 x_rev, y_rev = x_sorted[::-1], y_sorted[::-1]
961 x_sorted, rev_unique_indices = np.unique(x_rev, return_index=True)
962 y_sorted = y_rev[rev_unique_indices]
964 n_points = len(x_sorted)
965 if method == "linear" and n_points < 2:
966 raise ValueError(f"線形補間には少なくとも2点のデータが必要です(現在{n_points}点)。")
967 if method == "cubic" and n_points < 4:
968 raise ValueError(f"3次スプライン補間には少なくとも4点のデータが必要です(現在{n_points}点)。")
970 x_min, x_max = x_sorted[0], x_sorted[-1]
972 if method == "linear":
973 if extrapolate:
974 # np.interp は範囲外を端の値で止めるだけなので、両端の傾きで外挿する
975 result = np.interp(target_x, x_sorted, y_sorted)
976 below = target_x < x_min
977 if below.any(): 977 ↛ 980line 977 didn't jump to line 980 because the condition on line 977 was always true
978 slope = (y_sorted[1] - y_sorted[0]) / (x_sorted[1] - x_sorted[0])
979 result[below] = y_sorted[0] + slope * (target_x[below] - x_min)
980 above = target_x > x_max
981 if above.any(): 981 ↛ 997line 981 didn't jump to line 997 because the condition on line 981 was always true
982 slope = (y_sorted[-1] - y_sorted[-2]) / (x_sorted[-1] - x_sorted[-2])
983 result[above] = y_sorted[-1] + slope * (target_x[above] - x_max)
984 else:
985 result = np.interp(target_x, x_sorted, y_sorted)
986 out_of_range = (target_x < x_min) | (target_x > x_max)
987 result = result.astype(float)
988 result[out_of_range] = np.nan
989 else:
990 spline = CubicSpline(x_sorted, y_sorted, extrapolate=extrapolate)
991 result = spline(target_x)
992 if not extrapolate:
993 out_of_range = (target_x < x_min) | (target_x > x_max)
994 result = np.asarray(result, dtype=float)
995 result[out_of_range] = np.nan
997 return result
1000def calculate_average_duplicate_x(x_data: Any, y_data: Any) -> dict[str, Any]:
1001 """同じ X の行の Y を平均する。重複の「除去」は index ラベルが要るので呼び出し側で行う。"""
1002 x_data = np.asarray(x_data, dtype=float)
1003 y_data = np.asarray(y_data, dtype=float)
1005 nan_mask = np.isnan(x_data) | np.isnan(y_data)
1006 if nan_mask.any():
1007 x_data, y_data = x_data[~nan_mask], y_data[~nan_mask]
1009 if len(x_data) == 0:
1010 raise ValueError("有効なデータ点がありません(すべて欠損値です)。")
1012 order = np.argsort(x_data, kind='stable')
1013 x_sorted, y_sorted = x_data[order], y_data[order]
1015 unique_x, inverse, counts = np.unique(x_sorted, return_inverse=True, return_counts=True)
1016 # NumPy の版によって inverse の形が (N,) か (N, 1) になる
1017 inverse = np.asarray(inverse).reshape(-1)
1018 sums = np.zeros(len(unique_x))
1019 np.add.at(sums, inverse, y_sorted)
1020 y_averaged = sums / counts
1022 return {
1023 'x_used': unique_x,
1024 'y_averaged': y_averaged,
1025 'group_sizes': counts,
1026 'n_duplicate_groups': int(np.sum(counts > 1)),
1027 'n_points_in': len(x_sorted),
1028 'n_points_out': len(unique_x),
1029 }
1032# 外れ値の検出。マスクへの適用は利用者が選ぶので、ここでは検出だけ。
1033# is_outlier は入力と同じ長さと並び(NaN は False)。visible_df.index[is_outlier] でそのまま引けるように。
1035def sample_standard_deviation(values: Any) -> float:
1036 """標本標準偏差(n−1 で割る)。有効な値が 2 未満なら NaN。
1038 アプリ内の「標準偏差」はすべてこれで計算する。場所によって n と n−1 が混ざると同じデータで値が違って見える。
1039 """
1040 arr = np.asarray(values, dtype=float)
1041 arr = arr[~np.isnan(arr)]
1042 if arr.size < 2:
1043 return float('nan')
1044 return float(np.std(arr, ddof=1))
1047def calculate_zscore_outliers(y_data: Any, threshold: float = 3.0) -> dict[str, Any]:
1048 if threshold <= 0:
1049 raise ValueError("しきい値は正の値である必要があります。")
1051 y = np.asarray(y_data, dtype=float)
1052 is_outlier = np.zeros(len(y), dtype=bool)
1053 z_scores = np.full(len(y), np.nan)
1054 valid = ~np.isnan(y)
1056 if valid.sum() >= 2: 1056 ↛ 1063line 1056 didn't jump to line 1063 because the condition on line 1056 was always true
1057 mean = np.mean(y[valid])
1058 std = sample_standard_deviation(y[valid])
1059 if std > 0:
1060 z_scores[valid] = (y[valid] - mean) / std
1061 is_outlier[valid] = np.abs(z_scores[valid]) > threshold
1063 return {
1064 'is_outlier': is_outlier,
1065 'z_scores': z_scores,
1066 'threshold': threshold,
1067 'n_outliers': int(np.sum(is_outlier)),
1068 }
1071def calculate_iqr_outliers(y_data: Any, multiplier: float = 1.5) -> dict[str, Any]:
1072 """[Q1 - multiplier*IQR, Q3 + multiplier*IQR] の外を外れ値とする。"""
1073 if multiplier <= 0:
1074 raise ValueError("係数は正の値である必要があります。")
1076 y = np.asarray(y_data, dtype=float)
1077 is_outlier = np.zeros(len(y), dtype=bool)
1078 valid = ~np.isnan(y)
1079 lower_bound = upper_bound = None
1081 if valid.sum() >= 4:
1082 q1, q3 = np.percentile(y[valid], [25, 75])
1083 iqr = q3 - q1
1084 lower_bound = float(q1 - multiplier * iqr)
1085 upper_bound = float(q3 + multiplier * iqr)
1086 is_outlier[valid] = (y[valid] < lower_bound) | (y[valid] > upper_bound)
1088 return {
1089 'is_outlier': is_outlier,
1090 'lower_bound': lower_bound,
1091 'upper_bound': upper_bound,
1092 'multiplier': multiplier,
1093 'n_outliers': int(np.sum(is_outlier)),
1094 }
1097def calculate_lttb_downsample(x_data: Any, y_data: Any, n_out: int) -> np.ndarray:
1098 """Largest-Triangle-Three-Buckets(Steinarsson 2013)による表示用の間引き。
1100 x_data はソート済みであること。選んだ点のインデックス(先頭と末尾を含む昇順)を返すので、
1101 同じインデックスを誤差などほかの配列にも使える。n_out 以下なら間引かない。
1102 """
1103 n = len(x_data)
1104 if n_out < 3 or n <= n_out:
1105 return np.arange(n)
1107 x_data = np.asarray(x_data, dtype=float)
1108 y_data = np.asarray(y_data, dtype=float)
1110 selected = np.empty(n_out, dtype=np.int64)
1111 selected[0] = 0
1112 selected[-1] = n - 1
1114 bucket_edges = np.linspace(1, n - 1, n_out - 1).astype(np.int64)
1115 a = 0
1116 for i in range(n_out - 2):
1117 bucket_start, bucket_end = bucket_edges[i], bucket_edges[i + 1]
1118 if bucket_end <= bucket_start: 1118 ↛ 1119line 1118 didn't jump to line 1119 because the condition on line 1118 was never true
1119 bucket_end = bucket_start + 1
1120 next_start = bucket_end
1121 next_end = bucket_edges[i + 2] if i + 2 < len(bucket_edges) else n
1122 if next_end <= next_start: 1122 ↛ 1123line 1122 didn't jump to line 1123 because the condition on line 1122 was never true
1123 next_end = min(next_start + 1, n)
1124 avg_x = np.mean(x_data[next_start:next_end])
1125 avg_y = np.mean(y_data[next_start:next_end])
1127 cand_x = x_data[bucket_start:bucket_end]
1128 cand_y = y_data[bucket_start:bucket_end]
1129 ax_, ay_ = x_data[a], y_data[a]
1130 areas = np.abs(
1131 (ax_ - avg_x) * (cand_y - ay_) - (ax_ - cand_x) * (avg_y - ay_)
1132 )
1133 best_local = np.argmax(areas)
1134 chosen = bucket_start + best_local
1135 selected[i + 1] = chosen
1136 a = chosen
1138 return selected
1141def calculate_histogram(data: Any, bins: Any = 'auto', density: bool = False) -> dict[str, Any]:
1142 """ビン中心と度数(density=True なら確率密度)を返す。"""
1143 data = np.asarray(data, dtype=float)
1144 data = data[~np.isnan(data)]
1145 if len(data) == 0:
1146 raise ValueError("有効なデータ点がありません(すべて欠損値です)。")
1147 counts, bin_edges = np.histogram(data, bins=bins, density=density)
1148 bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
1149 return {
1150 'bin_centers': bin_centers,
1151 'bin_edges': bin_edges,
1152 'counts': counts,
1153 'n_points_used': len(data),
1154 }
1157def calculate_kde(data: Any, n_points: int = 200, bw_method: Any = None) -> dict[str, Any]:
1158 data = np.asarray(data, dtype=float)
1159 data = data[~np.isnan(data)]
1160 if len(data) < 2:
1161 raise ValueError("カーネル密度推定には少なくとも2点の有効なデータが必要です。")
1162 try:
1163 kde = gaussian_kde(data, bw_method=bw_method)
1164 x_grid = np.linspace(data.min(), data.max(), n_points)
1165 density = kde(x_grid)
1166 except np.linalg.LinAlgError:
1167 raise ValueError("データにばらつきが無いため、カーネル密度推定を計算できません。") from None
1168 return {'x_grid': x_grid, 'density': density, 'n_points_used': len(data)}
1171def calculate_error_propagation(operation: str, value_a: Any, error_a: Any, value_b: Any, error_b: Any) -> np.ndarray:
1172 """独立な誤差を仮定した誤差伝播。
1174 除算は、分子がゼロのときに NaN にならないよう、比ではなく分母で割る形の式にしている。
1175 """
1176 a = np.asarray(value_a, dtype=float)
1177 ea = np.asarray(error_a, dtype=float)
1178 b = np.asarray(value_b, dtype=float)
1179 eb = np.asarray(error_b, dtype=float)
1181 if operation in ("A - B", "B - A", "A + B"):
1182 return np.sqrt(ea ** 2 + eb ** 2)
1183 if operation == "A × B":
1184 return np.sqrt((ea * b) ** 2 + (eb * a) ** 2)
1185 with np.errstate(divide='ignore', invalid='ignore'):
1186 if operation == "A ÷ B":
1187 return np.sqrt((ea / b) ** 2 + (a * eb / b ** 2) ** 2)
1188 else:
1189 return np.sqrt((eb / a) ** 2 + (b * ea / a ** 2) ** 2)
1192# 共通グリッドの点数の上限(大きいデータでフリーズしないため)
1193_XCORR_MAX_GRID_POINTS = 20000
1196def calculate_cross_correlation_alignment(x_a: Any, y_a: Any, x_b: Any, y_b: Any) -> dict[str, float]:
1197 """B を A に重ねるための X のシフト量(x_b + shift)を相互相関のピークから求める。
1199 両者の範囲全体を覆う等間隔グリッドに補間し(範囲外は 0)、平均を引いてから相関を取る。
1200 """
1201 x_a = np.asarray(x_a, dtype=float)
1202 y_a = np.asarray(y_a, dtype=float)
1203 x_b = np.asarray(x_b, dtype=float)
1204 y_b = np.asarray(y_b, dtype=float)
1206 valid_a = ~(np.isnan(x_a) | np.isnan(y_a))
1207 valid_b = ~(np.isnan(x_b) | np.isnan(y_b))
1208 x_a, y_a = x_a[valid_a], y_a[valid_a]
1209 x_b, y_b = x_b[valid_b], y_b[valid_b]
1210 if len(x_a) < 2 or len(x_b) < 2:
1211 raise ValueError("有効なデータ点が不足しています(それぞれ最低2点必要です)。")
1213 order_a = np.argsort(x_a)
1214 x_a, y_a = x_a[order_a], y_a[order_a]
1215 order_b = np.argsort(x_b)
1216 x_b, y_b = x_b[order_b], y_b[order_b]
1218 step_a = np.median(np.diff(x_a))
1219 step_b = np.median(np.diff(x_b))
1220 candidates = [s for s in (step_a, step_b) if s and s > 0]
1221 if not candidates: 1221 ↛ 1222line 1221 didn't jump to line 1222 because the condition on line 1221 was never true
1222 raise ValueError("X間隔を推定できません(Xの値が一定、または重複しています)。")
1223 step = min(candidates)
1225 lo = min(x_a.min(), x_b.min())
1226 hi = max(x_a.max(), x_b.max())
1227 n_points = int(np.ceil((hi - lo) / step)) + 1
1228 if n_points > _XCORR_MAX_GRID_POINTS: 1228 ↛ 1229line 1228 didn't jump to line 1229 because the condition on line 1228 was never true
1229 step = (hi - lo) / _XCORR_MAX_GRID_POINTS
1230 n_points = _XCORR_MAX_GRID_POINTS + 1
1231 grid = np.linspace(lo, hi, n_points)
1233 # 範囲外を含む全体で平均を引くので中心化は厳密でないが、ピーク位置を探すには足りる
1234 sig_a = np.interp(grid, x_a, y_a, left=0.0, right=0.0)
1235 sig_b = np.interp(grid, x_b, y_b, left=0.0, right=0.0)
1236 sig_a = sig_a - sig_a.mean()
1237 sig_b = sig_b - sig_b.mean()
1239 corr = correlate(sig_a, sig_b, mode='full')
1240 lags = correlation_lags(len(sig_a), len(sig_b), mode='full')
1241 best_idx = int(np.argmax(corr))
1242 shift = float(lags[best_idx] * step)
1244 return {'shift': shift, 'grid_step': float(step), 'correlation_peak': float(corr[best_idx])}
1247def assign_peak_label_levels(peak_x_values: Any, x_axis_span: float, proximity_ratio: float = 0.05,
1248 max_levels: int = 4) -> list[int]:
1249 """X が近いピークのラベルに互い違いの段番号を割り当てる(ずらす量は呼び出し側で決める)。"""
1250 peak_x_values = list(peak_x_values)
1251 if x_axis_span <= 0 or not peak_x_values:
1252 return [0] * len(peak_x_values)
1254 threshold = x_axis_span * proximity_ratio
1255 levels = []
1256 prev_x = None
1257 current_level = 0
1258 for x in peak_x_values:
1259 if prev_x is not None and abs(x - prev_x) < threshold:
1260 current_level = (current_level + 1) % max_levels
1261 else:
1262 current_level = 0
1263 levels.append(current_level)
1264 prev_x = x
1265 return levels
1268def split_dataframe_by_column(df: Any, split_col: str) -> dict[str, Any]:
1269 """区分列の値ごとに DataFrame を分ける(long 形式のデータ)。
1271 グループはファイル中の初出順(測定順などの並びに意味があることが多い)。
1272 区分列が NaN の行は除き、その数を n_dropped で返す。各グループの index は 0 から振り直す。
1273 """
1274 if split_col not in df.columns:
1275 raise ValueError(f"列 '{split_col}' がデータに存在しません。")
1277 values = df[split_col]
1278 keep = values.notna()
1279 n_dropped = int((~keep).sum())
1280 filtered = df[keep]
1282 groups = []
1283 for label, sub_df in filtered.groupby(split_col, sort=False, observed=True):
1284 groups.append((_format_group_label(label), sub_df.reset_index(drop=True)))
1286 return {'groups': groups, 'n_dropped': n_dropped, 'n_groups': len(groups)}
1289def _format_group_label(value: Any) -> str:
1290 """numpy のスカラは str() すると 'np.float64(1.5)' になるので、Python の型にしてから文字列にする。"""
1291 if hasattr(value, 'item'): 1291 ↛ 1292line 1291 didn't jump to line 1292 because the condition on line 1291 was never true
1292 try:
1293 value = value.item()
1294 except (ValueError, TypeError):
1295 pass
1296 if isinstance(value, float):
1297 return f"{value:g}"
1298 return str(value)