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

1"""データセットのデータ処理(規格化・平滑化・ベースライン補正・積分・リサンプリング・データセット間の演算など)の本体。 

2 

3列を数に直すのは op.as_numbers() / op.valid_points() で、文字の列ならその処理の題で知らせて止める。 

4データセットを足す操作は op.add() で Undo できる追加にする(Undo の名前はその処理の題)。 

5""" 

6import logging 

7 

8import numpy as np 

9import pandas as pd 

10from PySide6.QtWidgets import QMessageBox 

11 

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) 

28 

29logger = logging.getLogger(__name__) 

30 

31# 列の値で分割したとき、これを超える数の系列ができるなら確認する(連続値の列を取り違えた疑い)。 

32SPLIT_BY_COLUMN_CONFIRM_THRESHOLD = 30 

33 

34INTEGRAL_RESULT_WINDOW = "interval_integral" 

35OUTLIER_RESULT_WINDOW = "outliers" 

36 

37 

38def arithmetic(op): 

39 """選んだ2件 A・B の差・和・積・商を、B を A の X に線形補間してから計算し、新しいデータセットにする。""" 

40 ds_a, ds_b = op.selected_datasets( 

41 exactly=2, message="演算対象として、データセットをちょうど2つ選択してください。")[:2] 

42 

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) 

46 

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") 

51 

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) 

60 

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] 

68 

69 if len(xa) == 0 or len(xb) == 0: 

70 op.stop_with_warning("有効なデータ点がありません。") 

71 

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軸の範囲が重なっていないため演算できません。") 

75 

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側のデータ点がありません。") 

82 

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]) 

87 

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 

102 

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 )) 

113 

114 

115def align(op): 

116 """選んだ2件のうち B を、A との相互相関で求めた X のずれだけ動かした新しいデータセットを作る(A が基準)。""" 

117 ds_a, ds_b = op.selected_datasets( 

118 exactly=2, message="位置合わせ対象として、データセットをちょうど2つ選択してください。")[:2] 

119 

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) 

123 

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") 

128 

129 result = op.calculate(lambda: calculate_cross_correlation_alignment(xa, ya, xb, yb)) 

130 

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) 

140 

141 

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つ以上選択してください。") 

149 

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) 

157 

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軸の範囲が重なっていません。") 

162 

163 num_points = max(len(x) for x in x_arrays) 

164 common_x = np.linspace(x_min, x_max, num_points) 

165 

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) 

171 

172 mean_y = np.nanmean(stacked, axis=0) 

173 std_y = np.nanstd(stacked, axis=0, ddof=1) 

174 

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() 

179 

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 )) 

185 

186 

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("有効なデータ点がありません。") 

193 

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) 

198 

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])) 

209 

210 if abs(reference_value) < 1e-12: 

211 op.stop_with_warning("基準値が0に近すぎるため、規格化できません。") 

212 

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 )) 

221 

222 

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("有効なデータ点が不足しています。") 

229 

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) 

233 

234 x_sorted, y_result = op.calculate( 

235 lambda: calculate_savgol(x_data, y_data, window_length, polyorder, deriv=deriv)) 

236 

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 )) 

243 

244 

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"]) 

259 

260 

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("有効なデータ点が不足しています。") 

267 

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) 

272 

273 x_sorted, baseline, corrected = op.calculate(lambda: _baseline(method, params, x_data, y_data)) 

274 

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) 

287 

288 

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点必要)。") 

295 

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() 

299 

300 result = op.calculate(lambda: calculate_interval_integral( 

301 x_data, y_data, x_range, method=method, subtract_baseline=subtract_baseline)) 

302 

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" 

310 

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 }))) 

317 

318 

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点必要)。") 

325 

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) 

329 

330 result = op.calculate(lambda: calculate_cumulative_integral(x_data, y_data, method=method)) 

331 

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 )) 

337 

338 

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("分割に使える列がありません。") 

348 

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() 

356 

357 result = op.calculate(lambda: split_dataframe_by_column(original_dataset.df, split_col)) 

358 

359 groups = result['groups'] 

360 if len(groups) < 2: 

361 op.stop_with_information( 

362 f"列「{split_col}」の値は{len(groups)}種類しかないため、" 

363 "分割しても系列は増えません。", 

364 ) 

365 

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() 

377 

378 host = op.host 

379 color_cycle = host.active_color_cycle() 

380 target_folder = host.target_folder_for_new_dataset() 

381 

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}」の追加") 

395 

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) 

400 

401 

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点必要)。") 

408 

409 other_dataset_names = [ds.name for ds in op.host.datasets() if ds is not original_dataset] 

410 

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) 

417 

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) 

435 

436 result_y = op.calculate(lambda: calculate_resample_to_grid( 

437 x_data, y_data, target_x, method=method, extrapolate=extrapolate)) 

438 

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 )) 

447 

448 

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' 

459 

460 

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] 

468 

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']) 

473 

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)) 

476 

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 )) 

482 

483 

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値を持つ行は見つかりませんでした。") 

494 

495 dialog = op.ask(DuplicateXDialog(original_dataset.name, n_duplicate_rows, parent=op.parent)) 

496 mode, output_name = dialog.get_settings() 

497 

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) 

524 

525 

526def filter_rows(op): 

527 """条件式を満たさない行を除外(マスク)する。今までの除外に足す。""" 

528 original_dataset = op.current_dataset() 

529 

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="入力エラー") 

534 

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}") 

540 

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 

547 

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("条件を満たさない(新たにマスクされる)行はありませんでした。") 

553 

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) 

556 

557 

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})" 

562 

563 

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点必要)。") 

570 

571 dialog = op.ask(OutlierDetectionDialog(original_dataset.name, parent=op.parent)) 

572 method, value, apply_to_mask = dialog.get_settings() 

573 

574 result, method_label = op.calculate(lambda: _outliers(method, value, y_data)) 

575 

576 is_outlier = result['is_outlier'] 

577 x_data = op.as_numbers(original_dataset, "x") 

578 visible_index = original_dataset.visible_df.index 

579 

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" 

583 

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" 

596 

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)) 

600 

601 

602def batch_column_calculate(op): 

603 """選んだデータセットすべてに同じ計算式で列を足す。データエディタの列計算と同じく Undo の対象ではない。""" 

604 selected = op.selected_datasets(at_least=2, message="2つ以上のデータセットを選択してください。") 

605 

606 # 式の候補には、選んだデータセットすべてにある列だけを出す 

607 common_columns = set(selected[0].df.columns) 

608 for ds in selected[1:]: 

609 common_columns &= set(ds.df.columns) 

610 

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="入力エラー") 

615 

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}") 

624 

625 op.host.refresh_ui_state() 

626 op.host.redraw() 

627 

628 message = f"{len(succeeded)}件のデータセットに適用しました。" 

629 if failed: 

630 message += "\n\n失敗:\n" + "\n".join(failed) 

631 op.information(message) 

632 

633 

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}