Coverage for graphica/gui/datasets/operations/processing.py: 94.8%
364 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"""データセットのデータ処理(規格化・平滑化・ベースライン補正・積分・リサンプリング・データセット間の演算など)の本体。
3列を数に直すのは op.as_numbers() / op.valid_points() で、文字の列ならその処理の題で知らせて止める。
4データセットを足す操作は op.add() で Undo できる追加にする(Undo の名前はその処理の題)。
5"""
6import logging
8import numpy as np
9import pandas as pd
10from PySide6.QtWidgets import QMessageBox
12from graphica.gui import notify
13from graphica.core.provenance import build_provenance
14from graphica.core.analysis import (calculate_savgol,
15 calculate_baseline_als, calculate_baseline_polynomial,
16 calculate_baseline_rubberband, calculate_baseline_manual,
17 calculate_interval_integral, calculate_cumulative_integral,
18 calculate_average_duplicate_x,
19 calculate_zscore_outliers, calculate_iqr_outliers,
20 calculate_resample_to_grid, calculate_histogram, calculate_kde, calculate_error_propagation,
21 calculate_cross_correlation_alignment, split_dataframe_by_column)
22from graphica.core.dataset import Dataset
23from graphica.core.safe_eval import safe_eval_column_formula
24from graphica.gui.datasets.operations.runner import Operation
25from graphica.gui.dialogs import (ResultDialog, ColumnCalculatorDialog, DatasetArithmeticDialog, NormalizeDatasetDialog, SavGolDialog, BaselineCorrectionDialog, IntervalIntegralDialog, CumulativeIntegralDialog,
26 ResampleDatasetDialog, DuplicateXDialog, RowFilterDialog, OutlierDetectionDialog,
27 HistogramKDEDialog, XAxisAlignmentDialog)
29logger = logging.getLogger(__name__)
31# 列の値で分割したとき、これを超える数の系列ができるなら確認する(連続値の列を取り違えた疑い)。
32SPLIT_BY_COLUMN_CONFIRM_THRESHOLD = 30
34INTEGRAL_RESULT_WINDOW = "interval_integral"
35OUTLIER_RESULT_WINDOW = "outliers"
38def arithmetic(op):
39 """選んだ2件 A・B の差・和・積・商を、B を A の X に線形補間してから計算し、新しいデータセットにする。"""
40 ds_a, ds_b = op.selected_datasets(
41 exactly=2, message="演算対象として、データセットをちょうど2つ選択してください。")[:2]
43 dialog = op.ask(DatasetArithmeticDialog(ds_a.name, ds_b.name, op.parent))
44 operation, output_name = dialog.get_settings()
45 op.require_output_name(output_name)
47 xa = op.as_numbers(ds_a, "x")
48 ya = op.as_numbers(ds_a, "y")
49 xb = op.as_numbers(ds_b, "x")
50 yb = op.as_numbers(ds_b, "y")
52 # 誤差は A・B の両方に誤差列があるときだけ伝播させる(片方だけだと不完全で誤解を招く)。
53 # 誤差列は x_data/y_data と行が対応しているので、同じ絞り込みをそのまま掛けられる。
54 err_a_full = ds_a.y_err_data
55 err_b_full = ds_b.y_err_data
56 propagate_errors = err_a_full is not None and err_b_full is not None
57 if propagate_errors:
58 err_a_full = np.asarray(err_a_full, dtype=float)
59 err_b_full = np.asarray(err_b_full, dtype=float)
61 valid_a = ~(np.isnan(xa) | np.isnan(ya))
62 valid_b = ~(np.isnan(xb) | np.isnan(yb))
63 xa, ya = xa[valid_a], ya[valid_a]
64 xb, yb = xb[valid_b], yb[valid_b]
65 if propagate_errors:
66 err_a_full = err_a_full[valid_a]
67 err_b_full = err_b_full[valid_b]
69 if len(xa) == 0 or len(xb) == 0:
70 op.stop_with_warning("有効なデータ点がありません。")
72 lo, hi = max(np.min(xa), np.min(xb)), min(np.max(xa), np.max(xb))
73 if lo > hi:
74 op.stop_with_warning("2つのデータセットのX軸の範囲が重なっていないため演算できません。")
76 mask = (xa >= lo) & (xa <= hi)
77 xa_sub, ya_sub = xa[mask], ya[mask]
78 if propagate_errors:
79 err_a_sub = err_a_full[mask]
80 if len(xa_sub) == 0: 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true
81 op.stop_with_warning("重なる範囲にA側のデータ点がありません。")
83 order_b = np.argsort(xb)
84 yb_interp = np.interp(xa_sub, xb[order_b], yb[order_b])
85 if propagate_errors:
86 err_b_interp = np.interp(xa_sub, xb[order_b], err_b_full[order_b])
88 if operation == "A - B":
89 result = ya_sub - yb_interp
90 elif operation == "B - A": 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true
91 result = yb_interp - ya_sub
92 elif operation == "A + B": 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 result = ya_sub + yb_interp
94 elif operation == "A × B": 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 result = ya_sub * yb_interp
96 elif operation == "A ÷ B": 96 ↛ 100line 96 didn't jump to line 100 because the condition on line 96 was always true
97 with np.errstate(divide='ignore', invalid='ignore'):
98 result = ya_sub / yb_interp
99 else: # "B ÷ A"
100 with np.errstate(divide='ignore', invalid='ignore'):
101 result = yb_interp / ya_sub
103 result_data = {'x': xa_sub, 'y': result}
104 if propagate_errors:
105 result_data['y_err'] = calculate_error_propagation(operation, ya_sub, err_a_sub, yb_interp, err_b_interp)
106 op.add_and_report(Dataset(
107 name=output_name, df=pd.DataFrame(result_data), x_col_name='x', y_col_name='y',
108 y_err_col_name='y_err' if propagate_errors else None,
109 provenance=build_provenance(
110 'arithmetic', {'operation_symbol': operation, 'error_propagated': propagate_errors}, [ds_a, ds_b],
111 ),
112 ))
115def align(op):
116 """選んだ2件のうち B を、A との相互相関で求めた X のずれだけ動かした新しいデータセットを作る(A が基準)。"""
117 ds_a, ds_b = op.selected_datasets(
118 exactly=2, message="位置合わせ対象として、データセットをちょうど2つ選択してください。")[:2]
120 dialog = op.ask(XAxisAlignmentDialog(ds_a.name, ds_b.name, op.parent))
121 output_name = dialog.get_settings()
122 op.require_output_name(output_name)
124 xa = op.as_numbers(ds_a, "x")
125 ya = op.as_numbers(ds_a, "y")
126 xb = op.as_numbers(ds_b, "x")
127 yb = op.as_numbers(ds_b, "y")
129 result = op.calculate(lambda: calculate_cross_correlation_alignment(xa, ya, xb, yb))
131 shift = result['shift']
132 valid_b = ~(np.isnan(xb) | np.isnan(yb))
133 op.add(Dataset(
134 name=output_name, df=pd.DataFrame({'x': xb[valid_b] + shift, 'y': yb[valid_b]}), x_col_name='x', y_col_name='y',
135 provenance=build_provenance(
136 'xaxis_alignment', {'shift': shift, 'grid_step': result['grid_step']}, [ds_a, ds_b],
137 ),
138 ))
139 op.status(f"「{output_name}」を追加しました(シフト量: {shift:+.4g})", 4000)
142def mean_and_sd(op):
143 """
144 選んだ2件以上を、X が重なる範囲の共通の格子へ線形補間し、点ごとの平均と標本標準偏差(ddof=1)を
145 誤差列付きの新しいデータセットにする。格子の点数は最も点の多いデータセットに合わせる。
146 """
147 selected = op.selected_datasets(
148 at_least=2, message="平均±SDの生成には、データセットを2つ以上選択してください。")
150 x_arrays, y_arrays = [], []
151 for ds in selected:
152 x, y = op.valid_points(ds)
153 if len(x) < 2: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 op.stop_with_warning(f"「{ds.name}」に有効なデータ点が不足しています(最低2点必要)。")
155 x_arrays.append(x)
156 y_arrays.append(y)
158 x_min = max(float(np.min(x)) for x in x_arrays)
159 x_max = min(float(np.max(x)) for x in x_arrays)
160 if x_min >= x_max:
161 op.stop_with_warning("選択したデータセット間でX軸の範囲が重なっていません。")
163 num_points = max(len(x) for x in x_arrays)
164 common_x = np.linspace(x_min, x_max, num_points)
166 resampled = []
167 for x, y in zip(x_arrays, y_arrays):
168 resampled.append(op.calculate(
169 lambda x=x, y=y: calculate_resample_to_grid(x, y, common_x, method='linear', extrapolate=False)))
170 stacked = np.vstack(resampled) # (データセット数, num_points)
172 mean_y = np.nanmean(stacked, axis=0)
173 std_y = np.nanstd(stacked, axis=0, ddof=1)
175 default_name = f"{selected[0].name} 他{len(selected) - 1}件の平均±SD"
176 output_name, ok = notify.get_text(op.parent, op.title, "出力データセット名:", text=default_name)
177 if not ok or not output_name.strip():
178 op.stop()
180 op.add_and_report(Dataset(
181 name=output_name.strip(), df=pd.DataFrame({'x': common_x, 'y_mean': mean_y, 'y_sd': std_y}),
182 x_col_name='x', y_col_name='y_mean', y_err_col_name='y_sd',
183 provenance=build_provenance('mean_sd', {'method': 'linear', 'n_source': len(selected)}, selected),
184 ))
187def normalize(op):
188 """今のデータセットの Y を、最大値か指定した X での値で割った新しいデータセットを作る。"""
189 original_dataset = op.current_dataset()
190 x_data, y_data = op.valid_points(original_dataset)
191 if len(x_data) == 0:
192 op.stop_with_warning("有効なデータ点がありません。")
194 x_min, x_max = float(np.min(x_data)), float(np.max(x_data))
195 dialog = op.ask(NormalizeDatasetDialog(original_dataset.name, x_min=x_min, x_max=x_max, parent=op.parent))
196 mode, reference_x, output_name = dialog.get_settings()
197 op.require_output_name(output_name)
199 if mode == NormalizeDatasetDialog.MODE_MAX:
200 reference_value = float(np.max(y_data))
201 else:
202 if reference_x < x_min or reference_x > x_max:
203 op.stop_with_warning(
204 f"指定されたX値 ({reference_x}) がデータセットのX軸範囲 "
205 f"({x_min} 〜 {x_max}) の外にあるため、規格化できません。"
206 )
207 order = np.argsort(x_data)
208 reference_value = float(np.interp(reference_x, x_data[order], y_data[order]))
210 if abs(reference_value) < 1e-12:
211 op.stop_with_warning("基準値が0に近すぎるため、規格化できません。")
213 op.add_and_report(Dataset(
214 name=output_name, df=pd.DataFrame({'x': x_data, 'y': y_data / reference_value}), x_col_name='x', y_col_name='y',
215 provenance=build_provenance(
216 'normalize',
217 {'mode': mode, 'reference_x': reference_x, 'reference_value': reference_value},
218 [original_dataset],
219 ),
220 ))
223def savgol_smooth(op):
224 """今のデータセットを Savitzky-Golay で平滑化(または微分)した新しいデータセットを作る。"""
225 original_dataset = op.current_dataset()
226 x_data, y_data = op.valid_points(original_dataset)
227 if len(x_data) < 3:
228 op.stop_with_warning("有効なデータ点が不足しています。")
230 dialog = op.ask(SavGolDialog(original_dataset.name, max_window=len(x_data), parent=op.parent))
231 window_length, polyorder, deriv, output_name = dialog.get_settings()
232 op.require_output_name(output_name)
234 x_sorted, y_result = op.calculate(
235 lambda: calculate_savgol(x_data, y_data, window_length, polyorder, deriv=deriv))
237 op.add_and_report(Dataset(
238 name=output_name, df=pd.DataFrame({'x': x_sorted, 'y': y_result}), x_col_name='x', y_col_name='y',
239 provenance=build_provenance(
240 'savgol', {'window_length': window_length, 'polyorder': polyorder, 'deriv': deriv}, [original_dataset],
241 ),
242 ))
245def _baseline(method, params, x_data, y_data):
246 if method == "als":
247 return calculate_baseline_als(x_data, y_data, **params)
248 if method == "polynomial":
249 return calculate_baseline_polynomial(x_data, y_data, **params)
250 if method == "rubberband":
251 return calculate_baseline_rubberband(x_data, y_data)
252 # "manual": アンカー点は自由記入のテキストで来るので、ここで数に直す(書式の誤りも同じ警告で出す)
253 anchor_text = params["anchor_x_text"]
254 try:
255 anchor_x = [float(token) for token in anchor_text.replace("\n", ",").split(",") if token.strip()]
256 except ValueError:
257 raise ValueError("アンカー点のX座標は数値をカンマ区切りで入力してください。") from None
258 return calculate_baseline_manual(x_data, y_data, anchor_x=anchor_x, method=params["method"])
261def baseline_correction(op):
262 """今のデータセットからベースラインを引いた新しいデータセットを作る(選べばベースライン自体も足す)。"""
263 original_dataset = op.current_dataset()
264 x_data, y_data = op.valid_points(original_dataset)
265 if len(x_data) < 3:
266 op.stop_with_warning("有効なデータ点が不足しています。")
268 x_min, x_max = float(np.min(x_data)), float(np.max(x_data))
269 dialog = op.ask(BaselineCorrectionDialog(original_dataset.name, x_min=x_min, x_max=x_max, parent=op.parent))
270 method, params, output_name, add_baseline_dataset = dialog.get_settings()
271 op.require_output_name(output_name)
273 x_sorted, baseline, corrected = op.calculate(lambda: _baseline(method, params, x_data, y_data))
275 # ベースライン曲線も足すときは、補正したものと合わせて Undo 1回分にする
276 with op.host.undo_macro(op.title, enabled=add_baseline_dataset):
277 op.add(Dataset(
278 name=output_name, df=pd.DataFrame({'x': x_sorted, 'y': corrected}), x_col_name='x', y_col_name='y',
279 provenance=build_provenance(f'baseline_{method}', dict(params), [original_dataset]),
280 ))
281 if add_baseline_dataset:
282 op.add(Dataset(
283 name=f"{output_name}_baseline", df=pd.DataFrame({'x': x_sorted, 'y': baseline}),
284 x_col_name='x', y_col_name='y',
285 ))
286 op.status(f"「{output_name}」を追加しました", 3000)
289def interval_integral(op):
290 """今のデータセットの指定した X 範囲の積分値を、結果の窓(グラフを見ながら確かめられるよう非モーダル)に出す。"""
291 original_dataset = op.current_dataset()
292 x_data, y_data = op.valid_points(original_dataset)
293 if len(x_data) < 2:
294 op.stop_with_warning("有効なデータ点が不足しています(最低2点必要)。")
296 x_min, x_max = float(np.min(x_data)), float(np.max(x_data))
297 dialog = op.ask(IntervalIntegralDialog(original_dataset.name, x_min=x_min, x_max=x_max, parent=op.parent))
298 method, x_range, subtract_baseline = dialog.get_settings()
300 result = op.calculate(lambda: calculate_interval_integral(
301 x_data, y_data, x_range, method=method, subtract_baseline=subtract_baseline))
303 method_label = "台形則(Trapezoidal)" if method == "trapezoid" else "Simpson則"
304 result_text = f"[{original_dataset.name}] の区間積分結果:\n"
305 result_text += f" 積分方法: {method_label}\n"
306 result_text += f" 積分範囲: {x_range[0]: .6g} 〜 {x_range[1]: .6g}\n"
307 result_text += f" ベースライン差し引き: {'あり(範囲両端を結ぶ直線)' if subtract_baseline else 'なし'}\n"
308 result_text += f" 使用データ点数: {result['n_points']}\n"
309 result_text += f" 積分値 = {result['integral']: .6e}\n"
311 op.show_result_window(INTEGRAL_RESULT_WINDOW, lambda: ResultDialog(
312 "区間積分完了", result_text, op.parent, csv_data=pd.DataFrame({
313 'X': result['x_used'],
314 'Y(元データ)': result['y_raw_used'],
315 'Y(積分に使用)': result['y_used'],
316 })))
319def cumulative_integral(op):
320 """今のデータセットの、X の各点までの積分値を新しいデータセットにする。"""
321 original_dataset = op.current_dataset()
322 x_data, y_data = op.valid_points(original_dataset)
323 if len(x_data) < 2:
324 op.stop_with_warning("有効なデータ点が不足しています(最低2点必要)。")
326 dialog = op.ask(CumulativeIntegralDialog(original_dataset.name, parent=op.parent))
327 method, output_name = dialog.get_settings()
328 op.require_output_name(output_name)
330 result = op.calculate(lambda: calculate_cumulative_integral(x_data, y_data, method=method))
332 op.add_and_report(Dataset(
333 name=output_name, df=pd.DataFrame({'x': result['x_used'], 'y': result['y_cumulative']}),
334 x_col_name='x', y_col_name='y',
335 provenance=build_provenance('cumulative_integral', {'method': method}, [original_dataset]),
336 ))
339def split_by_column(op):
340 """
341 区分列(試料名・条件など)の値ごとに、今のデータセットを別々のデータセットに分ける。元は残す。
342 Dataset.color の既定は全部同じ色なので、パレットから順に色を割り当てる。追加は Undo 1回分。
343 """
344 original_dataset = op.current_dataset()
345 columns = list(original_dataset.df.columns)
346 if not columns: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true
347 op.stop_with_warning("分割に使える列がありません。")
349 split_col, ok = notify.get_item(
350 op.parent, "列の値で系列に分割",
351 "分割に使う列(この列の値ごとに別々の系列になります):",
352 columns, 0, False,
353 )
354 if not ok or not split_col:
355 op.stop()
357 result = op.calculate(lambda: split_dataframe_by_column(original_dataset.df, split_col))
359 groups = result['groups']
360 if len(groups) < 2:
361 op.stop_with_information(
362 f"列「{split_col}」の値は{len(groups)}種類しかないため、"
363 "分割しても系列は増えません。",
364 )
366 if len(groups) > SPLIT_BY_COLUMN_CONFIRM_THRESHOLD:
367 answer = notify.question(
368 op.parent, op.title,
369 f"列「{split_col}」の値は{len(groups)}種類あります。\n"
370 f"同じ数({len(groups)}件)のデータセットを追加しますが、よろしいですか?\n\n"
371 "(連続値の列を選んでいる場合は、意図しない大量の系列になります)",
372 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
373 QMessageBox.StandardButton.No,
374 )
375 if answer != QMessageBox.StandardButton.Yes:
376 op.stop()
378 host = op.host
379 color_cycle = host.active_color_cycle()
380 target_folder = host.target_folder_for_new_dataset()
382 with host.batched_undo_group(f"列「{split_col}」で系列に分割 ({len(groups)}件)"):
383 for i, (label, sub_df) in enumerate(groups):
384 new_dataset = Dataset(
385 name=f"{original_dataset.name} ({label})",
386 df=sub_df,
387 x_col_name=original_dataset.x_col_name,
388 y_col_name=original_dataset.y_col_name,
389 color=color_cycle[i % len(color_cycle)],
390 provenance=build_provenance(
391 'split_by_column', {'split_column': split_col, 'group_value': label}, [original_dataset],
392 ),
393 )
394 host.add_dataset_with_undo(new_dataset, target_folder, description=f"「{new_dataset.name}」の追加")
396 message = f"列「{split_col}」の値で{len(groups)}件の系列に分割しました"
397 if result['n_dropped']: 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true
398 message += f"(「{split_col}」が空の{result['n_dropped']}行は除外)"
399 op.status(message, 5000)
402def resample(op):
403 """今のデータセットを、別のデータセットの X か等間隔の格子へ補間した新しいデータセットを作る。"""
404 original_dataset = op.current_dataset()
405 x_data, y_data = op.valid_points(original_dataset)
406 if len(x_data) < 2:
407 op.stop_with_warning("有効なデータ点が不足しています(最低2点必要)。")
409 other_dataset_names = [ds.name for ds in op.host.datasets() if ds is not original_dataset]
411 x_min, x_max = float(np.min(x_data)), float(np.max(x_data))
412 dialog = op.ask(ResampleDatasetDialog(
413 original_dataset.name, other_dataset_names, x_min=x_min, x_max=x_max, parent=op.parent
414 ))
415 source, params, method, extrapolate, output_name = dialog.get_settings()
416 op.require_output_name(output_name)
418 if source == "dataset":
419 target_dataset_name = params["dataset_name"]
420 target_dataset = next(
421 (ds for ds in op.host.datasets() if ds is not original_dataset and ds.name == target_dataset_name),
422 None
423 )
424 if target_dataset is None:
425 op.stop_with_warning("リサンプリング先のデータセットを選択してください。")
426 target_x = op.as_numbers(target_dataset, "x")
427 target_x = target_x[~np.isnan(target_x)]
428 if len(target_x) == 0: 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true
429 op.stop_with_warning(f"「{target_dataset_name}」に有効なX値がありません。")
430 else: # "linspace"
431 start, stop, num_points = params["start"], params["stop"], params["num_points"]
432 if start == stop:
433 op.stop_with_warning("開始Xと終了Xが同じ値です。")
434 target_x = np.linspace(start, stop, num_points)
436 result_y = op.calculate(lambda: calculate_resample_to_grid(
437 x_data, y_data, target_x, method=method, extrapolate=extrapolate))
439 # 選んだ格子の並び順をそのまま使う(並べ替えない)
440 provenance_sources = [original_dataset] + ([target_dataset] if source == "dataset" else [])
441 op.add_and_report(Dataset(
442 name=output_name, df=pd.DataFrame({'x': target_x, 'y': result_y}), x_col_name='x', y_col_name='y',
443 provenance=build_provenance(
444 'resample', {'source': source, 'method': method, 'extrapolate': extrapolate}, provenance_sources,
445 ),
446 ))
449def _histogram_or_kde_result(settings, column_data):
450 if settings['mode'] == 'histogram':
451 result = calculate_histogram(column_data, bins=settings['bins'], density=settings['density'])
452 result_df = pd.DataFrame({'x': result['bin_centers'], 'y': result['counts']})
453 params = {'column': settings['column'], 'bins': settings['bins'], 'density': settings['density']}
454 return result_df, params, 'Bar'
455 result = calculate_kde(column_data, n_points=settings['n_points'])
456 result_df = pd.DataFrame({'x': result['x_grid'], 'y': result['density']})
457 params = {'column': settings['column'], 'n_points': settings['n_points']}
458 return result_df, params, 'Line'
461def histogram_or_kde(op):
462 """今のデータセットの数値列1つのヒストグラムかカーネル密度推定を、新しいデータセットにする。"""
463 original_dataset = op.current_dataset()
464 numeric_columns = original_dataset.df.select_dtypes(include=[np.number]).columns.tolist()
465 if not numeric_columns: 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true
466 op.stop_with_warning("数値列がありません。")
467 default_column = original_dataset.y_col_name if original_dataset.y_col_name in numeric_columns else numeric_columns[0]
469 dialog = op.ask(HistogramKDEDialog(
470 original_dataset.name, numeric_columns, default_column=default_column, parent=op.parent))
471 settings = dialog.get_settings()
472 op.require_output_name(settings['output_name'])
474 column_data = original_dataset.visible_df[settings['column']]
475 result_df, params, plot_type = op.calculate(lambda: _histogram_or_kde_result(settings, column_data))
477 op.add_and_report(Dataset(
478 name=settings['output_name'], df=result_df, x_col_name='x', y_col_name='y',
479 plot_type=plot_type,
480 provenance=build_provenance(settings['mode'], params, [original_dataset]),
481 ))
484def detect_duplicate_x(op):
485 """
486 同じ X の行を見つけ、平均した新しいデータセットを作るか、各 X の最初の行以外を除外(マスク)する。
487 平均は除外済みの行を除いたデータから、除外する行の判定は df 全体から行う。
488 """
489 original_dataset = op.current_dataset()
490 x_col = original_dataset.x_col_name
491 n_duplicate_rows = int(original_dataset.df[x_col].duplicated(keep=False).sum())
492 if n_duplicate_rows == 0:
493 op.stop_with_information("重複するX値を持つ行は見つかりませんでした。")
495 dialog = op.ask(DuplicateXDialog(original_dataset.name, n_duplicate_rows, parent=op.parent))
496 mode, output_name = dialog.get_settings()
498 if mode == "average":
499 op.require_output_name(output_name)
500 x_data = op.as_numbers(original_dataset, "x")
501 y_data = op.as_numbers(original_dataset, "y")
502 result = op.calculate(lambda: calculate_average_duplicate_x(x_data, y_data))
503 op.add_and_report(Dataset(
504 name=output_name, df=pd.DataFrame({'x': result['x_used'], 'y': result['y_averaged']}),
505 x_col_name='x', y_col_name='y',
506 provenance=build_provenance(
507 'average_duplicate_x',
508 {'n_duplicate_groups': result['n_duplicate_groups'],
509 'n_points_in': result['n_points_in'], 'n_points_out': result['n_points_out']},
510 [original_dataset],
511 ),
512 ))
513 else: # "remove"
514 to_mask_indices = original_dataset.df.index[original_dataset.df[x_col].duplicated(keep='first')].tolist()
515 old_masked = list(original_dataset.masked_row_indices)
516 new_masked = sorted(set(old_masked) | set(to_mask_indices))
517 if new_masked == old_masked:
518 op.stop_with_information("既にすべてマスク済みです。")
519 op.host.push_masked_rows(
520 original_dataset, old_masked, new_masked,
521 description=f"重複X値の除去({len(to_mask_indices)}件をマスク)",
522 )
523 op.status(f"{len(to_mask_indices)}件をマスクしました", 3000)
526def filter_rows(op):
527 """条件式を満たさない行を除外(マスク)する。今までの除外に足す。"""
528 original_dataset = op.current_dataset()
530 dialog = op.ask(RowFilterDialog(original_dataset.df.columns.tolist(), parent=op.parent))
531 formula = dialog.get_formula()
532 if not formula:
533 op.stop_with_warning("条件式が空です。", title="入力エラー")
535 try:
536 match_result = safe_eval_column_formula(original_dataset.df, formula)
537 except Exception as e:
538 logger.exception("行フィルタの条件式を評価できませんでした")
539 op.stop_with_warning(f"条件式の評価に失敗しました:\n{e}")
541 # 真偽値以外が返ったら pandas の規約で bool にする。NaN は偽(=除外する側)に倒す。
542 try:
543 match_bool = match_result.astype(bool)
544 except (TypeError, ValueError):
545 op.stop_with_warning("条件式の結果を真偽値に変換できませんでした。")
546 match_bool = match_bool.fillna(False) if hasattr(match_bool, 'fillna') else match_bool
548 to_mask_indices = original_dataset.df.index[~match_bool].tolist()
549 old_masked = list(original_dataset.masked_row_indices)
550 new_masked = sorted(set(old_masked) | set(to_mask_indices))
551 if new_masked == old_masked:
552 op.stop_with_information("条件を満たさない(新たにマスクされる)行はありませんでした。")
554 op.host.push_masked_rows(original_dataset, old_masked, new_masked, description=f"行フィルタ({formula})")
555 op.status(f"{len(new_masked) - len(old_masked)}件をマスクしました", 3000)
558def _outliers(method, value, y_data):
559 if method == "zscore":
560 return calculate_zscore_outliers(y_data, threshold=value), f"Z-score(しきい値 |Z| > {value:g})"
561 return calculate_iqr_outliers(y_data, multiplier=value), f"IQR(係数 {value:g})"
564def detect_outliers(op):
565 """Y の外れ値を Z-score か IQR で探して結果を出す。除外(マスク)は利用者が選んだときだけ。"""
566 original_dataset = op.current_dataset()
567 y_data = op.as_numbers(original_dataset, "y")
568 if len(y_data) < 2:
569 op.stop_with_warning("有効なデータ点が不足しています(最低2点必要)。")
571 dialog = op.ask(OutlierDetectionDialog(original_dataset.name, parent=op.parent))
572 method, value, apply_to_mask = dialog.get_settings()
574 result, method_label = op.calculate(lambda: _outliers(method, value, y_data))
576 is_outlier = result['is_outlier']
577 x_data = op.as_numbers(original_dataset, "x")
578 visible_index = original_dataset.visible_df.index
580 result_text = f"[{original_dataset.name}] の外れ値検出結果:\n"
581 result_text += f" 検出方法: {method_label}\n"
582 result_text += f" 検出件数: {int(np.sum(is_outlier))}件 / 全{len(y_data)}件\n"
584 if apply_to_mask and is_outlier.any():
585 to_mask_indices = visible_index[is_outlier].tolist()
586 old_masked = list(original_dataset.masked_row_indices)
587 new_masked = sorted(set(old_masked) | set(to_mask_indices))
588 op.host.push_masked_rows(
589 original_dataset, old_masked, new_masked, description=f"外れ値の自動マスク({method_label})",
590 )
591 result_text += f" → {len(to_mask_indices)}件をマスクに追加しました。\n"
592 elif apply_to_mask: 592 ↛ 593line 592 didn't jump to line 593 because the condition on line 592 was never true
593 result_text += " (マスク対象の行はありませんでした)\n"
594 else:
595 result_text += " (プレビューのみ、マスクは適用していません)\n"
597 outlier_csv_data = pd.DataFrame({'X': x_data[is_outlier], 'Y': y_data[is_outlier]})
598 op.show_result_window(OUTLIER_RESULT_WINDOW, lambda: ResultDialog(
599 "外れ値検出完了", result_text, op.parent, csv_data=outlier_csv_data))
602def batch_column_calculate(op):
603 """選んだデータセットすべてに同じ計算式で列を足す。データエディタの列計算と同じく Undo の対象ではない。"""
604 selected = op.selected_datasets(at_least=2, message="2つ以上のデータセットを選択してください。")
606 # 式の候補には、選んだデータセットすべてにある列だけを出す
607 common_columns = set(selected[0].df.columns)
608 for ds in selected[1:]:
609 common_columns &= set(ds.df.columns)
611 dialog = op.ask(ColumnCalculatorDialog(sorted(str(c) for c in common_columns), op.parent))
612 output_col, formula = dialog.get_formula()
613 if not output_col or not formula:
614 op.stop_with_warning("出力列または計算式が空です。", title="入力エラー")
616 succeeded, failed = [], []
617 for dataset in selected:
618 try:
619 dataset.df[output_col] = safe_eval_column_formula(dataset.df, formula)
620 dataset.invalidate_visible_df_cache()
621 succeeded.append(dataset.name)
622 except Exception as e:
623 failed.append(f"{dataset.name}: {e}")
625 op.host.refresh_ui_state()
626 op.host.redraw()
628 message = f"{len(succeeded)}件のデータセットに適用しました。"
629 if failed:
630 message += "\n\n失敗:\n" + "\n".join(failed)
631 op.information(message)
634PROCESSING_OPERATIONS = {
635 "arithmetic": Operation("データセット間演算", arithmetic),
636 "align": Operation("X軸アライメント", align),
637 "mean_and_sd": Operation("平均±SD生成", mean_and_sd),
638 "normalize": Operation("規格化(ノーマライズ)", normalize),
639 "savgol_smooth": Operation("Savitzky-Golayフィルタ", savgol_smooth),
640 "baseline_correction": Operation("ベースライン補正", baseline_correction),
641 "interval_integral": Operation("区間積分", interval_integral),
642 "cumulative_integral": Operation("累積積分", cumulative_integral),
643 "split_by_column": Operation("系列に分割", split_by_column),
644 "resample": Operation("共通X格子へのリサンプリング/補間", resample),
645 "histogram_or_kde": Operation("ヒストグラム / KDE", histogram_or_kde),
646 "detect_duplicate_x": Operation("重複X値の検出", detect_duplicate_x),
647 "filter_rows": Operation("行フィルタ", filter_rows),
648 "detect_outliers": Operation("外れ値検出", detect_outliers),
649 "batch_column_calculate": Operation("バッチ列計算", batch_column_calculate),
650}