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

1"""解析のダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。""" 

2 

3import logging 

4 

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 

31 

32logger = logging.getLogger(__name__) 

33 

34 

35class FitDialog(QDialog): 

36 """フィットのモデルと条件を選ぶ。""" 

37 

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("曲線フィット") 

42 

43 layout = QVBoxLayout(self) 

44 

45 layout.addWidget(QLabel("フィットする関数の種類を選択してください:")) 

46 

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) 

57 

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) 

65 

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) 

72 

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) 

85 

86 self.range_checkbox = QCheckBox("フィット範囲を指定する") 

87 layout.addWidget(self.range_checkbox) 

88 

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) 

97 

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) 

106 

107 self.range_checkbox.toggled.connect(self.range_min_spinbox.setEnabled) 

108 self.range_checkbox.toggled.connect(self.range_max_spinbox.setEnabled) 

109 

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) 

118 

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) 

122 

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) 

133 

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) 

139 

140 self._rebuild_param_table() 

141 

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) 

147 

148 def _rebuild_param_table(self, *_args): 

149 """フィットの種類か数式が変わるたびにパラメータの行を作り直す。まだ決まらない間は0行。 

150 

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 = [] 

162 

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) 

169 

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) 

183 

184 fixed_check = QCheckBox() 

185 self.param_table.setCellWidget(row, 2, fixed_check) 

186 

187 range_check = QCheckBox() 

188 self.param_table.setCellWidget(row, 3, range_check) 

189 

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) 

196 

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) 

203 

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 ) 

211 

212 self.param_table.resizeColumnsToContents() 

213 

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

220 

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) 

231 

232 if fixed_check.isChecked(): 

233 fixed_params[name] = value_spin.value() 

234 continue 

235 

236 if value_spin.property("_user_overridden"): 

237 p0_overrides[name] = value_spin.value() 

238 

239 if range_check.isChecked(): 

240 bounds[name] = (min_spin.value(), max_spin.value()) 

241 

242 return p0_overrides, fixed_params, bounds 

243 

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 

252 

253 def get_weighted(self): 

254 return self.weighted_checkbox.isChecked() 

255 

256 def get_loss(self): 

257 """'linear' / 'soft_l1' / 'huber'。""" 

258 return self.loss_combo.currentData() 

259 

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

265 

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

269 

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' 

281 

282 

283class MultiPeakFitDialog(QDialog): 

284 """多峰分離の設定。各ピークの初期値の行は、手で、ピーク検出から、またはキャンバスのクリックで入れる。""" 

285 

286 COMPONENT_TYPES = [ 

287 ('gaussian', 'ガウシアン'), 

288 ('lorentzian', 'ローレンツ'), 

289 ('pseudo_voigt', '擬似フォークト'), 

290 ('voigt', 'フォークト'), 

291 ] 

292 BASELINE_TYPES = [ 

293 ('none', 'なし'), 

294 ('constant', '定数'), 

295 ('linear', '1次'), 

296 ] 

297 

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 

304 

305 layout = QVBoxLayout(self) 

306 

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) 

312 

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) 

319 

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) 

328 

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) 

342 

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 ) 

349 

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) 

355 

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) 

365 

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) 

370 

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) 

390 

391 def _on_accept(self): 

392 if self.guess_table.rowCount() == 0: 

393 notify.warning(self, "多峰分離フィット", "少なくとも1つのピークの初期値が必要です。") 

394 return 

395 self.accept() 

396 

397 def get_component_type(self): 

398 return self.component_combo.currentData() 

399 

400 def get_baseline_type(self): 

401 return self.baseline_combo.currentData() 

402 

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 

412 

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 

420 

421 

422class PeakSettingsDialog(QDialog): 

423 """ピーク/谷検出(scipy.signal.find_peaks)の height・distance・prominence を入力する。""" 

424 

425 # height の最小値を「なし」(高さで絞らない)として表示する。 

426 _HEIGHT_NONE = -1e12 

427 

428 def __init__(self, parent=None): 

429 super().__init__(parent) 

430 self.setWindowTitle("ピーク検出 設定") 

431 layout = QFormLayout(self) 

432 

433 self.type_combo = QComboBox() 

434 self.type_combo.addItems(["上に凸 (Peaks)", "下に凸 (Valleys)"]) 

435 layout.addRow("検出タイプ", self.type_combo) 

436 

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) 

444 

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) 

450 

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) 

456 

457 layout.addRow("Y値の閾値 (Height)", self.height_spinbox) 

458 layout.addRow("最小X距離 (Distance)", self.distance_spinbox) 

459 layout.addRow("最小突出度 (Prominence)", self.prominence_spinbox) 

460 

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) 

466 

467 apply_form_spacing(self) 

468 

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 } 

479 

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 

487 

488 

489class ResultDialog(QDialog): 

490 """結果の文章をスクロールできる欄で見せる(ピークが多いと QMessageBox は画面からはみ出す)。""" 

491 

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 

498 

499 layout = QVBoxLayout(self) 

500 

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) 

509 

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) 

532 

533 button_layout = QHBoxLayout() 

534 

535 self.copy_button = QPushButton("コピー") 

536 self.copy_button.clicked.connect(self._on_copy) 

537 button_layout.addWidget(self.copy_button) 

538 

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) 

543 

544 button_layout.addStretch() 

545 

546 close_button = QPushButton("閉じる") 

547 close_button.clicked.connect(self.reject) 

548 button_layout.addWidget(close_button) 

549 

550 layout.addLayout(button_layout) 

551 

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) 

557 

558 def _restore(): 

559 self.copy_button.setText(original_text) 

560 self.copy_button.setEnabled(True) 

561 

562 QTimer.singleShot(1200, _restore) 

563 

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

576 

577 

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] 

584 

585 _MANUAL_LINEAR = "直線" 

586 _MANUAL_SPLINE = "3次スプライン" 

587 

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 

593 

594 layout = QVBoxLayout(self) 

595 layout.addWidget(QLabel(f"対象: {name}")) 

596 

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) 

602 

603 self.stack = QStackedWidget() 

604 layout.addWidget(self.stack) 

605 

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) 

624 

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) 

636 

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) 

647 

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) 

670 

671 self.method_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) 

672 

673 form = QFormLayout() 

674 self.output_name_edit = QLineEdit(f"{name}_baseline_corrected") 

675 form.addRow("出力データセット名", self.output_name_edit) 

676 layout.addLayout(form) 

677 

678 self.add_baseline_checkbox = QCheckBox("推定したベースライン曲線も別データセットとして追加する") 

679 layout.addWidget(self.add_baseline_checkbox) 

680 

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) 

686 

687 apply_form_spacing(self) 

688 

689 def get_settings(self): 

690 """(手法, 手法の引数, 出力名, ベースラインも追加するか)。 

691 

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

718 

719 

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

727 

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 

733 

734 layout = QVBoxLayout(self) 

735 layout.addWidget(QLabel(f"対象: {name}")) 

736 

737 form = QFormLayout() 

738 self.mode_combo = QComboBox() 

739 self.mode_combo.addItems(self.MODES) 

740 form.addRow("種類", self.mode_combo) 

741 

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) 

747 

748 self.polyorder_spinbox = QSpinBox() 

749 self.polyorder_spinbox.setRange(1, 10) 

750 self.polyorder_spinbox.setValue(2) 

751 form.addRow("多項式の次数", self.polyorder_spinbox) 

752 

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) 

756 

757 self.mode_combo.currentTextChanged.connect(self._on_mode_changed) 

758 

759 info_label = QLabel( 

760 "窓幅は奇数かつ多項式の次数より大きい値を指定してください。\n" 

761 "微分はX軸の間隔が概ね等間隔であることを前提とします。" 

762 ) 

763 info_label.setWordWrap(True) 

764 layout.addWidget(info_label) 

765 

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) 

771 

772 apply_form_spacing(self) 

773 

774 def _on_mode_changed(self, mode): 

775 self.output_name_edit.setText(f"{self._name}{self._SUFFIX_BY_MODE[mode]}") 

776 

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 ) 

786 

787 

788class IntervalIntegralDialog(QDialog): 

789 """区間積分の設定。X の範囲はデータの範囲を初期値にする(そのまま OK で全体を積分)。""" 

790 

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

795 

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) 

800 

801 layout = QVBoxLayout(self) 

802 layout.addWidget(QLabel(f"対象: {name}")) 

803 

804 form = QFormLayout() 

805 self.method_combo = QComboBox() 

806 self.method_combo.addItems(self.METHODS) 

807 form.addRow("積分方法", self.method_combo) 

808 

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) 

815 

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) 

823 

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) 

836 

837 info_label = QLabel( 

838 "台形則・Simpson則いずれもデータ点数の偶奇による制約はありません。" 

839 ) 

840 info_label.setWordWrap(True) 

841 layout.addWidget(info_label) 

842 

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) 

848 

849 apply_form_spacing(self) 

850 

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

857 

858 

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

864 

865 def __init__(self, name, parent=None): 

866 super().__init__(parent) 

867 self.setWindowTitle("累積積分") 

868 self.resize(380, 200) 

869 

870 layout = QVBoxLayout(self) 

871 layout.addWidget(QLabel(f"対象: {name}")) 

872 

873 form = QFormLayout() 

874 self.method_combo = QComboBox() 

875 self.method_combo.addItems(self.METHODS) 

876 form.addRow("積分方法", self.method_combo) 

877 

878 self.output_name_edit = QLineEdit(f"{name}_cumsum") 

879 form.addRow("出力データセット名", self.output_name_edit) 

880 layout.addLayout(form) 

881 

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) 

888 

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) 

894 

895 apply_form_spacing(self) 

896 

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

902 

903 

904class NormalizeDatasetDialog(QDialog): 

905 """最大値か、指定した X での値を 1.0 にする。""" 

906 

907 MODE_MAX = "最大値基準" 

908 MODE_X_VALUE = "特定X値での強度基準" 

909 MODES = [MODE_MAX, MODE_X_VALUE] 

910 

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) 

915 

916 layout = QVBoxLayout(self) 

917 label = QLabel(f"対象: {name}") 

918 layout.addWidget(label) 

919 

920 form = QFormLayout() 

921 self.mode_combo = QComboBox() 

922 self.mode_combo.addItems(self.MODES) 

923 form.addRow("基準", self.mode_combo) 

924 

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) 

932 

933 self.output_name_edit = QLineEdit(f"{name}_normalized") 

934 form.addRow("出力データセット名", self.output_name_edit) 

935 layout.addLayout(form) 

936 

937 self.mode_combo.currentTextChanged.connect( 

938 lambda text: self.reference_x_spinbox.setEnabled(text == self.MODE_X_VALUE) 

939 ) 

940 

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) 

947 

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) 

953 

954 apply_form_spacing(self) 

955 

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

961 

962 

963class ResampleDatasetDialog(QDialog): 

964 """ほかのデータセットの X か等間隔のグリッドに補間する。""" 

965 

966 SOURCE_DATASET = "他のデータセットのX格子" 

967 SOURCE_LINSPACE = "等間隔グリッド" 

968 SOURCES = [SOURCE_DATASET, SOURCE_LINSPACE] 

969 

970 METHOD_LINEAR = "線形補間" 

971 METHOD_CUBIC = "3次スプライン補間" 

972 METHODS = [METHOD_LINEAR, METHOD_CUBIC] 

973 _METHOD_KEY_BY_LABEL = {METHOD_LINEAR: "linear", METHOD_CUBIC: "cubic"} 

974 

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 

980 

981 layout = QVBoxLayout(self) 

982 layout.addWidget(QLabel(f"対象: {name}")) 

983 

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) 

989 

990 self.stack = QStackedWidget() 

991 layout.addWidget(self.stack) 

992 

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) 

1005 

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) 

1014 

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) 

1021 

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) 

1027 

1028 self.source_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) 

1029 if not other_dataset_names: 

1030 self.source_combo.setCurrentText(self.SOURCE_LINSPACE) 

1031 

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) 

1037 

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) 

1047 

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) 

1052 

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) 

1058 

1059 apply_form_spacing(self) 

1060 

1061 def get_settings(self): 

1062 """(グリッドの元, 元ごとの引数, 補間の方法, 外挿するか, 出力名)。 

1063 

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 ) 

1084 

1085 

1086class OutlierDetectionDialog(QDialog): 

1087 """外れ値の検出の設定。マスクに適用するかは別のチェック(既定はオフ、結果を見てから決められる)。""" 

1088 

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

1093 

1094 def __init__(self, name, parent=None): 

1095 super().__init__(parent) 

1096 self.setWindowTitle("外れ値検出") 

1097 self.resize(420, 280) 

1098 

1099 layout = QVBoxLayout(self) 

1100 layout.addWidget(QLabel(f"対象: {name}(Y値をもとに検出します)")) 

1101 

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) 

1107 

1108 self.stack = QStackedWidget() 

1109 

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) 

1118 

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) 

1127 

1128 layout.addWidget(self.stack) 

1129 self.method_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) 

1130 

1131 self.apply_mask_checkbox = QCheckBox("検出した外れ値をマスク(除外)に適用する") 

1132 self.apply_mask_checkbox.setToolTip( 

1133 "チェックを外すと検出結果を表示するだけでマスクは変更しません。\n" 

1134 "マスクは非破壊で、いつでも解除できます。" 

1135 ) 

1136 layout.addWidget(self.apply_mask_checkbox) 

1137 

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) 

1143 

1144 apply_form_spacing(self) 

1145 

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

1151 

1152 

1153class HistogramKDEDialog(QDialog): 

1154 """ヒストグラムか KDE。対象の列は Y に限らない(分布を見たいのは Y とは限らない)。""" 

1155 

1156 MODE_HISTOGRAM = "ヒストグラム" 

1157 MODE_KDE = "カーネル密度推定(KDE)" 

1158 MODES = [MODE_HISTOGRAM, MODE_KDE] 

1159 

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 

1165 

1166 layout = QVBoxLayout(self) 

1167 layout.addWidget(QLabel(f"対象: {name}")) 

1168 

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) 

1175 

1176 self.mode_combo = QComboBox() 

1177 self.mode_combo.addItems(self.MODES) 

1178 form.addRow("モード", self.mode_combo) 

1179 layout.addLayout(form) 

1180 

1181 self.stack = QStackedWidget() 

1182 

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) 

1197 

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) 

1205 

1206 layout.addWidget(self.stack) 

1207 self.mode_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) 

1208 

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) 

1214 

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) 

1220 

1221 apply_form_spacing(self) 

1222 

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

1230 

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 

1245 

1246 

1247class DatasetArithmeticDialog(QDialog): 

1248 """A と B の演算。X が揃わないので、呼び出し側が B を A の X に補間してから計算する。""" 

1249 

1250 OPERATIONS = ["A - B", "B - A", "A + B", "A × B", "A ÷ B", "B ÷ A"] 

1251 

1252 def __init__(self, name_a, name_b, parent=None): 

1253 super().__init__(parent) 

1254 self.setWindowTitle("データセット間演算") 

1255 self.resize(360, 200) 

1256 

1257 layout = QVBoxLayout(self) 

1258 label = QLabel(f"A: {name_a}\nB: {name_b}") 

1259 layout.addWidget(label) 

1260 

1261 form = QFormLayout() 

1262 self.operation_combo = QComboBox() 

1263 self.operation_combo.addItems(self.OPERATIONS) 

1264 form.addRow("演算", self.operation_combo) 

1265 

1266 self.output_name_edit = QLineEdit(f"{name_a} vs {name_b}") 

1267 form.addRow("出力データセット名", self.output_name_edit) 

1268 layout.addLayout(form) 

1269 

1270 info_label = QLabel( 

1271 "B側のY値を、A側のX値に線形補間してから演算します。" 

1272 "2つのデータセットのX軸の値が重なる範囲のみが対象になります。" 

1273 ) 

1274 info_label.setWordWrap(True) 

1275 layout.addWidget(info_label) 

1276 

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) 

1282 

1283 apply_form_spacing(self) 

1284 

1285 def get_settings(self): 

1286 """(演算の種類, 出力名)""" 

1287 return self.operation_combo.currentText(), self.output_name_edit.text().strip() 

1288 

1289 

1290class XAxisAlignmentDialog(QDialog): 

1291 """B(動かす側)を A に重ねる。シフト量は自動で求めるので、出力名だけ尋ねる。""" 

1292 

1293 def __init__(self, name_a, name_b, parent=None): 

1294 super().__init__(parent) 

1295 self.setWindowTitle("X軸アライメント(相互相関)") 

1296 self.resize(380, 200) 

1297 

1298 layout = QVBoxLayout(self) 

1299 label = QLabel(f"基準(移動しない): {name_a}\n位置合わせ対象(移動する): {name_b}") 

1300 layout.addWidget(label) 

1301 

1302 form = QFormLayout() 

1303 self.output_name_edit = QLineEdit(f"{name_b}_aligned") 

1304 form.addRow("出力データセット名", self.output_name_edit) 

1305 layout.addLayout(form) 

1306 

1307 info_label = QLabel( 

1308 "2つのデータセットの相互相関が最大になるよう、位置合わせ対象のX値を" 

1309 "シフトした新しいデータセットを作成します(元のデータセットは変更しません)。" 

1310 ) 

1311 info_label.setWordWrap(True) 

1312 layout.addWidget(info_label) 

1313 

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) 

1319 

1320 apply_form_spacing(self) 

1321 

1322 def get_settings(self): 

1323 return self.output_name_edit.text().strip()