graphica-plot 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. graphica/Graphica.ico +0 -0
  2. graphica/__init__.py +1 -0
  3. graphica/__main__.py +111 -0
  4. graphica/assets/__init__.py +0 -0
  5. graphica/assets/icons/__init__.py +0 -0
  6. graphica/assets/icons/arrow-right.svg +21 -0
  7. graphica/assets/icons/bold.svg +20 -0
  8. graphica/assets/icons/calculator.svg +26 -0
  9. graphica/assets/icons/chart-histogram.svg +24 -0
  10. graphica/assets/icons/chart-line.svg +20 -0
  11. graphica/assets/icons/chevron-down.svg +19 -0
  12. graphica/assets/icons/chevron-right.svg +19 -0
  13. graphica/assets/icons/color-swatch.svg +22 -0
  14. graphica/assets/icons/column-insert-right.svg +21 -0
  15. graphica/assets/icons/column-remove.svg +21 -0
  16. graphica/assets/icons/copy.svg +20 -0
  17. graphica/assets/icons/download.svg +21 -0
  18. graphica/assets/icons/edit.svg +21 -0
  19. graphica/assets/icons/eye-off.svg +21 -0
  20. graphica/assets/icons/eye.svg +20 -0
  21. graphica/assets/icons/file-plus.svg +22 -0
  22. graphica/assets/icons/folder-plus.svg +21 -0
  23. graphica/assets/icons/folder.svg +19 -0
  24. graphica/assets/icons/highlight.svg +22 -0
  25. graphica/assets/icons/history.svg +20 -0
  26. graphica/assets/icons/italic.svg +21 -0
  27. graphica/assets/icons/layout-grid.svg +22 -0
  28. graphica/assets/icons/math-function.svg +22 -0
  29. graphica/assets/icons/message-2.svg +21 -0
  30. graphica/assets/icons/mountain.svg +20 -0
  31. graphica/assets/icons/palette.svg +22 -0
  32. graphica/assets/icons/pointer.svg +19 -0
  33. graphica/assets/icons/refresh.svg +20 -0
  34. graphica/assets/icons/row-insert-bottom.svg +21 -0
  35. graphica/assets/icons/row-remove.svg +21 -0
  36. graphica/assets/icons/search.svg +20 -0
  37. graphica/assets/icons/select-all.svg +35 -0
  38. graphica/assets/icons/subscript.svg +20 -0
  39. graphica/assets/icons/superscript.svg +20 -0
  40. graphica/assets/icons/table.svg +21 -0
  41. graphica/assets/icons/trash.svg +23 -0
  42. graphica/assets/icons/typography.svg +23 -0
  43. graphica/assets/icons/x.svg +20 -0
  44. graphica/core/__init__.py +0 -0
  45. graphica/core/analysis.py +1278 -0
  46. graphica/core/app_paths.py +35 -0
  47. graphica/core/axis_settings.py +128 -0
  48. graphica/core/caption_export.py +47 -0
  49. graphica/core/color_palettes.py +56 -0
  50. graphica/core/commands.py +226 -0
  51. graphica/core/cvd_simulation.py +49 -0
  52. graphica/core/dataset.py +443 -0
  53. graphica/core/diagnostics.py +105 -0
  54. graphica/core/excel_utils.py +58 -0
  55. graphica/core/fit_models.py +379 -0
  56. graphica/core/grid_data.py +128 -0
  57. graphica/core/i18n.py +44 -0
  58. graphica/core/json_utils.py +27 -0
  59. graphica/core/label_utils.py +21 -0
  60. graphica/core/methods_text.py +115 -0
  61. graphica/core/named_colors.py +126 -0
  62. graphica/core/plugin_api.py +515 -0
  63. graphica/core/plugin_context.py +167 -0
  64. graphica/core/plugin_install.py +82 -0
  65. graphica/core/plugin_manifest.py +67 -0
  66. graphica/core/plugin_testing.py +213 -0
  67. graphica/core/plugin_types.py +118 -0
  68. graphica/core/provenance.py +19 -0
  69. graphica/core/report_export.py +51 -0
  70. graphica/core/safe_eval.py +194 -0
  71. graphica/core/script_export.py +272 -0
  72. graphica/core/translations_en.py +530 -0
  73. graphica/core/unit_conversion.py +51 -0
  74. graphica/core/update_check.py +43 -0
  75. graphica/core/version.py +14 -0
  76. graphica/gui/__init__.py +0 -0
  77. graphica/gui/app_settings.py +119 -0
  78. graphica/gui/axis_bindings.py +173 -0
  79. graphica/gui/binding.py +135 -0
  80. graphica/gui/builders/__init__.py +1 -0
  81. graphica/gui/builders/axis_panel.py +431 -0
  82. graphica/gui/builders/canvas_area.py +176 -0
  83. graphica/gui/builders/common.py +120 -0
  84. graphica/gui/builders/dataset_panel.py +433 -0
  85. graphica/gui/builders/property_sections.py +208 -0
  86. graphica/gui/canvas.py +519 -0
  87. graphica/gui/color_history.py +47 -0
  88. graphica/gui/color_picker_widget.py +198 -0
  89. graphica/gui/crash_handler.py +77 -0
  90. graphica/gui/cvd_preview.py +23 -0
  91. graphica/gui/data_editor.py +761 -0
  92. graphica/gui/data_import_flow.py +355 -0
  93. graphica/gui/dataset_bindings.py +107 -0
  94. graphica/gui/dataset_style_icon.py +80 -0
  95. graphica/gui/datasets/__init__.py +1 -0
  96. graphica/gui/datasets/actions_menu.py +114 -0
  97. graphica/gui/datasets/colors.py +218 -0
  98. graphica/gui/datasets/fitting.py +70 -0
  99. graphica/gui/datasets/host.py +162 -0
  100. graphica/gui/datasets/operations/__init__.py +1 -0
  101. graphica/gui/datasets/operations/fitting.py +468 -0
  102. graphica/gui/datasets/operations/peaks.py +109 -0
  103. graphica/gui/datasets/operations/processing.py +659 -0
  104. graphica/gui/datasets/operations/runner.py +148 -0
  105. graphica/gui/datasets/operations/transfer.py +227 -0
  106. graphica/gui/datasets/order.py +310 -0
  107. graphica/gui/datasets/overlays.py +74 -0
  108. graphica/gui/datasets/peaks.py +21 -0
  109. graphica/gui/datasets/plugin_runs.py +99 -0
  110. graphica/gui/datasets/processing.py +68 -0
  111. graphica/gui/datasets/property_panel.py +538 -0
  112. graphica/gui/datasets/transfer.py +36 -0
  113. graphica/gui/detached_canvas_window.py +22 -0
  114. graphica/gui/dialogs/__init__.py +137 -0
  115. graphica/gui/dialogs/analysis.py +1322 -0
  116. graphica/gui/dialogs/app.py +1015 -0
  117. graphica/gui/dialogs/appearance.py +851 -0
  118. graphica/gui/dialogs/data_edit.py +577 -0
  119. graphica/gui/dialogs/data_import.py +679 -0
  120. graphica/gui/dialogs/export.py +433 -0
  121. graphica/gui/dock_layout.py +176 -0
  122. graphica/gui/export_preview_panel.py +321 -0
  123. graphica/gui/export_settings.py +16 -0
  124. graphica/gui/file_association.py +97 -0
  125. graphica/gui/icon_utils.py +44 -0
  126. graphica/gui/main_app_window.py +210 -0
  127. graphica/gui/main_window.py +1151 -0
  128. graphica/gui/mathtext_preview.py +168 -0
  129. graphica/gui/menu_bar.py +320 -0
  130. graphica/gui/minimap_widget.py +151 -0
  131. graphica/gui/mixins/__init__.py +0 -0
  132. graphica/gui/mixins/export_mixin.py +520 -0
  133. graphica/gui/mixins/help_mixin.py +127 -0
  134. graphica/gui/mixins/project_io_mixin.py +289 -0
  135. graphica/gui/mixins/quick_access_mixin.py +194 -0
  136. graphica/gui/mixins/ui_setup_mixin.py +206 -0
  137. graphica/gui/notify.py +61 -0
  138. graphica/gui/panels/__init__.py +43 -0
  139. graphica/gui/panels/axis_settings.py +605 -0
  140. graphica/gui/panels/dataset_tree.py +424 -0
  141. graphica/gui/plot_type_drawers.py +131 -0
  142. graphica/gui/plugin_context.py +119 -0
  143. graphica/gui/project_files.py +361 -0
  144. graphica/gui/provenance_panel.py +65 -0
  145. graphica/gui/rendering/__init__.py +1 -0
  146. graphica/gui/rendering/annotations.py +177 -0
  147. graphica/gui/rendering/appearance.py +368 -0
  148. graphica/gui/rendering/common.py +316 -0
  149. graphica/gui/rendering/data_1d.py +376 -0
  150. graphica/gui/rendering/data_2d.py +66 -0
  151. graphica/gui/residual_panel.py +62 -0
  152. graphica/gui/resources.py +16 -0
  153. graphica/gui/single_instance.py +141 -0
  154. graphica/gui/splash.py +83 -0
  155. graphica/gui/task_runner.py +41 -0
  156. graphica/gui/theme.py +864 -0
  157. graphica/gui/tools/__init__.py +56 -0
  158. graphica/gui/tools/annotation.py +275 -0
  159. graphica/gui/tools/cursor.py +259 -0
  160. graphica/gui/tools/layout_edit.py +285 -0
  161. graphica/gui/tools/manager.py +78 -0
  162. graphica/gui/tools/peak_placement.py +132 -0
  163. graphica/gui/tools/range_select.py +191 -0
  164. graphica/gui/tools/region_highlight.py +222 -0
  165. graphica/gui/tools/slice_extraction.py +200 -0
  166. graphica/gui/widget_translation.py +46 -0
  167. graphica/gui/workers.py +280 -0
  168. graphica/models/__init__.py +0 -0
  169. graphica/models/project.py +197 -0
  170. graphica/plugin/__init__.py +20 -0
  171. graphica/plugin/testing.py +57 -0
  172. graphica/sample_data/__init__.py +0 -0
  173. graphica/sample_data/cooling_curve_sample.csv +42 -0
  174. graphica/ui_main_window.py +581 -0
  175. graphica_plot-2.0.0.dist-info/METADATA +317 -0
  176. graphica_plot-2.0.0.dist-info/RECORD +181 -0
  177. graphica_plot-2.0.0.dist-info/WHEEL +5 -0
  178. graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
  179. graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
  180. graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
  181. graphica_plot-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,659 @@
1
+ """データセットのデータ処理(規格化・平滑化・ベースライン補正・積分・リサンプリング・データセット間の演算など)の本体。
2
+
3
+ 列を数に直すのは op.as_numbers() / op.valid_points() で、文字の列ならその処理の題で知らせて止める。
4
+ データセットを足す操作は op.add() で Undo できる追加にする(Undo の名前はその処理の題)。
5
+ """
6
+ import logging
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from PySide6.QtWidgets import QMessageBox
11
+
12
+ from graphica.gui import notify
13
+ from graphica.core.provenance import build_provenance
14
+ from graphica.core.analysis import (calculate_savgol,
15
+ calculate_baseline_als, calculate_baseline_polynomial,
16
+ calculate_baseline_rubberband, calculate_baseline_manual,
17
+ calculate_interval_integral, calculate_cumulative_integral,
18
+ calculate_average_duplicate_x,
19
+ calculate_zscore_outliers, calculate_iqr_outliers,
20
+ calculate_resample_to_grid, calculate_histogram, calculate_kde, calculate_error_propagation,
21
+ calculate_cross_correlation_alignment, split_dataframe_by_column)
22
+ from graphica.core.commands import SetMaskedRowsCommand
23
+ from graphica.core.dataset import Dataset
24
+ from graphica.core.safe_eval import safe_eval_column_formula
25
+ from graphica.gui.datasets.operations.runner import Operation
26
+ from graphica.gui.dialogs import (ResultDialog, ColumnCalculatorDialog, DatasetArithmeticDialog, NormalizeDatasetDialog, SavGolDialog, BaselineCorrectionDialog, IntervalIntegralDialog, CumulativeIntegralDialog,
27
+ ResampleDatasetDialog, DuplicateXDialog, RowFilterDialog, OutlierDetectionDialog,
28
+ HistogramKDEDialog, XAxisAlignmentDialog)
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # 列の値で分割したとき、これを超える数の系列ができるなら確認する(連続値の列を取り違えた疑い)。
33
+ SPLIT_BY_COLUMN_CONFIRM_THRESHOLD = 30
34
+
35
+ INTEGRAL_RESULT_WINDOW = "interval_integral"
36
+ OUTLIER_RESULT_WINDOW = "outliers"
37
+
38
+
39
+ def arithmetic(op):
40
+ """選んだ2件 A・B の差・和・積・商を、B を A の X に線形補間してから計算し、新しいデータセットにする。"""
41
+ ds_a, ds_b = op.selected_datasets(
42
+ exactly=2, message="演算対象として、データセットをちょうど2つ選択してください。")[:2]
43
+
44
+ dialog = op.ask(DatasetArithmeticDialog(ds_a.name, ds_b.name, op.parent))
45
+ operation, output_name = dialog.get_settings()
46
+ op.require_output_name(output_name)
47
+
48
+ xa = op.as_numbers(ds_a, "x")
49
+ ya = op.as_numbers(ds_a, "y")
50
+ xb = op.as_numbers(ds_b, "x")
51
+ yb = op.as_numbers(ds_b, "y")
52
+
53
+ # 誤差は A・B の両方に誤差列があるときだけ伝播させる(片方だけだと不完全で誤解を招く)。
54
+ # 誤差列は x_data/y_data と行が対応しているので、同じ絞り込みをそのまま掛けられる。
55
+ err_a_full = ds_a.y_err_data
56
+ err_b_full = ds_b.y_err_data
57
+ propagate_errors = err_a_full is not None and err_b_full is not None
58
+ if propagate_errors:
59
+ err_a_full = np.asarray(err_a_full, dtype=float)
60
+ err_b_full = np.asarray(err_b_full, dtype=float)
61
+
62
+ valid_a = ~(np.isnan(xa) | np.isnan(ya))
63
+ valid_b = ~(np.isnan(xb) | np.isnan(yb))
64
+ xa, ya = xa[valid_a], ya[valid_a]
65
+ xb, yb = xb[valid_b], yb[valid_b]
66
+ if propagate_errors:
67
+ err_a_full = err_a_full[valid_a]
68
+ err_b_full = err_b_full[valid_b]
69
+
70
+ if len(xa) == 0 or len(xb) == 0:
71
+ op.stop_with_warning("有効なデータ点がありません。")
72
+
73
+ lo, hi = max(np.min(xa), np.min(xb)), min(np.max(xa), np.max(xb))
74
+ if lo > hi:
75
+ op.stop_with_warning("2つのデータセットのX軸の範囲が重なっていないため演算できません。")
76
+
77
+ mask = (xa >= lo) & (xa <= hi)
78
+ xa_sub, ya_sub = xa[mask], ya[mask]
79
+ if propagate_errors:
80
+ err_a_sub = err_a_full[mask]
81
+ if len(xa_sub) == 0:
82
+ op.stop_with_warning("重なる範囲にA側のデータ点がありません。")
83
+
84
+ order_b = np.argsort(xb)
85
+ yb_interp = np.interp(xa_sub, xb[order_b], yb[order_b])
86
+ if propagate_errors:
87
+ err_b_interp = np.interp(xa_sub, xb[order_b], err_b_full[order_b])
88
+
89
+ if operation == "A - B":
90
+ result = ya_sub - yb_interp
91
+ elif operation == "B - A":
92
+ result = yb_interp - ya_sub
93
+ elif operation == "A + B":
94
+ result = ya_sub + yb_interp
95
+ elif operation == "A × B":
96
+ result = ya_sub * yb_interp
97
+ elif operation == "A ÷ B":
98
+ with np.errstate(divide='ignore', invalid='ignore'):
99
+ result = ya_sub / yb_interp
100
+ else: # "B ÷ A"
101
+ with np.errstate(divide='ignore', invalid='ignore'):
102
+ result = yb_interp / ya_sub
103
+
104
+ result_data = {'x': xa_sub, 'y': result}
105
+ if propagate_errors:
106
+ result_data['y_err'] = calculate_error_propagation(operation, ya_sub, err_a_sub, yb_interp, err_b_interp)
107
+ op.add_and_report(Dataset(
108
+ name=output_name, df=pd.DataFrame(result_data), x_col_name='x', y_col_name='y',
109
+ y_err_col_name='y_err' if propagate_errors else None,
110
+ provenance=build_provenance(
111
+ 'arithmetic', {'operation_symbol': operation, 'error_propagated': propagate_errors}, [ds_a, ds_b],
112
+ ),
113
+ ))
114
+
115
+
116
+ def align(op):
117
+ """選んだ2件のうち B を、A との相互相関で求めた X のずれだけ動かした新しいデータセットを作る(A が基準)。"""
118
+ ds_a, ds_b = op.selected_datasets(
119
+ exactly=2, message="位置合わせ対象として、データセットをちょうど2つ選択してください。")[:2]
120
+
121
+ dialog = op.ask(XAxisAlignmentDialog(ds_a.name, ds_b.name, op.parent))
122
+ output_name = dialog.get_settings()
123
+ op.require_output_name(output_name)
124
+
125
+ xa = op.as_numbers(ds_a, "x")
126
+ ya = op.as_numbers(ds_a, "y")
127
+ xb = op.as_numbers(ds_b, "x")
128
+ yb = op.as_numbers(ds_b, "y")
129
+
130
+ result = op.calculate(lambda: calculate_cross_correlation_alignment(xa, ya, xb, yb))
131
+
132
+ shift = result['shift']
133
+ valid_b = ~(np.isnan(xb) | np.isnan(yb))
134
+ op.add(Dataset(
135
+ name=output_name, df=pd.DataFrame({'x': xb[valid_b] + shift, 'y': yb[valid_b]}), x_col_name='x', y_col_name='y',
136
+ provenance=build_provenance(
137
+ 'xaxis_alignment', {'shift': shift, 'grid_step': result['grid_step']}, [ds_a, ds_b],
138
+ ),
139
+ ))
140
+ op.status(f"「{output_name}」を追加しました(シフト量: {shift:+.4g})", 4000)
141
+
142
+
143
+ def mean_and_sd(op):
144
+ """
145
+ 選んだ2件以上を、X が重なる範囲の共通の格子へ線形補間し、点ごとの平均と標本標準偏差(ddof=1)を
146
+ 誤差列付きの新しいデータセットにする。格子の点数は最も点の多いデータセットに合わせる。
147
+ """
148
+ selected = op.selected_datasets(
149
+ at_least=2, message="平均±SDの生成には、データセットを2つ以上選択してください。")
150
+
151
+ x_arrays, y_arrays = [], []
152
+ for ds in selected:
153
+ x, y = op.valid_points(ds)
154
+ if len(x) < 2:
155
+ op.stop_with_warning(f"「{ds.name}」に有効なデータ点が不足しています(最低2点必要)。")
156
+ x_arrays.append(x)
157
+ y_arrays.append(y)
158
+
159
+ x_min = max(float(np.min(x)) for x in x_arrays)
160
+ x_max = min(float(np.max(x)) for x in x_arrays)
161
+ if x_min >= x_max:
162
+ op.stop_with_warning("選択したデータセット間でX軸の範囲が重なっていません。")
163
+
164
+ num_points = max(len(x) for x in x_arrays)
165
+ common_x = np.linspace(x_min, x_max, num_points)
166
+
167
+ resampled = []
168
+ for x, y in zip(x_arrays, y_arrays):
169
+ resampled.append(op.calculate(
170
+ lambda x=x, y=y: calculate_resample_to_grid(x, y, common_x, method='linear', extrapolate=False)))
171
+ stacked = np.vstack(resampled) # (データセット数, num_points)
172
+
173
+ mean_y = np.nanmean(stacked, axis=0)
174
+ std_y = np.nanstd(stacked, axis=0, ddof=1)
175
+
176
+ default_name = f"{selected[0].name} 他{len(selected) - 1}件の平均±SD"
177
+ output_name, ok = notify.get_text(op.parent, op.title, "出力データセット名:", text=default_name)
178
+ if not ok or not output_name.strip():
179
+ op.stop()
180
+
181
+ op.add_and_report(Dataset(
182
+ name=output_name.strip(), df=pd.DataFrame({'x': common_x, 'y_mean': mean_y, 'y_sd': std_y}),
183
+ x_col_name='x', y_col_name='y_mean', y_err_col_name='y_sd',
184
+ provenance=build_provenance('mean_sd', {'method': 'linear', 'n_source': len(selected)}, selected),
185
+ ))
186
+
187
+
188
+ def normalize(op):
189
+ """今のデータセットの Y を、最大値か指定した X での値で割った新しいデータセットを作る。"""
190
+ original_dataset = op.current_dataset()
191
+ x_data, y_data = op.valid_points(original_dataset)
192
+ if len(x_data) == 0:
193
+ op.stop_with_warning("有効なデータ点がありません。")
194
+
195
+ x_min, x_max = float(np.min(x_data)), float(np.max(x_data))
196
+ dialog = op.ask(NormalizeDatasetDialog(original_dataset.name, x_min=x_min, x_max=x_max, parent=op.parent))
197
+ mode, reference_x, output_name = dialog.get_settings()
198
+ op.require_output_name(output_name)
199
+
200
+ if mode == NormalizeDatasetDialog.MODE_MAX:
201
+ reference_value = float(np.max(y_data))
202
+ else:
203
+ if reference_x < x_min or reference_x > x_max:
204
+ op.stop_with_warning(
205
+ f"指定されたX値 ({reference_x}) がデータセットのX軸範囲 "
206
+ f"({x_min} 〜 {x_max}) の外にあるため、規格化できません。"
207
+ )
208
+ order = np.argsort(x_data)
209
+ reference_value = float(np.interp(reference_x, x_data[order], y_data[order]))
210
+
211
+ if abs(reference_value) < 1e-12:
212
+ op.stop_with_warning("基準値が0に近すぎるため、規格化できません。")
213
+
214
+ op.add_and_report(Dataset(
215
+ name=output_name, df=pd.DataFrame({'x': x_data, 'y': y_data / reference_value}), x_col_name='x', y_col_name='y',
216
+ provenance=build_provenance(
217
+ 'normalize',
218
+ {'mode': mode, 'reference_x': reference_x, 'reference_value': reference_value},
219
+ [original_dataset],
220
+ ),
221
+ ))
222
+
223
+
224
+ def savgol_smooth(op):
225
+ """今のデータセットを Savitzky-Golay で平滑化(または微分)した新しいデータセットを作る。"""
226
+ original_dataset = op.current_dataset()
227
+ x_data, y_data = op.valid_points(original_dataset)
228
+ if len(x_data) < 3:
229
+ op.stop_with_warning("有効なデータ点が不足しています。")
230
+
231
+ dialog = op.ask(SavGolDialog(original_dataset.name, max_window=len(x_data), parent=op.parent))
232
+ window_length, polyorder, deriv, output_name = dialog.get_settings()
233
+ op.require_output_name(output_name)
234
+
235
+ x_sorted, y_result = op.calculate(
236
+ lambda: calculate_savgol(x_data, y_data, window_length, polyorder, deriv=deriv))
237
+
238
+ op.add_and_report(Dataset(
239
+ name=output_name, df=pd.DataFrame({'x': x_sorted, 'y': y_result}), x_col_name='x', y_col_name='y',
240
+ provenance=build_provenance(
241
+ 'savgol', {'window_length': window_length, 'polyorder': polyorder, 'deriv': deriv}, [original_dataset],
242
+ ),
243
+ ))
244
+
245
+
246
+ def _baseline(method, params, x_data, y_data):
247
+ if method == "als":
248
+ return calculate_baseline_als(x_data, y_data, **params)
249
+ if method == "polynomial":
250
+ return calculate_baseline_polynomial(x_data, y_data, **params)
251
+ if method == "rubberband":
252
+ return calculate_baseline_rubberband(x_data, y_data)
253
+ # "manual": アンカー点は自由記入のテキストで来るので、ここで数に直す(書式の誤りも同じ警告で出す)
254
+ anchor_text = params["anchor_x_text"]
255
+ try:
256
+ anchor_x = [float(token) for token in anchor_text.replace("\n", ",").split(",") if token.strip()]
257
+ except ValueError:
258
+ raise ValueError("アンカー点のX座標は数値をカンマ区切りで入力してください。") from None
259
+ return calculate_baseline_manual(x_data, y_data, anchor_x=anchor_x, method=params["method"])
260
+
261
+
262
+ def baseline_correction(op):
263
+ """今のデータセットからベースラインを引いた新しいデータセットを作る(選べばベースライン自体も足す)。"""
264
+ original_dataset = op.current_dataset()
265
+ x_data, y_data = op.valid_points(original_dataset)
266
+ if len(x_data) < 3:
267
+ op.stop_with_warning("有効なデータ点が不足しています。")
268
+
269
+ x_min, x_max = float(np.min(x_data)), float(np.max(x_data))
270
+ dialog = op.ask(BaselineCorrectionDialog(original_dataset.name, x_min=x_min, x_max=x_max, parent=op.parent))
271
+ method, params, output_name, add_baseline_dataset = dialog.get_settings()
272
+ op.require_output_name(output_name)
273
+
274
+ x_sorted, baseline, corrected = op.calculate(lambda: _baseline(method, params, x_data, y_data))
275
+
276
+ # ベースライン曲線も足すときは、補正したものと合わせて Undo 1回分にする
277
+ with op.host.undo_macro(op.title, enabled=add_baseline_dataset):
278
+ op.add(Dataset(
279
+ name=output_name, df=pd.DataFrame({'x': x_sorted, 'y': corrected}), x_col_name='x', y_col_name='y',
280
+ provenance=build_provenance(f'baseline_{method}', dict(params), [original_dataset]),
281
+ ))
282
+ if add_baseline_dataset:
283
+ op.add(Dataset(
284
+ name=f"{output_name}_baseline", df=pd.DataFrame({'x': x_sorted, 'y': baseline}),
285
+ x_col_name='x', y_col_name='y',
286
+ ))
287
+ op.status(f"「{output_name}」を追加しました", 3000)
288
+
289
+
290
+ def interval_integral(op):
291
+ """今のデータセットの指定した X 範囲の積分値を、結果の窓(グラフを見ながら確かめられるよう非モーダル)に出す。"""
292
+ original_dataset = op.current_dataset()
293
+ x_data, y_data = op.valid_points(original_dataset)
294
+ if len(x_data) < 2:
295
+ op.stop_with_warning("有効なデータ点が不足しています(最低2点必要)。")
296
+
297
+ x_min, x_max = float(np.min(x_data)), float(np.max(x_data))
298
+ dialog = op.ask(IntervalIntegralDialog(original_dataset.name, x_min=x_min, x_max=x_max, parent=op.parent))
299
+ method, x_range, subtract_baseline = dialog.get_settings()
300
+
301
+ result = op.calculate(lambda: calculate_interval_integral(
302
+ x_data, y_data, x_range, method=method, subtract_baseline=subtract_baseline))
303
+
304
+ method_label = "台形則(Trapezoidal)" if method == "trapezoid" else "Simpson則"
305
+ result_text = f"[{original_dataset.name}] の区間積分結果:\n"
306
+ result_text += f" 積分方法: {method_label}\n"
307
+ result_text += f" 積分範囲: {x_range[0]: .6g} 〜 {x_range[1]: .6g}\n"
308
+ result_text += f" ベースライン差し引き: {'あり(範囲両端を結ぶ直線)' if subtract_baseline else 'なし'}\n"
309
+ result_text += f" 使用データ点数: {result['n_points']}\n"
310
+ result_text += f" 積分値 = {result['integral']: .6e}\n"
311
+
312
+ op.show_result_window(INTEGRAL_RESULT_WINDOW, lambda: ResultDialog(
313
+ "区間積分完了", result_text, op.parent, csv_data=pd.DataFrame({
314
+ 'X': result['x_used'],
315
+ 'Y(元データ)': result['y_raw_used'],
316
+ 'Y(積分に使用)': result['y_used'],
317
+ })))
318
+
319
+
320
+ def cumulative_integral(op):
321
+ """今のデータセットの、X の各点までの積分値を新しいデータセットにする。"""
322
+ original_dataset = op.current_dataset()
323
+ x_data, y_data = op.valid_points(original_dataset)
324
+ if len(x_data) < 2:
325
+ op.stop_with_warning("有効なデータ点が不足しています(最低2点必要)。")
326
+
327
+ dialog = op.ask(CumulativeIntegralDialog(original_dataset.name, parent=op.parent))
328
+ method, output_name = dialog.get_settings()
329
+ op.require_output_name(output_name)
330
+
331
+ result = op.calculate(lambda: calculate_cumulative_integral(x_data, y_data, method=method))
332
+
333
+ op.add_and_report(Dataset(
334
+ name=output_name, df=pd.DataFrame({'x': result['x_used'], 'y': result['y_cumulative']}),
335
+ x_col_name='x', y_col_name='y',
336
+ provenance=build_provenance('cumulative_integral', {'method': method}, [original_dataset]),
337
+ ))
338
+
339
+
340
+ def split_by_column(op):
341
+ """
342
+ 区分列(試料名・条件など)の値ごとに、今のデータセットを別々のデータセットに分ける。元は残す。
343
+ Dataset.color の既定は全部同じ色なので、パレットから順に色を割り当てる。追加は Undo 1回分。
344
+ """
345
+ original_dataset = op.current_dataset()
346
+ columns = list(original_dataset.df.columns)
347
+ if not columns:
348
+ op.stop_with_warning("分割に使える列がありません。")
349
+
350
+ split_col, ok = notify.get_item(
351
+ op.parent, "列の値で系列に分割",
352
+ "分割に使う列(この列の値ごとに別々の系列になります):",
353
+ columns, 0, False,
354
+ )
355
+ if not ok or not split_col:
356
+ op.stop()
357
+
358
+ result = op.calculate(lambda: split_dataframe_by_column(original_dataset.df, split_col))
359
+
360
+ groups = result['groups']
361
+ if len(groups) < 2:
362
+ op.stop_with_information(
363
+ f"列「{split_col}」の値は{len(groups)}種類しかないため、"
364
+ "分割しても系列は増えません。",
365
+ )
366
+
367
+ if len(groups) > SPLIT_BY_COLUMN_CONFIRM_THRESHOLD:
368
+ answer = notify.question(
369
+ op.parent, op.title,
370
+ f"列「{split_col}」の値は{len(groups)}種類あります。\n"
371
+ f"同じ数({len(groups)}件)のデータセットを追加しますが、よろしいですか?\n\n"
372
+ "(連続値の列を選んでいる場合は、意図しない大量の系列になります)",
373
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
374
+ QMessageBox.StandardButton.No,
375
+ )
376
+ if answer != QMessageBox.StandardButton.Yes:
377
+ op.stop()
378
+
379
+ host = op.host
380
+ color_cycle = host.active_color_cycle()
381
+ target_folder = host.target_folder_for_new_dataset()
382
+
383
+ host.undo_stack.beginMacro(f"列「{split_col}」で系列に分割 ({len(groups)}件)")
384
+ try:
385
+ for i, (label, sub_df) in enumerate(groups):
386
+ new_dataset = Dataset(
387
+ name=f"{original_dataset.name} ({label})",
388
+ df=sub_df,
389
+ x_col_name=original_dataset.x_col_name,
390
+ y_col_name=original_dataset.y_col_name,
391
+ color=color_cycle[i % len(color_cycle)],
392
+ provenance=build_provenance(
393
+ 'split_by_column', {'split_column': split_col, 'group_value': label}, [original_dataset],
394
+ ),
395
+ )
396
+ host.add_dataset_with_undo(new_dataset, target_folder, description=f"「{new_dataset.name}」の追加")
397
+ finally:
398
+ host.undo_stack.endMacro()
399
+
400
+ message = f"列「{split_col}」の値で{len(groups)}件の系列に分割しました"
401
+ if result['n_dropped']:
402
+ message += f"(「{split_col}」が空の{result['n_dropped']}行は除外)"
403
+ op.status(message, 5000)
404
+
405
+
406
+ def resample(op):
407
+ """今のデータセットを、別のデータセットの X か等間隔の格子へ補間した新しいデータセットを作る。"""
408
+ original_dataset = op.current_dataset()
409
+ x_data, y_data = op.valid_points(original_dataset)
410
+ if len(x_data) < 2:
411
+ op.stop_with_warning("有効なデータ点が不足しています(最低2点必要)。")
412
+
413
+ other_dataset_names = [ds.name for ds in op.host.datasets() if ds is not original_dataset]
414
+
415
+ x_min, x_max = float(np.min(x_data)), float(np.max(x_data))
416
+ dialog = op.ask(ResampleDatasetDialog(
417
+ original_dataset.name, other_dataset_names, x_min=x_min, x_max=x_max, parent=op.parent
418
+ ))
419
+ source, params, method, extrapolate, output_name = dialog.get_settings()
420
+ op.require_output_name(output_name)
421
+
422
+ if source == "dataset":
423
+ target_dataset_name = params["dataset_name"]
424
+ target_dataset = next(
425
+ (ds for ds in op.host.datasets() if ds is not original_dataset and ds.name == target_dataset_name),
426
+ None
427
+ )
428
+ if target_dataset is None:
429
+ op.stop_with_warning("リサンプリング先のデータセットを選択してください。")
430
+ target_x = op.as_numbers(target_dataset, "x")
431
+ target_x = target_x[~np.isnan(target_x)]
432
+ if len(target_x) == 0:
433
+ op.stop_with_warning(f"「{target_dataset_name}」に有効なX値がありません。")
434
+ else: # "linspace"
435
+ start, stop, num_points = params["start"], params["stop"], params["num_points"]
436
+ if start == stop:
437
+ op.stop_with_warning("開始Xと終了Xが同じ値です。")
438
+ target_x = np.linspace(start, stop, num_points)
439
+
440
+ result_y = op.calculate(lambda: calculate_resample_to_grid(
441
+ x_data, y_data, target_x, method=method, extrapolate=extrapolate))
442
+
443
+ # 選んだ格子の並び順をそのまま使う(並べ替えない)
444
+ provenance_sources = [original_dataset] + ([target_dataset] if source == "dataset" else [])
445
+ op.add_and_report(Dataset(
446
+ name=output_name, df=pd.DataFrame({'x': target_x, 'y': result_y}), x_col_name='x', y_col_name='y',
447
+ provenance=build_provenance(
448
+ 'resample', {'source': source, 'method': method, 'extrapolate': extrapolate}, provenance_sources,
449
+ ),
450
+ ))
451
+
452
+
453
+ def _histogram_or_kde_result(settings, column_data):
454
+ if settings['mode'] == 'histogram':
455
+ result = calculate_histogram(column_data, bins=settings['bins'], density=settings['density'])
456
+ result_df = pd.DataFrame({'x': result['bin_centers'], 'y': result['counts']})
457
+ params = {'column': settings['column'], 'bins': settings['bins'], 'density': settings['density']}
458
+ return result_df, params, 'Bar'
459
+ result = calculate_kde(column_data, n_points=settings['n_points'])
460
+ result_df = pd.DataFrame({'x': result['x_grid'], 'y': result['density']})
461
+ params = {'column': settings['column'], 'n_points': settings['n_points']}
462
+ return result_df, params, 'Line'
463
+
464
+
465
+ def histogram_or_kde(op):
466
+ """今のデータセットの数値列1つのヒストグラムかカーネル密度推定を、新しいデータセットにする。"""
467
+ original_dataset = op.current_dataset()
468
+ numeric_columns = original_dataset.df.select_dtypes(include=[np.number]).columns.tolist()
469
+ if not numeric_columns:
470
+ op.stop_with_warning("数値列がありません。")
471
+ default_column = original_dataset.y_col_name if original_dataset.y_col_name in numeric_columns else numeric_columns[0]
472
+
473
+ dialog = op.ask(HistogramKDEDialog(
474
+ original_dataset.name, numeric_columns, default_column=default_column, parent=op.parent))
475
+ settings = dialog.get_settings()
476
+ op.require_output_name(settings['output_name'])
477
+
478
+ column_data = original_dataset.visible_df[settings['column']]
479
+ result_df, params, plot_type = op.calculate(lambda: _histogram_or_kde_result(settings, column_data))
480
+
481
+ op.add_and_report(Dataset(
482
+ name=settings['output_name'], df=result_df, x_col_name='x', y_col_name='y',
483
+ plot_type=plot_type,
484
+ provenance=build_provenance(settings['mode'], params, [original_dataset]),
485
+ ))
486
+
487
+
488
+ def detect_duplicate_x(op):
489
+ """
490
+ 同じ X の行を見つけ、平均した新しいデータセットを作るか、各 X の最初の行以外を除外(マスク)する。
491
+ 平均は除外済みの行を除いたデータから、除外する行の判定は df 全体から行う。
492
+ """
493
+ original_dataset = op.current_dataset()
494
+ x_col = original_dataset.x_col_name
495
+ n_duplicate_rows = int(original_dataset.df[x_col].duplicated(keep=False).sum())
496
+ if n_duplicate_rows == 0:
497
+ op.stop_with_information("重複するX値を持つ行は見つかりませんでした。")
498
+
499
+ dialog = op.ask(DuplicateXDialog(original_dataset.name, n_duplicate_rows, parent=op.parent))
500
+ mode, output_name = dialog.get_settings()
501
+
502
+ if mode == "average":
503
+ op.require_output_name(output_name)
504
+ x_data = op.as_numbers(original_dataset, "x")
505
+ y_data = op.as_numbers(original_dataset, "y")
506
+ result = op.calculate(lambda: calculate_average_duplicate_x(x_data, y_data))
507
+ op.add_and_report(Dataset(
508
+ name=output_name, df=pd.DataFrame({'x': result['x_used'], 'y': result['y_averaged']}),
509
+ x_col_name='x', y_col_name='y',
510
+ provenance=build_provenance(
511
+ 'average_duplicate_x',
512
+ {'n_duplicate_groups': result['n_duplicate_groups'],
513
+ 'n_points_in': result['n_points_in'], 'n_points_out': result['n_points_out']},
514
+ [original_dataset],
515
+ ),
516
+ ))
517
+ else: # "remove"
518
+ to_mask_indices = original_dataset.df.index[original_dataset.df[x_col].duplicated(keep='first')].tolist()
519
+ old_masked = list(original_dataset.masked_row_indices)
520
+ new_masked = sorted(set(old_masked) | set(to_mask_indices))
521
+ if new_masked == old_masked:
522
+ op.stop_with_information("既にすべてマスク済みです。")
523
+ op.host.undo_stack.push(SetMaskedRowsCommand(
524
+ original_dataset, old_masked, new_masked,
525
+ description=f"重複X値の除去({len(to_mask_indices)}件をマスク)",
526
+ ))
527
+ op.host.redraw()
528
+ op.status(f"{len(to_mask_indices)}件をマスクしました", 3000)
529
+
530
+
531
+ def filter_rows(op):
532
+ """条件式を満たさない行を除外(マスク)する。今までの除外に足す。"""
533
+ original_dataset = op.current_dataset()
534
+
535
+ dialog = op.ask(RowFilterDialog(original_dataset.df.columns.tolist(), parent=op.parent))
536
+ formula = dialog.get_formula()
537
+ if not formula:
538
+ op.stop_with_warning("条件式が空です。", title="入力エラー")
539
+
540
+ try:
541
+ match_result = safe_eval_column_formula(original_dataset.df, formula)
542
+ except Exception as e:
543
+ logger.exception("行フィルタの条件式を評価できませんでした")
544
+ op.stop_with_warning(f"条件式の評価に失敗しました:\n{e}")
545
+
546
+ # 真偽値以外が返ったら pandas の規約で bool にする。NaN は偽(=除外する側)に倒す。
547
+ try:
548
+ match_bool = match_result.astype(bool)
549
+ except (TypeError, ValueError):
550
+ op.stop_with_warning("条件式の結果を真偽値に変換できませんでした。")
551
+ match_bool = match_bool.fillna(False) if hasattr(match_bool, 'fillna') else match_bool
552
+
553
+ to_mask_indices = original_dataset.df.index[~match_bool].tolist()
554
+ old_masked = list(original_dataset.masked_row_indices)
555
+ new_masked = sorted(set(old_masked) | set(to_mask_indices))
556
+ if new_masked == old_masked:
557
+ op.stop_with_information("条件を満たさない(新たにマスクされる)行はありませんでした。")
558
+
559
+ op.host.undo_stack.push(SetMaskedRowsCommand(
560
+ original_dataset, old_masked, new_masked, description=f"行フィルタ({formula})",
561
+ ))
562
+ op.host.redraw()
563
+ op.status(f"{len(new_masked) - len(old_masked)}件をマスクしました", 3000)
564
+
565
+
566
+ def _outliers(method, value, y_data):
567
+ if method == "zscore":
568
+ return calculate_zscore_outliers(y_data, threshold=value), f"Z-score(しきい値 |Z| > {value:g})"
569
+ return calculate_iqr_outliers(y_data, multiplier=value), f"IQR(係数 {value:g})"
570
+
571
+
572
+ def detect_outliers(op):
573
+ """Y の外れ値を Z-score か IQR で探して結果を出す。除外(マスク)は利用者が選んだときだけ。"""
574
+ original_dataset = op.current_dataset()
575
+ y_data = op.as_numbers(original_dataset, "y")
576
+ if len(y_data) < 2:
577
+ op.stop_with_warning("有効なデータ点が不足しています(最低2点必要)。")
578
+
579
+ dialog = op.ask(OutlierDetectionDialog(original_dataset.name, parent=op.parent))
580
+ method, value, apply_to_mask = dialog.get_settings()
581
+
582
+ result, method_label = op.calculate(lambda: _outliers(method, value, y_data))
583
+
584
+ is_outlier = result['is_outlier']
585
+ x_data = op.as_numbers(original_dataset, "x")
586
+ visible_index = original_dataset.visible_df.index
587
+
588
+ result_text = f"[{original_dataset.name}] の外れ値検出結果:\n"
589
+ result_text += f" 検出方法: {method_label}\n"
590
+ result_text += f" 検出件数: {int(np.sum(is_outlier))}件 / 全{len(y_data)}件\n"
591
+
592
+ if apply_to_mask and is_outlier.any():
593
+ to_mask_indices = visible_index[is_outlier].tolist()
594
+ old_masked = list(original_dataset.masked_row_indices)
595
+ new_masked = sorted(set(old_masked) | set(to_mask_indices))
596
+ op.host.undo_stack.push(SetMaskedRowsCommand(
597
+ original_dataset, old_masked, new_masked, description=f"外れ値の自動マスク({method_label})",
598
+ ))
599
+ op.host.redraw()
600
+ result_text += f" → {len(to_mask_indices)}件をマスクに追加しました。\n"
601
+ elif apply_to_mask:
602
+ result_text += " (マスク対象の行はありませんでした)\n"
603
+ else:
604
+ result_text += " (プレビューのみ、マスクは適用していません)\n"
605
+
606
+ outlier_csv_data = pd.DataFrame({'X': x_data[is_outlier], 'Y': y_data[is_outlier]})
607
+ op.show_result_window(OUTLIER_RESULT_WINDOW, lambda: ResultDialog(
608
+ "外れ値検出完了", result_text, op.parent, csv_data=outlier_csv_data))
609
+
610
+
611
+ def batch_column_calculate(op):
612
+ """選んだデータセットすべてに同じ計算式で列を足す。データエディタの列計算と同じく Undo の対象ではない。"""
613
+ selected = op.selected_datasets(at_least=2, message="2つ以上のデータセットを選択してください。")
614
+
615
+ # 式の候補には、選んだデータセットすべてにある列だけを出す
616
+ common_columns = set(selected[0].df.columns)
617
+ for ds in selected[1:]:
618
+ common_columns &= set(ds.df.columns)
619
+
620
+ dialog = op.ask(ColumnCalculatorDialog(sorted(str(c) for c in common_columns), op.parent))
621
+ output_col, formula = dialog.get_formula()
622
+ if not output_col or not formula:
623
+ op.stop_with_warning("出力列または計算式が空です。", title="入力エラー")
624
+
625
+ succeeded, failed = [], []
626
+ for dataset in selected:
627
+ try:
628
+ dataset.df[output_col] = safe_eval_column_formula(dataset.df, formula)
629
+ dataset.invalidate_visible_df_cache()
630
+ succeeded.append(dataset.name)
631
+ except Exception as e:
632
+ failed.append(f"{dataset.name}: {e}")
633
+
634
+ op.host.refresh_ui_state()
635
+ op.host.redraw()
636
+
637
+ message = f"{len(succeeded)}件のデータセットに適用しました。"
638
+ if failed:
639
+ message += "\n\n失敗:\n" + "\n".join(failed)
640
+ op.information(message)
641
+
642
+
643
+ PROCESSING_OPERATIONS = {
644
+ "arithmetic": Operation("データセット間演算", arithmetic),
645
+ "align": Operation("X軸アライメント", align),
646
+ "mean_and_sd": Operation("平均±SD生成", mean_and_sd),
647
+ "normalize": Operation("規格化(ノーマライズ)", normalize),
648
+ "savgol_smooth": Operation("Savitzky-Golayフィルタ", savgol_smooth),
649
+ "baseline_correction": Operation("ベースライン補正", baseline_correction),
650
+ "interval_integral": Operation("区間積分", interval_integral),
651
+ "cumulative_integral": Operation("累積積分", cumulative_integral),
652
+ "split_by_column": Operation("系列に分割", split_by_column),
653
+ "resample": Operation("共通X格子へのリサンプリング/補間", resample),
654
+ "histogram_or_kde": Operation("ヒストグラム / KDE", histogram_or_kde),
655
+ "detect_duplicate_x": Operation("重複X値の検出", detect_duplicate_x),
656
+ "filter_rows": Operation("行フィルタ", filter_rows),
657
+ "detect_outliers": Operation("外れ値検出", detect_outliers),
658
+ "batch_column_calculate": Operation("バッチ列計算", batch_column_calculate),
659
+ }