Coverage for graphica/gui/dialogs/analysis.py: 75.8%
886 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"""解析のダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。"""
3import logging
5import numpy as np
6from PySide6.QtWidgets import (
7 QApplication,
8 QCheckBox,
9 QComboBox,
10 QDialog,
11 QDialogButtonBox,
12 QDoubleSpinBox,
13 QFormLayout,
14 QHBoxLayout,
15 QLabel,
16 QLineEdit,
17 QPlainTextEdit,
18 QPushButton,
19 QSpinBox,
20 QStackedWidget,
21 QTableWidget,
22 QTableWidgetItem,
23 QVBoxLayout,
24 QWidget,
25)
26from PySide6.QtCore import QTimer, Qt
27from PySide6.QtGui import QFont
28from graphica.gui import notify
29from graphica.gui import dialog_dirs
30from graphica.gui.theme import apply_form_spacing
32logger = logging.getLogger(__name__)
35class FitDialog(QDialog):
36 """フィットのモデルと条件を選ぶ。"""
38 def __init__(self, parent=None, x_min=None, x_max=None):
39 """x_min / x_max はフィット範囲の欄の初期値(ふつうはデータの X の範囲)。"""
40 super().__init__(parent)
41 self.setWindowTitle("曲線フィット")
43 layout = QVBoxLayout(self)
45 layout.addWidget(QLabel("フィットする関数の種類を選択してください:"))
47 self.fit_type_combo = QComboBox()
48 # scipy を読み込むので、ダイアログを開くときまで遅らせる
49 from graphica.core.analysis import get_plugin_fit_type_names
50 from graphica.core.fit_models import CUSTOM_FORMULA_LABEL, builtin_menu_labels
51 # プラグインのフィット関数は、組み込みと「カスタム数式...」の間に入れる
52 self.fit_type_combo.addItems(builtin_menu_labels())
53 self.fit_type_combo.addItems(get_plugin_fit_type_names())
54 self.fit_type_combo.addItem(CUSTOM_FORMULA_LABEL)
55 self.fit_type_combo.currentTextChanged.connect(self._on_fit_type_changed)
56 layout.addWidget(self.fit_type_combo)
58 self.custom_formula_label = QLabel("数式 (xとパラメータ名を使って入力、例: a*exp(-b*x)+c)")
59 self.custom_formula_edit = QLineEdit()
60 self.custom_formula_edit.setPlaceholderText("a*exp(-b*x)+c")
61 self.custom_formula_label.setVisible(False)
62 self.custom_formula_edit.setVisible(False)
63 layout.addWidget(self.custom_formula_label)
64 layout.addWidget(self.custom_formula_edit)
66 self.weighted_checkbox = QCheckBox("Y誤差列を重みとして使用する(設定されている場合)")
67 self.weighted_checkbox.setToolTip(
68 "誤差が大きい点ほどフィットへの影響を小さくする(scipy.optimize.curve_fitのsigma)。\n"
69 "対象データセットにY誤差列が設定されていない場合はチェックしても効果がありません。"
70 )
71 layout.addWidget(self.weighted_checkbox)
73 loss_form = QFormLayout()
74 self.loss_combo = QComboBox()
75 self.loss_combo.addItem("通常の最小二乗(既定)", "linear")
76 self.loss_combo.addItem("ロバスト(Soft L1)", "soft_l1")
77 self.loss_combo.addItem("ロバスト(Huber)", "huber")
78 self.loss_combo.setToolTip(
79 "外れ値に引きずられにくい損失関数に切り替える(scipy.optimize.least_squares)。\n"
80 "Soft L1/Huberはいずれも外れ値の寄与を線形二乗より抑える点で似ているが、\n"
81 "Huberの方が正常値付近では通常の最小二乗に近い挙動になる。"
82 )
83 loss_form.addRow("損失関数", self.loss_combo)
84 layout.addLayout(loss_form)
86 self.range_checkbox = QCheckBox("フィット範囲を指定する")
87 layout.addWidget(self.range_checkbox)
89 range_form = QFormLayout()
90 self.range_min_spinbox = QDoubleSpinBox()
91 self.range_min_spinbox.setRange(-1e12, 1e12)
92 self.range_min_spinbox.setDecimals(6)
93 self.range_min_spinbox.setEnabled(False)
94 if x_min is not None:
95 self.range_min_spinbox.setValue(x_min)
96 range_form.addRow("最小X", self.range_min_spinbox)
98 self.range_max_spinbox = QDoubleSpinBox()
99 self.range_max_spinbox.setRange(-1e12, 1e12)
100 self.range_max_spinbox.setDecimals(6)
101 self.range_max_spinbox.setEnabled(False)
102 if x_max is not None:
103 self.range_max_spinbox.setValue(x_max)
104 range_form.addRow("最大X", self.range_max_spinbox)
105 layout.addLayout(range_form)
107 self.range_checkbox.toggled.connect(self.range_min_spinbox.setEnabled)
108 self.range_checkbox.toggled.connect(self.range_max_spinbox.setEnabled)
110 layout.addWidget(QLabel("パラメータごとの初期値・固定・範囲拘束(収束しない場合に指定してください):"))
111 self.param_table = QTableWidget(0, 6)
112 self.param_table.setHorizontalHeaderLabels(
113 ["パラメータ", "初期値/固定値", "固定", "範囲拘束", "最小", "最大"]
114 )
115 self.param_table.verticalHeader().setVisible(False)
116 self.param_table.setMaximumHeight(180)
117 layout.addWidget(self.param_table)
119 self.fit_type_combo.currentTextChanged.connect(self._rebuild_param_table)
120 # 入力途中でもパラメータの数が変わるので、1文字ごとに作り直す(解釈できなければ0行になるだけ)
121 self.custom_formula_edit.textChanged.connect(self._rebuild_param_table)
123 band_form = QFormLayout()
124 self.band_combo = QComboBox()
125 self.band_combo.addItems(["表示しない", "信頼帯 (95%)", "予測帯 (95%)"])
126 self.band_combo.setToolTip(
127 "信頼帯: 真の回帰曲線が収まると考えられる範囲(パラメータの不確かさのみ)。\n"
128 "予測帯: 次に測定する新しい1点が収まると考えられる範囲\n"
129 "(パラメータの不確かさに加え、観測ノイズの分散も加味するため信頼帯より広くなる)。"
130 )
131 band_form.addRow("信頼帯/予測帯", self.band_combo)
132 layout.addLayout(band_form)
134 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
135 QDialogButtonBox.StandardButton.Cancel)
136 button_box.accepted.connect(self.accept)
137 button_box.rejected.connect(self.reject)
138 layout.addWidget(button_box)
140 self._rebuild_param_table()
142 def _on_fit_type_changed(self, text):
143 from graphica.core.fit_models import is_custom_formula_type
144 is_custom = is_custom_formula_type(text)
145 self.custom_formula_label.setVisible(is_custom)
146 self.custom_formula_edit.setVisible(is_custom)
148 def _rebuild_param_table(self, *_args):
149 """フィットの種類か数式が変わるたびにパラメータの行を作り直す。まだ決まらない間は0行。
151 値の欄は、「固定」が外れていれば初期値(触った行だけ p0_overrides に入る)、入っていれば固定値。
152 固定したパラメータには範囲拘束を付けられない。
153 """
154 from graphica.core.fit_models import is_custom_formula_type
155 fit_type = self.fit_type_combo.currentText()
156 custom_formula = self.custom_formula_edit.text().strip() if is_custom_formula_type(fit_type) else None
157 try:
158 from graphica.core.analysis import get_fit_param_names
159 param_names = get_fit_param_names(fit_type, custom_formula)
160 except ValueError:
161 param_names = []
163 self.param_table.setRowCount(0)
164 self.param_table.setRowCount(len(param_names))
165 for row, name in enumerate(param_names):
166 name_item = QTableWidgetItem(name)
167 name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
168 self.param_table.setItem(row, 0, name_item)
170 value_spin = QDoubleSpinBox()
171 value_spin.setRange(-1e12, 1e12)
172 value_spin.setDecimals(6)
173 value_spin.blockSignals(True)
174 value_spin.setValue(1.0)
175 value_spin.blockSignals(False)
176 # 触った行だけ初期値を上書きする(触らなければ種類ごとの自動推定のまま)。
177 # 自動推定の値はデータが要るので、このダイアログでは出せない
178 value_spin.setProperty("_user_overridden", False)
179 value_spin.valueChanged.connect(
180 lambda _v, w=value_spin: w.setProperty("_user_overridden", True)
181 )
182 self.param_table.setCellWidget(row, 1, value_spin)
184 fixed_check = QCheckBox()
185 self.param_table.setCellWidget(row, 2, fixed_check)
187 range_check = QCheckBox()
188 self.param_table.setCellWidget(row, 3, range_check)
190 min_spin = QDoubleSpinBox()
191 min_spin.setRange(-1e12, 1e12)
192 min_spin.setDecimals(6)
193 min_spin.setValue(-1e12)
194 min_spin.setEnabled(False)
195 self.param_table.setCellWidget(row, 4, min_spin)
197 max_spin = QDoubleSpinBox()
198 max_spin.setRange(-1e12, 1e12)
199 max_spin.setDecimals(6)
200 max_spin.setValue(1e12)
201 max_spin.setEnabled(False)
202 self.param_table.setCellWidget(row, 5, max_spin)
204 fixed_check.toggled.connect(
205 lambda checked, rc=range_check, mn=min_spin, mx=max_spin:
206 self._on_param_fixed_toggled(checked, rc, mn, mx)
207 )
208 range_check.toggled.connect(
209 lambda checked, mn=min_spin, mx=max_spin: (mn.setEnabled(checked), mx.setEnabled(checked))
210 )
212 self.param_table.resizeColumnsToContents()
214 @staticmethod
215 def _on_param_fixed_toggled(checked, range_check, min_spin, max_spin):
216 """固定したパラメータは最適化されないので、範囲拘束の欄を無効にする。"""
217 range_check.setEnabled(not checked)
218 min_spin.setEnabled((not checked) and range_check.isChecked())
219 max_spin.setEnabled((not checked) and range_check.isChecked())
221 def get_param_settings(self):
222 """calculate_curve_fit() に渡す (p0_overrides, fixed_params, bounds)。何も変えなければ空の dict。"""
223 p0_overrides, fixed_params, bounds = {}, {}, {}
224 for row in range(self.param_table.rowCount()):
225 name = self.param_table.item(row, 0).text()
226 value_spin = self.param_table.cellWidget(row, 1)
227 fixed_check = self.param_table.cellWidget(row, 2)
228 range_check = self.param_table.cellWidget(row, 3)
229 min_spin = self.param_table.cellWidget(row, 4)
230 max_spin = self.param_table.cellWidget(row, 5)
232 if fixed_check.isChecked():
233 fixed_params[name] = value_spin.value()
234 continue
236 if value_spin.property("_user_overridden"):
237 p0_overrides[name] = value_spin.value()
239 if range_check.isChecked():
240 bounds[name] = (min_spin.value(), max_spin.value())
242 return p0_overrides, fixed_params, bounds
244 def get_band_type(self):
245 """"confidence" / "prediction" / None(表示しない)。"""
246 text = self.band_combo.currentText()
247 if "信頼帯" in text: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 return "confidence"
249 if "予測帯" in text:
250 return "prediction"
251 return None
253 def get_weighted(self):
254 return self.weighted_checkbox.isChecked()
256 def get_loss(self):
257 """'linear' / 'soft_l1' / 'huber'。"""
258 return self.loss_combo.currentData()
260 def get_x_range(self):
261 """指定があれば (最小X, 最大X)、無ければ None。"""
262 if not self.range_checkbox.isChecked():
263 return None
264 return (self.range_min_spinbox.value(), self.range_max_spinbox.value())
266 @staticmethod
267 def get_fit_type(parent=None, x_min=None, x_max=None):
268 """OK なら (種類, 数式, 重み付け, 範囲, p0_overrides, fixed_params, bounds, band_type, loss)。
270 キャンセルなら (None, None, False, None, {}, {}, {}, None, 'linear')。
271 """
272 dialog = FitDialog(parent, x_min=x_min, x_max=x_max)
273 if dialog.exec() == QDialog.DialogCode.Accepted:
274 from graphica.core.fit_models import is_custom_formula_type
275 fit_type = dialog.fit_type_combo.currentText()
276 custom_formula = dialog.custom_formula_edit.text().strip() if is_custom_formula_type(fit_type) else None
277 p0_overrides, fixed_params, bounds = dialog.get_param_settings()
278 return (fit_type, custom_formula, dialog.get_weighted(), dialog.get_x_range(),
279 p0_overrides, fixed_params, bounds, dialog.get_band_type(), dialog.get_loss())
280 return None, None, False, None, {}, {}, {}, None, 'linear'
283class MultiPeakFitDialog(QDialog):
284 """多峰分離の設定。各ピークの初期値の行は、手で、ピーク検出から、またはキャンバスのクリックで入れる。"""
286 COMPONENT_TYPES = [
287 ('gaussian', 'ガウシアン'),
288 ('lorentzian', 'ローレンツ'),
289 ('pseudo_voigt', '擬似フォークト'),
290 ('voigt', 'フォークト'),
291 ]
292 BASELINE_TYPES = [
293 ('none', 'なし'),
294 ('constant', '定数'),
295 ('linear', '1次'),
296 ]
298 def __init__(self, parent=None, x_data=None, y_data=None, initial_guesses=None):
299 """x_data / y_data が無ければ「ピーク検出から自動配置」を無効にする。initial_guesses はクリックで集めた初期値。"""
300 super().__init__(parent)
301 self.setWindowTitle("多峰分離フィット")
302 self._x_data = x_data
303 self._y_data = y_data
305 layout = QVBoxLayout(self)
307 form = QFormLayout()
308 self.component_combo = QComboBox()
309 for key, label in self.COMPONENT_TYPES:
310 self.component_combo.addItem(label, key)
311 form.addRow("成分タイプ(全ピーク共通)", self.component_combo)
313 self.baseline_combo = QComboBox()
314 for key, label in self.BASELINE_TYPES:
315 self.baseline_combo.addItem(label, key)
316 self.baseline_combo.setCurrentIndex(1) # 定数
317 form.addRow("ベースライン", self.baseline_combo)
318 layout.addLayout(form)
320 layout.addWidget(QLabel(
321 "各ピークの初期値(中心X・高さ・おおよその幅[FWHM]):"
322 ))
323 self.guess_table = QTableWidget(0, 3)
324 self.guess_table.setHorizontalHeaderLabels(["中心 (X)", "高さ", "幅 (FWHM)"])
325 self.guess_table.verticalHeader().setVisible(False)
326 self.guess_table.setMaximumHeight(200)
327 layout.addWidget(self.guess_table)
329 row_button_layout = QHBoxLayout()
330 add_row_button = QPushButton("行を追加")
331 add_row_button.clicked.connect(lambda: self._add_guess_row())
332 remove_row_button = QPushButton("選択行を削除")
333 remove_row_button.clicked.connect(self._remove_selected_rows)
334 self.auto_detect_button = QPushButton("ピーク検出から自動配置...")
335 self.auto_detect_button.clicked.connect(self._on_auto_detect)
336 self.auto_detect_button.setEnabled(x_data is not None and y_data is not None)
337 row_button_layout.addWidget(add_row_button)
338 row_button_layout.addWidget(remove_row_button)
339 row_button_layout.addWidget(self.auto_detect_button)
340 row_button_layout.addStretch()
341 layout.addLayout(row_button_layout)
343 for guess in (initial_guesses or []):
344 self._add_guess_row(
345 center=guess.get('center', 0.0),
346 height=guess.get('height', 0.0),
347 width=guess.get('width', 1.0),
348 )
350 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
351 QDialogButtonBox.StandardButton.Cancel)
352 button_box.accepted.connect(self._on_accept)
353 button_box.rejected.connect(self.reject)
354 layout.addWidget(button_box)
356 def _add_guess_row(self, center=0.0, height=0.0, width=1.0):
357 row = self.guess_table.rowCount()
358 self.guess_table.insertRow(row)
359 for col, value in enumerate((center, height, width)):
360 spin = QDoubleSpinBox()
361 spin.setRange(-1e12, 1e12)
362 spin.setDecimals(6)
363 spin.setValue(float(value))
364 self.guess_table.setCellWidget(row, col, spin)
366 def _remove_selected_rows(self):
367 rows = sorted({idx.row() for idx in self.guess_table.selectedIndexes()}, reverse=True)
368 for row in rows:
369 self.guess_table.removeRow(row)
371 def _on_auto_detect(self):
372 """ピーク検出の結果(中心・高さ・FWHM)を行に足す。既にある行は消さない(クリックでの配置と併用できる)。"""
373 settings = PeakSettingsDialog.get_peak_settings(self)
374 if settings is None:
375 return
376 from graphica.core.analysis import calculate_peak_quantification
377 try:
378 result = calculate_peak_quantification(
379 self._x_data, self._y_data, settings['peak_type'], settings
380 )
381 except Exception as e:
382 logger.exception("ピークの自動検出に失敗しました")
383 notify.warning(self, "ピーク検出", f"ピーク検出に失敗しました:\n{e}")
384 return
385 if len(result['peak_x']) == 0:
386 notify.information(self, "ピーク検出", "条件に一致するピークが見つかりませんでした。")
387 return
388 for x, y, fwhm in zip(result['peak_x'], result['peak_y'], result['fwhm']):
389 self._add_guess_row(center=float(x), height=float(y), width=float(fwhm) or 1.0)
391 def _on_accept(self):
392 if self.guess_table.rowCount() == 0:
393 notify.warning(self, "多峰分離フィット", "少なくとも1つのピークの初期値が必要です。")
394 return
395 self.accept()
397 def get_component_type(self):
398 return self.component_combo.currentData()
400 def get_baseline_type(self):
401 return self.baseline_combo.currentData()
403 def get_initial_guesses(self):
404 """calculate_multi_peak_fit() に渡す [{'center', 'height', 'width'}, ...]。"""
405 guesses = []
406 for row in range(self.guess_table.rowCount()):
407 center = self.guess_table.cellWidget(row, 0).value()
408 height = self.guess_table.cellWidget(row, 1).value()
409 width = self.guess_table.cellWidget(row, 2).value()
410 guesses.append({'center': center, 'height': height, 'width': width})
411 return guesses
413 @staticmethod
414 def get_multi_peak_fit_settings(parent=None, x_data=None, y_data=None, initial_guesses=None):
415 """OK なら (component_type, baseline_type, initial_guesses)、キャンセルなら (None, None, None)。"""
416 dialog = MultiPeakFitDialog(parent, x_data=x_data, y_data=y_data, initial_guesses=initial_guesses)
417 if dialog.exec() == QDialog.DialogCode.Accepted:
418 return dialog.get_component_type(), dialog.get_baseline_type(), dialog.get_initial_guesses()
419 return None, None, None
422class PeakSettingsDialog(QDialog):
423 """ピーク/谷検出(scipy.signal.find_peaks)の height・distance・prominence を入力する。"""
425 # height の最小値を「なし」(高さで絞らない)として表示する。
426 _HEIGHT_NONE = -1e12
428 def __init__(self, parent=None):
429 super().__init__(parent)
430 self.setWindowTitle("ピーク検出 設定")
431 layout = QFormLayout(self)
433 self.type_combo = QComboBox()
434 self.type_combo.addItems(["上に凸 (Peaks)", "下に凸 (Valleys)"])
435 layout.addRow("検出タイプ", self.type_combo)
437 # 既定を 0 にすると、Y<0 の山や Y>0 の谷が1つも見つからない。
438 self.height_spinbox = QDoubleSpinBox()
439 self.height_spinbox.setToolTip("検出するピーク/谷の最小高さ (Y値)。「なし」は高さで絞らない。\n例: 5 を指定すると Y > 5 のピークのみ検出。\n例: -10 を指定すると Y < -10 の谷のみ検出。")
440 self.height_spinbox.setDecimals(4)
441 self.height_spinbox.setRange(self._HEIGHT_NONE, 1e12)
442 self.height_spinbox.setSpecialValueText("なし")
443 self.height_spinbox.setValue(self._HEIGHT_NONE)
445 self.distance_spinbox = QDoubleSpinBox()
446 self.distance_spinbox.setToolTip("隣接するピーク/谷の間の最小距離 (X軸の値)。\n近すぎるピーク/谷を間引きます。")
447 self.distance_spinbox.setDecimals(4)
448 self.distance_spinbox.setRange(0.0001, np.inf)
449 self.distance_spinbox.setValue(1.0)
451 self.prominence_spinbox = QDoubleSpinBox()
452 self.prominence_spinbox.setToolTip("ピーク/谷の突出度 (周囲のデータからの際立ち)。\nノイズのような小さなピーク/谷を除去するのに有効です。")
453 self.prominence_spinbox.setDecimals(4)
454 self.prominence_spinbox.setRange(0.0, np.inf)
455 self.prominence_spinbox.setValue(0.0)
457 layout.addRow("Y値の閾値 (Height)", self.height_spinbox)
458 layout.addRow("最小X距離 (Distance)", self.distance_spinbox)
459 layout.addRow("最小突出度 (Prominence)", self.prominence_spinbox)
461 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
462 QDialogButtonBox.StandardButton.Cancel)
463 button_box.accepted.connect(self.accept)
464 button_box.rejected.connect(self.reject)
465 layout.addRow(button_box)
467 apply_form_spacing(self)
469 def get_settings(self):
470 """distance_x は X の値のまま(点の数への換算は core.analysis)。height と prominence は無効なら None。"""
471 height = self.height_spinbox.value()
472 prominence = self.prominence_spinbox.value()
473 return {
474 "peak_type": self.type_combo.currentText(),
475 "height": None if height == self._HEIGHT_NONE else height,
476 "distance_x": self.distance_spinbox.value(),
477 "prominence": prominence if prominence > 0 else None,
478 }
480 @staticmethod
481 def get_peak_settings(parent=None):
482 """モーダルで開き、OK なら設定の辞書、キャンセルなら None を返す。"""
483 dialog = PeakSettingsDialog(parent)
484 if dialog.exec() == QDialog.DialogCode.Accepted:
485 return dialog.get_settings()
486 return None
489class ResultDialog(QDialog):
490 """結果の文章をスクロールできる欄で見せる(ピークが多いと QMessageBox は画面からはみ出す)。"""
492 def __init__(self, title, text, parent=None, csv_data=None, residual_x=None, residual_y=None):
493 """csv_data を渡すと「CSVとして保存」を出す。residual_x と residual_y を両方渡すと残差の図も出す。"""
494 super().__init__(parent)
495 self.setWindowTitle(title)
496 self.resize(480, 420)
497 self.csv_data = csv_data
499 layout = QVBoxLayout(self)
501 self.text_edit = QPlainTextEdit()
502 self.text_edit.setReadOnly(True)
503 self.text_edit.setPlainText(text)
504 # タブ区切りの桁が揃う
505 mono_font = QFont("Consolas")
506 mono_font.setStyleHint(QFont.StyleHint.Monospace)
507 self.text_edit.setFont(mono_font)
508 layout.addWidget(self.text_edit)
510 if residual_x is not None and residual_y is not None and len(residual_x) > 0:
511 self.resize(480, 620)
512 layout.addWidget(QLabel("残差プロット (実測値 - フィット値)"))
513 from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
514 from matplotlib.figure import Figure
515 from graphica.gui import theme
516 # 独立した Figure なのでキャンバスのダークモードの配色を継承しない。テーマの色を当てる
517 _tokens = theme.current_tokens()
518 fig = Figure(figsize=(4, 2.2), dpi=100, tight_layout=True,
519 facecolor=_tokens['surface'])
520 canvas = FigureCanvasQTAgg(fig)
521 canvas.setFixedHeight(200)
522 ax = fig.add_subplot(111)
523 ax.set_facecolor(_tokens['surface'])
524 ax.axhline(0, color=_tokens['border_strong'], linewidth=0.8, linestyle='--')
525 ax.scatter(residual_x, residual_y, s=14, color='#1F6F78')
526 ax.set_xlabel("X", fontsize=8, color=_tokens['text_primary'])
527 ax.set_ylabel("残差", fontsize=8, color=_tokens['text_primary'])
528 ax.tick_params(labelsize=7, colors=_tokens['text_primary'])
529 for spine in ax.spines.values():
530 spine.set_color(_tokens['border_strong'])
531 layout.addWidget(canvas)
533 button_layout = QHBoxLayout()
535 self.copy_button = QPushButton("コピー")
536 self.copy_button.clicked.connect(self._on_copy)
537 button_layout.addWidget(self.copy_button)
539 if csv_data is not None:
540 save_csv_button = QPushButton("CSVとして保存...")
541 save_csv_button.clicked.connect(self._on_save_csv)
542 button_layout.addWidget(save_csv_button)
544 button_layout.addStretch()
546 close_button = QPushButton("閉じる")
547 close_button.clicked.connect(self.reject)
548 button_layout.addWidget(close_button)
550 layout.addLayout(button_layout)
552 def _on_copy(self):
553 QApplication.clipboard().setText(self.text_edit.toPlainText())
554 original_text = self.copy_button.text()
555 self.copy_button.setText("コピーしました ✓")
556 self.copy_button.setEnabled(False)
558 def _restore():
559 self.copy_button.setText(original_text)
560 self.copy_button.setEnabled(True)
562 QTimer.singleShot(1200, _restore)
564 def _on_save_csv(self):
565 file_path, _ = dialog_dirs.get_save_file_name(
566 self, dialog_dirs.TABLE, "CSVとして保存", "", "CSV Files (*.csv);;All Files (*)"
567 )
568 if not file_path:
569 return
570 try:
571 self.csv_data.to_csv(file_path, index=False, encoding='utf-8-sig')
572 notify.information(self, "保存完了", f"CSVファイルとして保存しました:\n{file_path}")
573 except Exception as e:
574 logger.exception("結果の CSV 保存に失敗しました")
575 notify.warning(self, "保存エラー", f"CSV保存中にエラーが発生しました:\n{e}")
578class BaselineCorrectionDialog(QDialog):
579 METHOD_ALS = "ALS(Asymmetric Least Squares)"
580 METHOD_POLYNOMIAL = "多項式(反復フィット)"
581 METHOD_RUBBERBAND = "ラバーバンド(下側凸包)"
582 METHOD_MANUAL = "手動点"
583 METHODS = [METHOD_ALS, METHOD_POLYNOMIAL, METHOD_RUBBERBAND, METHOD_MANUAL]
585 _MANUAL_LINEAR = "直線"
586 _MANUAL_SPLINE = "3次スプライン"
588 def __init__(self, name, x_min=None, x_max=None, parent=None):
589 super().__init__(parent)
590 self.setWindowTitle("ベースライン補正")
591 self.resize(420, 440)
592 self._name = name
594 layout = QVBoxLayout(self)
595 layout.addWidget(QLabel(f"対象: {name}"))
597 method_form = QFormLayout()
598 self.method_combo = QComboBox()
599 self.method_combo.addItems(self.METHODS)
600 method_form.addRow("手法", self.method_combo)
601 layout.addLayout(method_form)
603 self.stack = QStackedWidget()
604 layout.addWidget(self.stack)
606 als_page = QWidget()
607 als_form = QFormLayout(als_page)
608 self.als_lam_spinbox = QDoubleSpinBox()
609 self.als_lam_spinbox.setRange(1.0, 1e12)
610 self.als_lam_spinbox.setDecimals(0)
611 self.als_lam_spinbox.setValue(1e5)
612 als_form.addRow("lam(平滑さ)", self.als_lam_spinbox)
613 self.als_p_spinbox = QDoubleSpinBox()
614 self.als_p_spinbox.setRange(0.0001, 0.9999)
615 self.als_p_spinbox.setDecimals(4)
616 self.als_p_spinbox.setSingleStep(0.001)
617 self.als_p_spinbox.setValue(0.01)
618 als_form.addRow("p(非対称重み)", self.als_p_spinbox)
619 self.als_niter_spinbox = QSpinBox()
620 self.als_niter_spinbox.setRange(1, 1000)
621 self.als_niter_spinbox.setValue(10)
622 als_form.addRow("反復回数", self.als_niter_spinbox)
623 self.stack.addWidget(als_page)
625 poly_page = QWidget()
626 poly_form = QFormLayout(poly_page)
627 self.poly_degree_spinbox = QSpinBox()
628 self.poly_degree_spinbox.setRange(0, 10)
629 self.poly_degree_spinbox.setValue(3)
630 poly_form.addRow("次数", self.poly_degree_spinbox)
631 self.poly_iterations_spinbox = QSpinBox()
632 self.poly_iterations_spinbox.setRange(1, 1000)
633 self.poly_iterations_spinbox.setValue(10)
634 poly_form.addRow("反復回数", self.poly_iterations_spinbox)
635 self.stack.addWidget(poly_page)
637 rubberband_page = QWidget()
638 rubberband_layout = QVBoxLayout(rubberband_page)
639 rubberband_info = QLabel(
640 "データ点群の下側凸包(convex hull)を結んだ曲線をベースラインとします。"
641 "設定できるパラメータはありません。"
642 )
643 rubberband_info.setWordWrap(True)
644 rubberband_layout.addWidget(rubberband_info)
645 rubberband_layout.addStretch()
646 self.stack.addWidget(rubberband_page)
648 manual_page = QWidget()
649 manual_layout = QVBoxLayout(manual_page)
650 manual_layout.addWidget(QLabel("アンカー点のX座標をカンマ区切りで入力してください(2点以上):"))
651 self.manual_anchor_edit = QPlainTextEdit()
652 self.manual_anchor_edit.setPlaceholderText("例: 0, 20, 50, 80, 100")
653 if x_min is not None and x_max is not None:
654 self.manual_anchor_edit.setPlainText(f"{x_min:g}, {x_max:g}")
655 self.manual_anchor_edit.setMaximumHeight(60)
656 manual_layout.addWidget(self.manual_anchor_edit)
657 manual_method_form = QFormLayout()
658 self.manual_method_combo = QComboBox()
659 self.manual_method_combo.addItems([self._MANUAL_LINEAR, self._MANUAL_SPLINE])
660 manual_method_form.addRow("補間方法", self.manual_method_combo)
661 manual_layout.addLayout(manual_method_form)
662 manual_info = QLabel(
663 "各アンカー点のYは、指定したX位置での実データを線形補間して自動的に求めます"
664 "(3次スプラインには3点以上必要です)。"
665 )
666 manual_info.setWordWrap(True)
667 manual_layout.addWidget(manual_info)
668 manual_layout.addStretch()
669 self.stack.addWidget(manual_page)
671 self.method_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
673 form = QFormLayout()
674 self.output_name_edit = QLineEdit(f"{name}_baseline_corrected")
675 form.addRow("出力データセット名", self.output_name_edit)
676 layout.addLayout(form)
678 self.add_baseline_checkbox = QCheckBox("推定したベースライン曲線も別データセットとして追加する")
679 layout.addWidget(self.add_baseline_checkbox)
681 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
682 QDialogButtonBox.StandardButton.Cancel)
683 button_box.accepted.connect(self.accept)
684 button_box.rejected.connect(self.reject)
685 layout.addWidget(button_box)
687 apply_form_spacing(self)
689 def get_settings(self):
690 """(手法, 手法の引数, 出力名, ベースラインも追加するか)。
692 "manual" の引数は anchor_x ではなく未解釈の anchor_x_text(書式の誤りは呼び出し側が知らせる)。
693 """
694 method_text = self.method_combo.currentText()
695 if method_text == self.METHOD_ALS:
696 method = "als"
697 params = {
698 "lam": self.als_lam_spinbox.value(),
699 "p": self.als_p_spinbox.value(),
700 "niter": self.als_niter_spinbox.value(),
701 }
702 elif method_text == self.METHOD_POLYNOMIAL:
703 method = "polynomial"
704 params = {
705 "degree": self.poly_degree_spinbox.value(),
706 "iterations": self.poly_iterations_spinbox.value(),
707 }
708 elif method_text == self.METHOD_RUBBERBAND:
709 method = "rubberband"
710 params = {}
711 else:
712 method = "manual"
713 params = {
714 "anchor_x_text": self.manual_anchor_edit.toPlainText(),
715 "method": "spline" if self.manual_method_combo.currentText() == self._MANUAL_SPLINE else "linear",
716 }
717 return method, params, self.output_name_edit.text().strip(), self.add_baseline_checkbox.isChecked()
720class SavGolDialog(QDialog):
721 MODE_SMOOTH = "平滑化"
722 MODE_DERIV1 = "1次微分"
723 MODE_DERIV2 = "2次微分"
724 MODES = [MODE_SMOOTH, MODE_DERIV1, MODE_DERIV2]
725 _DERIV_BY_MODE = {MODE_SMOOTH: 0, MODE_DERIV1: 1, MODE_DERIV2: 2}
726 _SUFFIX_BY_MODE = {MODE_SMOOTH: "_smoothed", MODE_DERIV1: "_deriv1", MODE_DERIV2: "_deriv2"}
728 def __init__(self, name, max_window=None, parent=None):
729 super().__init__(parent)
730 self.setWindowTitle("Savitzky-Golayフィルタ")
731 self.resize(380, 260)
732 self._name = name
734 layout = QVBoxLayout(self)
735 layout.addWidget(QLabel(f"対象: {name}"))
737 form = QFormLayout()
738 self.mode_combo = QComboBox()
739 self.mode_combo.addItems(self.MODES)
740 form.addRow("種類", self.mode_combo)
742 self.window_spinbox = QSpinBox()
743 self.window_spinbox.setRange(3, max_window if max_window else 999999)
744 self.window_spinbox.setSingleStep(2)
745 self.window_spinbox.setValue(min(5, max_window) if max_window else 5)
746 form.addRow("窓幅(奇数)", self.window_spinbox)
748 self.polyorder_spinbox = QSpinBox()
749 self.polyorder_spinbox.setRange(1, 10)
750 self.polyorder_spinbox.setValue(2)
751 form.addRow("多項式の次数", self.polyorder_spinbox)
753 self.output_name_edit = QLineEdit(f"{name}{self._SUFFIX_BY_MODE[self.MODE_SMOOTH]}")
754 form.addRow("出力データセット名", self.output_name_edit)
755 layout.addLayout(form)
757 self.mode_combo.currentTextChanged.connect(self._on_mode_changed)
759 info_label = QLabel(
760 "窓幅は奇数かつ多項式の次数より大きい値を指定してください。\n"
761 "微分はX軸の間隔が概ね等間隔であることを前提とします。"
762 )
763 info_label.setWordWrap(True)
764 layout.addWidget(info_label)
766 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
767 QDialogButtonBox.StandardButton.Cancel)
768 button_box.accepted.connect(self.accept)
769 button_box.rejected.connect(self.reject)
770 layout.addWidget(button_box)
772 apply_form_spacing(self)
774 def _on_mode_changed(self, mode):
775 self.output_name_edit.setText(f"{self._name}{self._SUFFIX_BY_MODE[mode]}")
777 def get_settings(self):
778 """(窓幅, 多項式の次数, 微分の階数, 出力名)"""
779 mode = self.mode_combo.currentText()
780 return (
781 self.window_spinbox.value(),
782 self.polyorder_spinbox.value(),
783 self._DERIV_BY_MODE[mode],
784 self.output_name_edit.text().strip(),
785 )
788class IntervalIntegralDialog(QDialog):
789 """区間積分の設定。X の範囲はデータの範囲を初期値にする(そのまま OK で全体を積分)。"""
791 METHOD_TRAPEZOID = "台形則(Trapezoidal)"
792 METHOD_SIMPSON = "Simpson則"
793 METHODS = [METHOD_TRAPEZOID, METHOD_SIMPSON]
794 _METHOD_KEY_BY_LABEL = {METHOD_TRAPEZOID: "trapezoid", METHOD_SIMPSON: "simpson"}
796 def __init__(self, name, x_min=None, x_max=None, parent=None):
797 super().__init__(parent)
798 self.setWindowTitle("区間積分")
799 self.resize(380, 260)
801 layout = QVBoxLayout(self)
802 layout.addWidget(QLabel(f"対象: {name}"))
804 form = QFormLayout()
805 self.method_combo = QComboBox()
806 self.method_combo.addItems(self.METHODS)
807 form.addRow("積分方法", self.method_combo)
809 self.range_min_spinbox = QDoubleSpinBox()
810 self.range_min_spinbox.setRange(-1e12, 1e12)
811 self.range_min_spinbox.setDecimals(6)
812 if x_min is not None:
813 self.range_min_spinbox.setValue(x_min)
814 form.addRow("最小X", self.range_min_spinbox)
816 self.range_max_spinbox = QDoubleSpinBox()
817 self.range_max_spinbox.setRange(-1e12, 1e12)
818 self.range_max_spinbox.setDecimals(6)
819 if x_max is not None:
820 self.range_max_spinbox.setValue(x_max)
821 form.addRow("最大X", self.range_max_spinbox)
822 layout.addLayout(form)
824 self.subtract_baseline_checkbox = QCheckBox(
825 "ベースラインを差し引く(範囲の両端を結ぶ直線を差し引いてから積分)"
826 )
827 self.subtract_baseline_checkbox.setToolTip(
828 "積分範囲の両端のY値(実データを線形補間して求めた値)を結ぶ直線を\n"
829 "ベースラインとしてYから差し引いてから積分します。\n"
830 "スペクトルのピーク面積など「傾いた背景の上のピーク」を求める用途向けの\n"
831 "簡易オプションです。ALS等の本格的なベースライン補正を使いたい場合は、\n"
832 "先に「ベースライン補正...」で補正したデータセットに対してこの機能を\n"
833 "使ってください。"
834 )
835 layout.addWidget(self.subtract_baseline_checkbox)
837 info_label = QLabel(
838 "台形則・Simpson則いずれもデータ点数の偶奇による制約はありません。"
839 )
840 info_label.setWordWrap(True)
841 layout.addWidget(info_label)
843 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
844 QDialogButtonBox.StandardButton.Cancel)
845 button_box.accepted.connect(self.accept)
846 button_box.rejected.connect(self.reject)
847 layout.addWidget(button_box)
849 apply_form_spacing(self)
851 def get_settings(self):
852 """(積分の方法, (最小X, 最大X), ベースラインを引くか)"""
853 method_text = self.method_combo.currentText()
854 method = self._METHOD_KEY_BY_LABEL[method_text]
855 x_range = (self.range_min_spinbox.value(), self.range_max_spinbox.value())
856 return method, x_range, self.subtract_baseline_checkbox.isChecked()
859class CumulativeIntegralDialog(QDialog):
860 METHOD_TRAPEZOID = "台形則(Trapezoidal)"
861 METHOD_SIMPSON = "Simpson則"
862 METHODS = [METHOD_TRAPEZOID, METHOD_SIMPSON]
863 _METHOD_KEY_BY_LABEL = {METHOD_TRAPEZOID: "trapezoid", METHOD_SIMPSON: "simpson"}
865 def __init__(self, name, parent=None):
866 super().__init__(parent)
867 self.setWindowTitle("累積積分")
868 self.resize(380, 200)
870 layout = QVBoxLayout(self)
871 layout.addWidget(QLabel(f"対象: {name}"))
873 form = QFormLayout()
874 self.method_combo = QComboBox()
875 self.method_combo.addItems(self.METHODS)
876 form.addRow("積分方法", self.method_combo)
878 self.output_name_edit = QLineEdit(f"{name}_cumsum")
879 form.addRow("出力データセット名", self.output_name_edit)
880 layout.addLayout(form)
882 info_label = QLabel(
883 "Xの各点までの積分値 ∫[x_min, x_i] y dx を新しいデータセットとして追加します。\n"
884 "範囲を絞りたい場合は「区間積分」を使ってください。"
885 )
886 info_label.setWordWrap(True)
887 layout.addWidget(info_label)
889 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
890 QDialogButtonBox.StandardButton.Cancel)
891 button_box.accepted.connect(self.accept)
892 button_box.rejected.connect(self.reject)
893 layout.addWidget(button_box)
895 apply_form_spacing(self)
897 def get_settings(self):
898 """(積分の方法, 出力名)"""
899 method_text = self.method_combo.currentText()
900 method = self._METHOD_KEY_BY_LABEL[method_text]
901 return method, self.output_name_edit.text().strip()
904class NormalizeDatasetDialog(QDialog):
905 """最大値か、指定した X での値を 1.0 にする。"""
907 MODE_MAX = "最大値基準"
908 MODE_X_VALUE = "特定X値での強度基準"
909 MODES = [MODE_MAX, MODE_X_VALUE]
911 def __init__(self, name, x_min=None, x_max=None, parent=None):
912 super().__init__(parent)
913 self.setWindowTitle("規格化(ノーマライズ)")
914 self.resize(360, 220)
916 layout = QVBoxLayout(self)
917 label = QLabel(f"対象: {name}")
918 layout.addWidget(label)
920 form = QFormLayout()
921 self.mode_combo = QComboBox()
922 self.mode_combo.addItems(self.MODES)
923 form.addRow("基準", self.mode_combo)
925 self.reference_x_spinbox = QDoubleSpinBox()
926 self.reference_x_spinbox.setRange(-1e12, 1e12)
927 self.reference_x_spinbox.setDecimals(6)
928 self.reference_x_spinbox.setEnabled(False)
929 if x_min is not None: 929 ↛ 931line 929 didn't jump to line 931 because the condition on line 929 was always true
930 self.reference_x_spinbox.setValue(x_min)
931 form.addRow("基準X値", self.reference_x_spinbox)
933 self.output_name_edit = QLineEdit(f"{name}_normalized")
934 form.addRow("出力データセット名", self.output_name_edit)
935 layout.addLayout(form)
937 self.mode_combo.currentTextChanged.connect(
938 lambda text: self.reference_x_spinbox.setEnabled(text == self.MODE_X_VALUE)
939 )
941 info_label = QLabel(
942 "最大値基準: Yの最大値が1.0になるよう規格化します(マスク中の行は除外)。\n"
943 "特定X値での強度基準: 指定したX値でのYを線形補間し、その値が1.0になるよう規格化します。"
944 )
945 info_label.setWordWrap(True)
946 layout.addWidget(info_label)
948 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
949 QDialogButtonBox.StandardButton.Cancel)
950 button_box.accepted.connect(self.accept)
951 button_box.rejected.connect(self.reject)
952 layout.addWidget(button_box)
954 apply_form_spacing(self)
956 def get_settings(self):
957 """(基準の種類, 基準の X(MODE_X_VALUE のときだけ), 出力名)"""
958 mode = self.mode_combo.currentText()
959 reference_x = self.reference_x_spinbox.value() if mode == self.MODE_X_VALUE else None
960 return mode, reference_x, self.output_name_edit.text().strip()
963class ResampleDatasetDialog(QDialog):
964 """ほかのデータセットの X か等間隔のグリッドに補間する。"""
966 SOURCE_DATASET = "他のデータセットのX格子"
967 SOURCE_LINSPACE = "等間隔グリッド"
968 SOURCES = [SOURCE_DATASET, SOURCE_LINSPACE]
970 METHOD_LINEAR = "線形補間"
971 METHOD_CUBIC = "3次スプライン補間"
972 METHODS = [METHOD_LINEAR, METHOD_CUBIC]
973 _METHOD_KEY_BY_LABEL = {METHOD_LINEAR: "linear", METHOD_CUBIC: "cubic"}
975 def __init__(self, name, other_dataset_names, x_min=None, x_max=None, parent=None):
976 super().__init__(parent)
977 self.setWindowTitle("共通X格子へのリサンプリング/補間")
978 self.resize(420, 380)
979 self._name = name
981 layout = QVBoxLayout(self)
982 layout.addWidget(QLabel(f"対象: {name}"))
984 source_form = QFormLayout()
985 self.source_combo = QComboBox()
986 self.source_combo.addItems(self.SOURCES)
987 source_form.addRow("リサンプリング先の格子", self.source_combo)
988 layout.addLayout(source_form)
990 self.stack = QStackedWidget()
991 layout.addWidget(self.stack)
993 dataset_page = QWidget()
994 dataset_form = QFormLayout(dataset_page)
995 self.other_dataset_combo = QComboBox()
996 self.other_dataset_combo.addItems(other_dataset_names)
997 dataset_form.addRow("データセット", self.other_dataset_combo)
998 if not other_dataset_names:
999 # ほかにデータセットが無いので、空のまま OK されないよう無効にする
1000 self.other_dataset_combo.setEnabled(False)
1001 no_dataset_label = QLabel("(他に読み込まれているデータセットがありません)")
1002 no_dataset_label.setWordWrap(True)
1003 dataset_form.addRow(no_dataset_label)
1004 self.stack.addWidget(dataset_page)
1006 linspace_page = QWidget()
1007 linspace_form = QFormLayout(linspace_page)
1008 self.linspace_start_spinbox = QDoubleSpinBox()
1009 self.linspace_start_spinbox.setRange(-1e12, 1e12)
1010 self.linspace_start_spinbox.setDecimals(6)
1011 if x_min is not None:
1012 self.linspace_start_spinbox.setValue(x_min)
1013 linspace_form.addRow("開始X", self.linspace_start_spinbox)
1015 self.linspace_stop_spinbox = QDoubleSpinBox()
1016 self.linspace_stop_spinbox.setRange(-1e12, 1e12)
1017 self.linspace_stop_spinbox.setDecimals(6)
1018 if x_max is not None:
1019 self.linspace_stop_spinbox.setValue(x_max)
1020 linspace_form.addRow("終了X", self.linspace_stop_spinbox)
1022 self.linspace_num_points_spinbox = QSpinBox()
1023 self.linspace_num_points_spinbox.setRange(2, 1_000_000)
1024 self.linspace_num_points_spinbox.setValue(100)
1025 linspace_form.addRow("点数", self.linspace_num_points_spinbox)
1026 self.stack.addWidget(linspace_page)
1028 self.source_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
1029 if not other_dataset_names:
1030 self.source_combo.setCurrentText(self.SOURCE_LINSPACE)
1032 method_form = QFormLayout()
1033 self.method_combo = QComboBox()
1034 self.method_combo.addItems(self.METHODS)
1035 method_form.addRow("補間方法", self.method_combo)
1036 layout.addLayout(method_form)
1038 self.extrapolate_checkbox = QCheckBox("範囲外を外挿する(既定はNaNにする)")
1039 self.extrapolate_checkbox.setToolTip(
1040 "元データのX範囲外にある格子点の扱いです。\n"
1041 "オフ(既定): 範囲外はNaNになります(安全側)。\n"
1042 "オン: 線形補間は両端の傾きで、3次スプラインはscipy標準の\n"
1043 "区間多項式の延長で外挿します(いずれも範囲から離れるほど\n"
1044 "不正確になりうる点に注意してください)。"
1045 )
1046 layout.addWidget(self.extrapolate_checkbox)
1048 output_form = QFormLayout()
1049 self.output_name_edit = QLineEdit(f"{name}_resampled")
1050 output_form.addRow("出力データセット名", self.output_name_edit)
1051 layout.addLayout(output_form)
1053 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
1054 QDialogButtonBox.StandardButton.Cancel)
1055 button_box.accepted.connect(self.accept)
1056 button_box.rejected.connect(self.reject)
1057 layout.addWidget(button_box)
1059 apply_form_spacing(self)
1061 def get_settings(self):
1062 """(グリッドの元, 元ごとの引数, 補間の方法, 外挿するか, 出力名)。
1064 引数は "dataset" なら {"dataset_name"}、"linspace" なら {"start", "stop", "num_points"}。
1065 """
1066 source_text = self.source_combo.currentText()
1067 if source_text == self.SOURCE_DATASET:
1068 source = "dataset"
1069 params = {"dataset_name": self.other_dataset_combo.currentText()}
1070 else:
1071 source = "linspace"
1072 params = {
1073 "start": self.linspace_start_spinbox.value(),
1074 "stop": self.linspace_stop_spinbox.value(),
1075 "num_points": self.linspace_num_points_spinbox.value(),
1076 }
1077 method_text = self.method_combo.currentText()
1078 method = self._METHOD_KEY_BY_LABEL[method_text]
1079 return (
1080 source, params, method,
1081 self.extrapolate_checkbox.isChecked(),
1082 self.output_name_edit.text().strip(),
1083 )
1086class OutlierDetectionDialog(QDialog):
1087 """外れ値の検出の設定。マスクに適用するかは別のチェック(既定はオフ、結果を見てから決められる)。"""
1089 METHOD_ZSCORE = "Z-score"
1090 METHOD_IQR = "IQR(四分位範囲)"
1091 METHODS = [METHOD_ZSCORE, METHOD_IQR]
1092 _METHOD_KEY_BY_LABEL = {METHOD_ZSCORE: "zscore", METHOD_IQR: "iqr"}
1094 def __init__(self, name, parent=None):
1095 super().__init__(parent)
1096 self.setWindowTitle("外れ値検出")
1097 self.resize(420, 280)
1099 layout = QVBoxLayout(self)
1100 layout.addWidget(QLabel(f"対象: {name}(Y値をもとに検出します)"))
1102 form = QFormLayout()
1103 self.method_combo = QComboBox()
1104 self.method_combo.addItems(self.METHODS)
1105 form.addRow("検出方法", self.method_combo)
1106 layout.addLayout(form)
1108 self.stack = QStackedWidget()
1110 zscore_page = QWidget()
1111 zscore_form = QFormLayout(zscore_page)
1112 self.threshold_spinbox = QDoubleSpinBox()
1113 self.threshold_spinbox.setRange(0.1, 100.0)
1114 self.threshold_spinbox.setDecimals(2)
1115 self.threshold_spinbox.setValue(3.0)
1116 zscore_form.addRow("しきい値(|Z| >)", self.threshold_spinbox)
1117 self.stack.addWidget(zscore_page)
1119 iqr_page = QWidget()
1120 iqr_form = QFormLayout(iqr_page)
1121 self.multiplier_spinbox = QDoubleSpinBox()
1122 self.multiplier_spinbox.setRange(0.1, 100.0)
1123 self.multiplier_spinbox.setDecimals(2)
1124 self.multiplier_spinbox.setValue(1.5)
1125 iqr_form.addRow("係数(IQR ×)", self.multiplier_spinbox)
1126 self.stack.addWidget(iqr_page)
1128 layout.addWidget(self.stack)
1129 self.method_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
1131 self.apply_mask_checkbox = QCheckBox("検出した外れ値をマスク(除外)に適用する")
1132 self.apply_mask_checkbox.setToolTip(
1133 "チェックを外すと検出結果を表示するだけでマスクは変更しません。\n"
1134 "マスクは非破壊で、いつでも解除できます。"
1135 )
1136 layout.addWidget(self.apply_mask_checkbox)
1138 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
1139 QDialogButtonBox.StandardButton.Cancel)
1140 button_box.accepted.connect(self.accept)
1141 button_box.rejected.connect(self.reject)
1142 layout.addWidget(button_box)
1144 apply_form_spacing(self)
1146 def get_settings(self):
1147 """(方法, しきい値か係数, マスクに適用するか)"""
1148 method = self._METHOD_KEY_BY_LABEL[self.method_combo.currentText()]
1149 value = self.threshold_spinbox.value() if method == "zscore" else self.multiplier_spinbox.value()
1150 return method, value, self.apply_mask_checkbox.isChecked()
1153class HistogramKDEDialog(QDialog):
1154 """ヒストグラムか KDE。対象の列は Y に限らない(分布を見たいのは Y とは限らない)。"""
1156 MODE_HISTOGRAM = "ヒストグラム"
1157 MODE_KDE = "カーネル密度推定(KDE)"
1158 MODES = [MODE_HISTOGRAM, MODE_KDE]
1160 def __init__(self, name, column_names, default_column=None, parent=None):
1161 super().__init__(parent)
1162 self.setWindowTitle("ヒストグラム / KDE")
1163 self.resize(420, 320)
1164 self._name = name
1166 layout = QVBoxLayout(self)
1167 layout.addWidget(QLabel(f"対象: {name}"))
1169 form = QFormLayout()
1170 self.column_combo = QComboBox()
1171 self.column_combo.addItems(column_names)
1172 if default_column and default_column in column_names: 1172 ↛ 1174line 1172 didn't jump to line 1174 because the condition on line 1172 was always true
1173 self.column_combo.setCurrentText(default_column)
1174 form.addRow("対象列", self.column_combo)
1176 self.mode_combo = QComboBox()
1177 self.mode_combo.addItems(self.MODES)
1178 form.addRow("モード", self.mode_combo)
1179 layout.addLayout(form)
1181 self.stack = QStackedWidget()
1183 hist_page = QWidget()
1184 hist_form = QFormLayout(hist_page)
1185 self.auto_bins_checkbox = QCheckBox("ビン数を自動決定")
1186 self.auto_bins_checkbox.setChecked(True)
1187 hist_form.addRow(self.auto_bins_checkbox)
1188 self.bins_spinbox = QSpinBox()
1189 self.bins_spinbox.setRange(2, 500)
1190 self.bins_spinbox.setValue(10)
1191 self.bins_spinbox.setEnabled(False)
1192 hist_form.addRow("ビン数", self.bins_spinbox)
1193 self.auto_bins_checkbox.toggled.connect(lambda checked: self.bins_spinbox.setEnabled(not checked))
1194 self.density_checkbox = QCheckBox("確率密度で正規化する(合計面積 = 1)")
1195 hist_form.addRow(self.density_checkbox)
1196 self.stack.addWidget(hist_page)
1198 kde_page = QWidget()
1199 kde_form = QFormLayout(kde_page)
1200 self.kde_points_spinbox = QSpinBox()
1201 self.kde_points_spinbox.setRange(10, 2000)
1202 self.kde_points_spinbox.setValue(200)
1203 kde_form.addRow("評価点数", self.kde_points_spinbox)
1204 self.stack.addWidget(kde_page)
1206 layout.addWidget(self.stack)
1207 self.mode_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
1209 output_form = QFormLayout()
1210 self.output_name_edit = QLineEdit(f"{name}_hist")
1211 output_form.addRow("出力データセット名", self.output_name_edit)
1212 layout.addLayout(output_form)
1213 self.mode_combo.currentTextChanged.connect(self._update_default_output_name)
1215 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
1216 QDialogButtonBox.StandardButton.Cancel)
1217 button_box.accepted.connect(self.accept)
1218 button_box.rejected.connect(self.reject)
1219 layout.addWidget(button_box)
1221 apply_form_spacing(self)
1223 def _update_default_output_name(self, mode_text):
1224 # 利用者が書き換えた名前は上書きしない
1225 current = self.output_name_edit.text()
1226 if current not in (f"{self._name}_hist", f"{self._name}_kde"): 1226 ↛ 1227line 1226 didn't jump to line 1227 because the condition on line 1226 was never true
1227 return
1228 suffix = "_hist" if mode_text == self.MODE_HISTOGRAM else "_kde"
1229 self.output_name_edit.setText(f"{self._name}{suffix}")
1231 def get_settings(self):
1232 """{'mode', 'column', 'output_name'} と、histogram なら 'bins' / 'density'、kde なら 'n_points'。"""
1233 mode = "histogram" if self.mode_combo.currentText() == self.MODE_HISTOGRAM else "kde"
1234 settings = {
1235 'mode': mode,
1236 'column': self.column_combo.currentText(),
1237 'output_name': self.output_name_edit.text().strip(),
1238 }
1239 if mode == "histogram":
1240 settings['bins'] = 'auto' if self.auto_bins_checkbox.isChecked() else self.bins_spinbox.value()
1241 settings['density'] = self.density_checkbox.isChecked()
1242 else:
1243 settings['n_points'] = self.kde_points_spinbox.value()
1244 return settings
1247class DatasetArithmeticDialog(QDialog):
1248 """A と B の演算。X が揃わないので、呼び出し側が B を A の X に補間してから計算する。"""
1250 OPERATIONS = ["A - B", "B - A", "A + B", "A × B", "A ÷ B", "B ÷ A"]
1252 def __init__(self, name_a, name_b, parent=None):
1253 super().__init__(parent)
1254 self.setWindowTitle("データセット間演算")
1255 self.resize(360, 200)
1257 layout = QVBoxLayout(self)
1258 label = QLabel(f"A: {name_a}\nB: {name_b}")
1259 layout.addWidget(label)
1261 form = QFormLayout()
1262 self.operation_combo = QComboBox()
1263 self.operation_combo.addItems(self.OPERATIONS)
1264 form.addRow("演算", self.operation_combo)
1266 self.output_name_edit = QLineEdit(f"{name_a} vs {name_b}")
1267 form.addRow("出力データセット名", self.output_name_edit)
1268 layout.addLayout(form)
1270 info_label = QLabel(
1271 "B側のY値を、A側のX値に線形補間してから演算します。"
1272 "2つのデータセットのX軸の値が重なる範囲のみが対象になります。"
1273 )
1274 info_label.setWordWrap(True)
1275 layout.addWidget(info_label)
1277 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
1278 QDialogButtonBox.StandardButton.Cancel)
1279 button_box.accepted.connect(self.accept)
1280 button_box.rejected.connect(self.reject)
1281 layout.addWidget(button_box)
1283 apply_form_spacing(self)
1285 def get_settings(self):
1286 """(演算の種類, 出力名)"""
1287 return self.operation_combo.currentText(), self.output_name_edit.text().strip()
1290class XAxisAlignmentDialog(QDialog):
1291 """B(動かす側)を A に重ねる。シフト量は自動で求めるので、出力名だけ尋ねる。"""
1293 def __init__(self, name_a, name_b, parent=None):
1294 super().__init__(parent)
1295 self.setWindowTitle("X軸アライメント(相互相関)")
1296 self.resize(380, 200)
1298 layout = QVBoxLayout(self)
1299 label = QLabel(f"基準(移動しない): {name_a}\n位置合わせ対象(移動する): {name_b}")
1300 layout.addWidget(label)
1302 form = QFormLayout()
1303 self.output_name_edit = QLineEdit(f"{name_b}_aligned")
1304 form.addRow("出力データセット名", self.output_name_edit)
1305 layout.addLayout(form)
1307 info_label = QLabel(
1308 "2つのデータセットの相互相関が最大になるよう、位置合わせ対象のX値を"
1309 "シフトした新しいデータセットを作成します(元のデータセットは変更しません)。"
1310 )
1311 info_label.setWordWrap(True)
1312 layout.addWidget(info_label)
1314 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
1315 QDialogButtonBox.StandardButton.Cancel)
1316 button_box.accepted.connect(self.accept)
1317 button_box.rejected.connect(self.reject)
1318 layout.addWidget(button_box)
1320 apply_form_spacing(self)
1322 def get_settings(self):
1323 return self.output_name_edit.text().strip()