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.
- graphica/Graphica.ico +0 -0
- graphica/__init__.py +1 -0
- graphica/__main__.py +111 -0
- graphica/assets/__init__.py +0 -0
- graphica/assets/icons/__init__.py +0 -0
- graphica/assets/icons/arrow-right.svg +21 -0
- graphica/assets/icons/bold.svg +20 -0
- graphica/assets/icons/calculator.svg +26 -0
- graphica/assets/icons/chart-histogram.svg +24 -0
- graphica/assets/icons/chart-line.svg +20 -0
- graphica/assets/icons/chevron-down.svg +19 -0
- graphica/assets/icons/chevron-right.svg +19 -0
- graphica/assets/icons/color-swatch.svg +22 -0
- graphica/assets/icons/column-insert-right.svg +21 -0
- graphica/assets/icons/column-remove.svg +21 -0
- graphica/assets/icons/copy.svg +20 -0
- graphica/assets/icons/download.svg +21 -0
- graphica/assets/icons/edit.svg +21 -0
- graphica/assets/icons/eye-off.svg +21 -0
- graphica/assets/icons/eye.svg +20 -0
- graphica/assets/icons/file-plus.svg +22 -0
- graphica/assets/icons/folder-plus.svg +21 -0
- graphica/assets/icons/folder.svg +19 -0
- graphica/assets/icons/highlight.svg +22 -0
- graphica/assets/icons/history.svg +20 -0
- graphica/assets/icons/italic.svg +21 -0
- graphica/assets/icons/layout-grid.svg +22 -0
- graphica/assets/icons/math-function.svg +22 -0
- graphica/assets/icons/message-2.svg +21 -0
- graphica/assets/icons/mountain.svg +20 -0
- graphica/assets/icons/palette.svg +22 -0
- graphica/assets/icons/pointer.svg +19 -0
- graphica/assets/icons/refresh.svg +20 -0
- graphica/assets/icons/row-insert-bottom.svg +21 -0
- graphica/assets/icons/row-remove.svg +21 -0
- graphica/assets/icons/search.svg +20 -0
- graphica/assets/icons/select-all.svg +35 -0
- graphica/assets/icons/subscript.svg +20 -0
- graphica/assets/icons/superscript.svg +20 -0
- graphica/assets/icons/table.svg +21 -0
- graphica/assets/icons/trash.svg +23 -0
- graphica/assets/icons/typography.svg +23 -0
- graphica/assets/icons/x.svg +20 -0
- graphica/core/__init__.py +0 -0
- graphica/core/analysis.py +1278 -0
- graphica/core/app_paths.py +35 -0
- graphica/core/axis_settings.py +128 -0
- graphica/core/caption_export.py +47 -0
- graphica/core/color_palettes.py +56 -0
- graphica/core/commands.py +226 -0
- graphica/core/cvd_simulation.py +49 -0
- graphica/core/dataset.py +443 -0
- graphica/core/diagnostics.py +105 -0
- graphica/core/excel_utils.py +58 -0
- graphica/core/fit_models.py +379 -0
- graphica/core/grid_data.py +128 -0
- graphica/core/i18n.py +44 -0
- graphica/core/json_utils.py +27 -0
- graphica/core/label_utils.py +21 -0
- graphica/core/methods_text.py +115 -0
- graphica/core/named_colors.py +126 -0
- graphica/core/plugin_api.py +515 -0
- graphica/core/plugin_context.py +167 -0
- graphica/core/plugin_install.py +82 -0
- graphica/core/plugin_manifest.py +67 -0
- graphica/core/plugin_testing.py +213 -0
- graphica/core/plugin_types.py +118 -0
- graphica/core/provenance.py +19 -0
- graphica/core/report_export.py +51 -0
- graphica/core/safe_eval.py +194 -0
- graphica/core/script_export.py +272 -0
- graphica/core/translations_en.py +530 -0
- graphica/core/unit_conversion.py +51 -0
- graphica/core/update_check.py +43 -0
- graphica/core/version.py +14 -0
- graphica/gui/__init__.py +0 -0
- graphica/gui/app_settings.py +119 -0
- graphica/gui/axis_bindings.py +173 -0
- graphica/gui/binding.py +135 -0
- graphica/gui/builders/__init__.py +1 -0
- graphica/gui/builders/axis_panel.py +431 -0
- graphica/gui/builders/canvas_area.py +176 -0
- graphica/gui/builders/common.py +120 -0
- graphica/gui/builders/dataset_panel.py +433 -0
- graphica/gui/builders/property_sections.py +208 -0
- graphica/gui/canvas.py +519 -0
- graphica/gui/color_history.py +47 -0
- graphica/gui/color_picker_widget.py +198 -0
- graphica/gui/crash_handler.py +77 -0
- graphica/gui/cvd_preview.py +23 -0
- graphica/gui/data_editor.py +761 -0
- graphica/gui/data_import_flow.py +355 -0
- graphica/gui/dataset_bindings.py +107 -0
- graphica/gui/dataset_style_icon.py +80 -0
- graphica/gui/datasets/__init__.py +1 -0
- graphica/gui/datasets/actions_menu.py +114 -0
- graphica/gui/datasets/colors.py +218 -0
- graphica/gui/datasets/fitting.py +70 -0
- graphica/gui/datasets/host.py +162 -0
- graphica/gui/datasets/operations/__init__.py +1 -0
- graphica/gui/datasets/operations/fitting.py +468 -0
- graphica/gui/datasets/operations/peaks.py +109 -0
- graphica/gui/datasets/operations/processing.py +659 -0
- graphica/gui/datasets/operations/runner.py +148 -0
- graphica/gui/datasets/operations/transfer.py +227 -0
- graphica/gui/datasets/order.py +310 -0
- graphica/gui/datasets/overlays.py +74 -0
- graphica/gui/datasets/peaks.py +21 -0
- graphica/gui/datasets/plugin_runs.py +99 -0
- graphica/gui/datasets/processing.py +68 -0
- graphica/gui/datasets/property_panel.py +538 -0
- graphica/gui/datasets/transfer.py +36 -0
- graphica/gui/detached_canvas_window.py +22 -0
- graphica/gui/dialogs/__init__.py +137 -0
- graphica/gui/dialogs/analysis.py +1322 -0
- graphica/gui/dialogs/app.py +1015 -0
- graphica/gui/dialogs/appearance.py +851 -0
- graphica/gui/dialogs/data_edit.py +577 -0
- graphica/gui/dialogs/data_import.py +679 -0
- graphica/gui/dialogs/export.py +433 -0
- graphica/gui/dock_layout.py +176 -0
- graphica/gui/export_preview_panel.py +321 -0
- graphica/gui/export_settings.py +16 -0
- graphica/gui/file_association.py +97 -0
- graphica/gui/icon_utils.py +44 -0
- graphica/gui/main_app_window.py +210 -0
- graphica/gui/main_window.py +1151 -0
- graphica/gui/mathtext_preview.py +168 -0
- graphica/gui/menu_bar.py +320 -0
- graphica/gui/minimap_widget.py +151 -0
- graphica/gui/mixins/__init__.py +0 -0
- graphica/gui/mixins/export_mixin.py +520 -0
- graphica/gui/mixins/help_mixin.py +127 -0
- graphica/gui/mixins/project_io_mixin.py +289 -0
- graphica/gui/mixins/quick_access_mixin.py +194 -0
- graphica/gui/mixins/ui_setup_mixin.py +206 -0
- graphica/gui/notify.py +61 -0
- graphica/gui/panels/__init__.py +43 -0
- graphica/gui/panels/axis_settings.py +605 -0
- graphica/gui/panels/dataset_tree.py +424 -0
- graphica/gui/plot_type_drawers.py +131 -0
- graphica/gui/plugin_context.py +119 -0
- graphica/gui/project_files.py +361 -0
- graphica/gui/provenance_panel.py +65 -0
- graphica/gui/rendering/__init__.py +1 -0
- graphica/gui/rendering/annotations.py +177 -0
- graphica/gui/rendering/appearance.py +368 -0
- graphica/gui/rendering/common.py +316 -0
- graphica/gui/rendering/data_1d.py +376 -0
- graphica/gui/rendering/data_2d.py +66 -0
- graphica/gui/residual_panel.py +62 -0
- graphica/gui/resources.py +16 -0
- graphica/gui/single_instance.py +141 -0
- graphica/gui/splash.py +83 -0
- graphica/gui/task_runner.py +41 -0
- graphica/gui/theme.py +864 -0
- graphica/gui/tools/__init__.py +56 -0
- graphica/gui/tools/annotation.py +275 -0
- graphica/gui/tools/cursor.py +259 -0
- graphica/gui/tools/layout_edit.py +285 -0
- graphica/gui/tools/manager.py +78 -0
- graphica/gui/tools/peak_placement.py +132 -0
- graphica/gui/tools/range_select.py +191 -0
- graphica/gui/tools/region_highlight.py +222 -0
- graphica/gui/tools/slice_extraction.py +200 -0
- graphica/gui/widget_translation.py +46 -0
- graphica/gui/workers.py +280 -0
- graphica/models/__init__.py +0 -0
- graphica/models/project.py +197 -0
- graphica/plugin/__init__.py +20 -0
- graphica/plugin/testing.py +57 -0
- graphica/sample_data/__init__.py +0 -0
- graphica/sample_data/cooling_curve_sample.csv +42 -0
- graphica/ui_main_window.py +581 -0
- graphica_plot-2.0.0.dist-info/METADATA +317 -0
- graphica_plot-2.0.0.dist-info/RECORD +181 -0
- graphica_plot-2.0.0.dist-info/WHEEL +5 -0
- graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
- graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
- graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
- graphica_plot-2.0.0.dist-info/top_level.txt +1 -0
graphica/gui/workers.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""データファイルの読み込み(TaskRunner で別スレッドで動かす関数)と、文字コード・区切り文字の判定。"""
|
|
2
|
+
import csv
|
|
3
|
+
import dataclasses
|
|
4
|
+
import re
|
|
5
|
+
import warnings
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
# 順に試す文字コード。utf-8-sig は BOM の有無どちらの UTF-8 も読める。latin-1 は必ず読める最後の手段。
|
|
10
|
+
# UTF-16 は入れない(cp932 や latin-1 が例外を出さずに化けた結果を返す)。BOM で別に見分ける。
|
|
11
|
+
CSV_ENCODING_FALLBACKS = ['utf-8-sig', 'cp932', 'latin-1']
|
|
12
|
+
|
|
13
|
+
# 組み込みで読める拡張子。開くダイアログ、ドラッグ&ドロップ、フォルダ一括取り込み、プレビューがすべてここを見る。
|
|
14
|
+
# 区切り文字付きテキストは CSV と同じく文字コードと区切りを判定する。.xlsx は openpyxl、.xls は xlrd。
|
|
15
|
+
DELIMITED_TEXT_EXTENSIONS = ('.csv', '.txt')
|
|
16
|
+
EXCEL_EXTENSIONS = ('.xlsx', '.xls')
|
|
17
|
+
BUILTIN_DATA_FILE_EXTENSIONS = DELIMITED_TEXT_EXTENSIONS + EXCEL_EXTENSIONS
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_delimited_text_file(file_path):
|
|
21
|
+
return bool(file_path) and file_path.lower().endswith(DELIMITED_TEXT_EXTENSIONS)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_excel_file(file_path):
|
|
25
|
+
return bool(file_path) and file_path.lower().endswith(EXCEL_EXTENSIONS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def excel_engine_for(file_path):
|
|
29
|
+
"""openpyxl は .xls を読めないので、拡張子で xlrd と使い分ける。"""
|
|
30
|
+
return 'xlrd' if file_path.lower().endswith('.xls') else 'openpyxl'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pandas_separator(delimiter):
|
|
34
|
+
"""(sep, engine)。空白は空白の連続とみなす(桁揃えの空白で空の列が大量にできないように)。正規表現は python エンジン。"""
|
|
35
|
+
if delimiter == ' ':
|
|
36
|
+
return r'\s+', 'python'
|
|
37
|
+
if len(delimiter) == 1:
|
|
38
|
+
return delimiter, 'c'
|
|
39
|
+
return delimiter, 'python'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _detect_bom_encoding(file_path):
|
|
43
|
+
"""BOM から文字コードを判定する。無ければ None。"""
|
|
44
|
+
with open(file_path, 'rb') as f:
|
|
45
|
+
head = f.read(4)
|
|
46
|
+
if head.startswith(b'\xff\xfe') or head.startswith(b'\xfe\xff'):
|
|
47
|
+
return 'utf-16'
|
|
48
|
+
if head.startswith(b'\xef\xbb\xbf'):
|
|
49
|
+
return 'utf-8-sig'
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _csv_encoding_candidates(file_path):
|
|
54
|
+
"""BOM の判定を先頭に、CSV_ENCODING_FALLBACKS を並べた候補(読み込みと判定で順序を共有する)。"""
|
|
55
|
+
bom_encoding = _detect_bom_encoding(file_path)
|
|
56
|
+
candidates = [bom_encoding] if bom_encoding else []
|
|
57
|
+
candidates += [e for e in CSV_ENCODING_FALLBACKS if e not in candidates]
|
|
58
|
+
return candidates
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect_csv_encoding(file_path):
|
|
62
|
+
"""テキストとしてデコードできる最初の文字コード。DataFrame にはしないので軽い。"""
|
|
63
|
+
last_error = None
|
|
64
|
+
for encoding in _csv_encoding_candidates(file_path):
|
|
65
|
+
try:
|
|
66
|
+
with open(file_path, encoding=encoding) as f:
|
|
67
|
+
f.read()
|
|
68
|
+
return encoding
|
|
69
|
+
except UnicodeDecodeError as e:
|
|
70
|
+
last_error = e
|
|
71
|
+
continue
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"CSVファイルの文字コードを判定できませんでした "
|
|
74
|
+
f"(試行: {', '.join(CSV_ENCODING_FALLBACKS)})。詳細: {last_error}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _sniff_delimiter_from_text(sample_text):
|
|
79
|
+
"""カンマ・タブ・セミコロン・空白のどれかを推測する。判定できなければカンマ。"""
|
|
80
|
+
if not sample_text.strip():
|
|
81
|
+
return ','
|
|
82
|
+
try:
|
|
83
|
+
dialect = csv.Sniffer().sniff(sample_text, delimiters=',\t; ')
|
|
84
|
+
return dialect.delimiter
|
|
85
|
+
except csv.Error:
|
|
86
|
+
return ','
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def detect_csv_delimiter(file_path, encoding, sample_lines=50):
|
|
90
|
+
lines = []
|
|
91
|
+
try:
|
|
92
|
+
with open(file_path, encoding=encoding, errors='replace') as f:
|
|
93
|
+
for _ in range(sample_lines):
|
|
94
|
+
line = f.readline()
|
|
95
|
+
if not line:
|
|
96
|
+
break
|
|
97
|
+
lines.append(line)
|
|
98
|
+
except OSError:
|
|
99
|
+
return ','
|
|
100
|
+
return _sniff_delimiter_from_text(''.join(lines))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def detect_clipboard_delimiter(text, sample_lines=50):
|
|
104
|
+
"""クリップボードの区切り文字(Excel からのコピーはタブ、CSV の文字列ならカンマなど)。"""
|
|
105
|
+
sample = ''.join(text.splitlines(keepends=True)[:sample_lines])
|
|
106
|
+
return _sniff_delimiter_from_text(sample)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# 測定装置の書き出し(JASCO の TXT など)は、数値の表の前後に測定条件の行が付く。そういうファイルでだけ数値の表を探す
|
|
110
|
+
NUMERIC_TABLE_DELIMITERS = ('\t', ',', ';', ' ')
|
|
111
|
+
MIN_NUMERIC_TABLE_ROWS = 3
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclasses.dataclass
|
|
115
|
+
class NumericTable:
|
|
116
|
+
first_line: int # 0 始まり、表の最初の数値の行
|
|
117
|
+
stop_line: int # 表の最後の行の次
|
|
118
|
+
delimiter: str
|
|
119
|
+
header: list | None # 表の直前の行が同じ列数の文字なら列名
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _split_numeric_row(line, delimiter):
|
|
123
|
+
"""2 列以上がすべて数値として読めれば、その列の文字列。読めなければ None。"""
|
|
124
|
+
text = line.strip()
|
|
125
|
+
fields = text.split() if delimiter == ' ' else [f.strip() for f in text.split(delimiter)]
|
|
126
|
+
if len(fields) < 2:
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
for field in fields:
|
|
130
|
+
float(field)
|
|
131
|
+
except ValueError:
|
|
132
|
+
return None
|
|
133
|
+
return fields
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def find_numeric_table(lines):
|
|
137
|
+
"""列数のそろった数値の行が最も長く続くところ。MIN_NUMERIC_TABLE_ROWS 行に満たなければ None。"""
|
|
138
|
+
best = None
|
|
139
|
+
for delimiter in NUMERIC_TABLE_DELIMITERS:
|
|
140
|
+
start, columns = None, 0
|
|
141
|
+
for index, line in enumerate([*lines, '']):
|
|
142
|
+
fields = _split_numeric_row(line, delimiter)
|
|
143
|
+
if fields is not None and start is not None and len(fields) == columns:
|
|
144
|
+
continue
|
|
145
|
+
if start is not None and (best is None or index - start > best.stop_line - best.first_line):
|
|
146
|
+
best = NumericTable(start, index, delimiter, None)
|
|
147
|
+
start, columns = (index, len(fields)) if fields is not None else (None, 0)
|
|
148
|
+
if best is None or best.stop_line - best.first_line < MIN_NUMERIC_TABLE_ROWS:
|
|
149
|
+
return None
|
|
150
|
+
if best.first_line > 0:
|
|
151
|
+
above = lines[best.first_line - 1].strip()
|
|
152
|
+
names = above.split() if best.delimiter == ' ' else [f.strip() for f in above.split(best.delimiter)]
|
|
153
|
+
column_count = len(_split_numeric_row(lines[best.first_line], best.delimiter))
|
|
154
|
+
if above and len(names) == column_count and _split_numeric_row(above, best.delimiter) is None:
|
|
155
|
+
best.header = names
|
|
156
|
+
return best
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_numeric_table(file_path, encoding):
|
|
160
|
+
"""前後の説明の行を除いた数値の表を DataFrame にする。(DataFrame, NumericTable)、見つからなければ None。"""
|
|
161
|
+
import io
|
|
162
|
+
|
|
163
|
+
with open(file_path, encoding=encoding) as f:
|
|
164
|
+
lines = f.read().splitlines()
|
|
165
|
+
table = find_numeric_table(lines)
|
|
166
|
+
if table is None:
|
|
167
|
+
return None
|
|
168
|
+
sep, engine = pandas_separator(table.delimiter)
|
|
169
|
+
body = '\n'.join(line.strip() for line in lines[table.first_line:table.stop_line])
|
|
170
|
+
df = pd.read_csv(io.StringIO(body), sep=sep, engine=engine, header=None)
|
|
171
|
+
names = table.header or [f"列{i + 1}" for i in range(df.shape[1])]
|
|
172
|
+
# 同じ列名は read_csv と同じく .1, .2 を付けて分ける
|
|
173
|
+
seen = {}
|
|
174
|
+
unique = []
|
|
175
|
+
for name in names:
|
|
176
|
+
count = seen.get(name, 0)
|
|
177
|
+
unique.append(name if count == 0 else f"{name}.{count}")
|
|
178
|
+
seen[name] = count + 1
|
|
179
|
+
df.columns = unique
|
|
180
|
+
return df, table
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def has_numeric_column(df):
|
|
184
|
+
return any(pd.api.types.is_numeric_dtype(df[column]) for column in df.columns)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# 年(4 桁の数字)を含む値だけを日付とみなす。"12:30" のような時刻だけの文字は、読んだ日の日付が補われてしまう
|
|
188
|
+
_YEAR_PATTERN = re.compile(r"\d{4}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_date_columns(df):
|
|
192
|
+
"""文字の列のうち、空でない値がすべて日付として読める列を日付型にする(Excel の日付の列と同じ扱い)。"""
|
|
193
|
+
for column in df.columns:
|
|
194
|
+
series = df[column]
|
|
195
|
+
if series.dtype != object:
|
|
196
|
+
continue
|
|
197
|
+
text = series.dropna().astype(str).str.strip()
|
|
198
|
+
text = text[text != ""]
|
|
199
|
+
if text.empty or not text.map(lambda value: _YEAR_PATTERN.search(value) is not None).all():
|
|
200
|
+
continue
|
|
201
|
+
with warnings.catch_warnings():
|
|
202
|
+
# 書式を推測できない警告と、タイムゾーンが混ざった列の将来の仕様変更の警告(その列は下で日付にしない)
|
|
203
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
204
|
+
warnings.simplefilter("ignore", FutureWarning)
|
|
205
|
+
try:
|
|
206
|
+
# 書式は値ごとに読む(先頭の値の書式に合わせると、時刻のある値と無い値が混ざった列を読めない)
|
|
207
|
+
parsed = pd.to_datetime(text, errors="coerce", format="mixed")
|
|
208
|
+
except (ValueError, TypeError):
|
|
209
|
+
continue
|
|
210
|
+
# タイムゾーンが混ざると日付型にならない(object のまま)
|
|
211
|
+
if parsed.isna().any() or not pd.api.types.is_datetime64_any_dtype(parsed):
|
|
212
|
+
continue
|
|
213
|
+
converted = pd.Series(pd.NaT, index=series.index, dtype=parsed.dtype)
|
|
214
|
+
converted.loc[parsed.index] = parsed
|
|
215
|
+
df[column] = converted
|
|
216
|
+
return df
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def read_data_file(file_path):
|
|
220
|
+
"""データファイルを DataFrame にする。プラグインがその拡張子を登録していればそちらを使う。"""
|
|
221
|
+
ext = file_path.lower().split('.')[-1]
|
|
222
|
+
|
|
223
|
+
from graphica.core.plugin_api import get_plugin_api
|
|
224
|
+
from graphica.core.plugin_types import PluginExecutionError
|
|
225
|
+
api = get_plugin_api()
|
|
226
|
+
importer = api.get_importer_for_extension(ext) if api is not None else None
|
|
227
|
+
if importer is not None:
|
|
228
|
+
try:
|
|
229
|
+
result = importer.loader(file_path)
|
|
230
|
+
except Exception as e:
|
|
231
|
+
raise PluginExecutionError(importer.name, f"「{file_path}」の読み込みに失敗しました: {e}") from e
|
|
232
|
+
if not isinstance(result, pd.DataFrame):
|
|
233
|
+
raise PluginExecutionError(
|
|
234
|
+
importer.name,
|
|
235
|
+
"現在サポートされているのは単一のDataFrameを返すインポーターのみです"
|
|
236
|
+
"(複数シートを返す形式は未対応です)。"
|
|
237
|
+
)
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
if is_delimited_text_file(file_path):
|
|
241
|
+
# 区切り文字もここで推測する(カンマで読むとタブ区切りが1列に潰れ、「2列以上必要」で弾かれる)
|
|
242
|
+
last_error = None
|
|
243
|
+
for encoding in _csv_encoding_candidates(file_path):
|
|
244
|
+
try:
|
|
245
|
+
sep, engine = pandas_separator(detect_csv_delimiter(file_path, encoding))
|
|
246
|
+
df = pd.read_csv(file_path, header=0, encoding=encoding, sep=sep, engine=engine)
|
|
247
|
+
except UnicodeDecodeError as e:
|
|
248
|
+
last_error = e
|
|
249
|
+
continue
|
|
250
|
+
except pd.errors.ParserError as e:
|
|
251
|
+
try:
|
|
252
|
+
found = read_numeric_table(file_path, encoding)
|
|
253
|
+
except UnicodeDecodeError:
|
|
254
|
+
found = None
|
|
255
|
+
if found is not None:
|
|
256
|
+
return parse_date_columns(found[0])
|
|
257
|
+
last_error = e
|
|
258
|
+
continue
|
|
259
|
+
# 普通に読めて数値の列があるファイルは今までどおり。どの列も数値にならないときだけ、前後の説明の行を疑う
|
|
260
|
+
if not has_numeric_column(df):
|
|
261
|
+
found = read_numeric_table(file_path, encoding)
|
|
262
|
+
if found is not None:
|
|
263
|
+
return parse_date_columns(found[0])
|
|
264
|
+
return parse_date_columns(df)
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"テキストファイルの文字コードを判定できませんでした "
|
|
267
|
+
f"(試行: {', '.join(CSV_ENCODING_FALLBACKS)})。詳細: {last_error}"
|
|
268
|
+
)
|
|
269
|
+
elif is_excel_file(file_path):
|
|
270
|
+
return pd.read_excel(file_path, engine=excel_engine_for(file_path))
|
|
271
|
+
else:
|
|
272
|
+
raise ValueError(f"未対応のファイル形式です: {ext}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def load_data_file_task(file_path, report_progress=None, is_cancelled=None):
|
|
276
|
+
"""TaskRunner 用。読み込みは中断できないので、report_progress と is_cancelled は受け取るだけ。"""
|
|
277
|
+
df = read_data_file(file_path)
|
|
278
|
+
if len(df.columns) < 2:
|
|
279
|
+
raise ValueError("データには少なくとも2列必要です。")
|
|
280
|
+
return df
|
|
File without changes
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from PySide6.QtCore import QObject, Signal
|
|
7
|
+
|
|
8
|
+
from graphica.core.dataset import Dataset
|
|
9
|
+
from graphica.core.json_utils import GraphicaJSONEncoder
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# 保存は .gra。.graphica(以前の拡張子)も中身は同じ JSON なので読み書きできる
|
|
14
|
+
PROJECT_FILE_EXTENSION = '.gra'
|
|
15
|
+
PROJECT_FILE_EXTENSIONS = ('.gra', '.graphica')
|
|
16
|
+
LEGACY_PICKLE_EXTENSION = '.pkl'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# format_version が無いファイルは 0。構造を変えたら上げて、前の版からの変換を _MIGRATIONS に足す
|
|
20
|
+
CURRENT_FORMAT_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _migrate_v0_to_v1(data):
|
|
24
|
+
"""0 から 1 は format_version を足しただけで、構造は同じ。"""
|
|
25
|
+
return data
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# from_version -> (from_version + 1) の形にする関数
|
|
29
|
+
_MIGRATIONS = {
|
|
30
|
+
0: _migrate_v0_to_v1,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _migrate_project_data(data):
|
|
35
|
+
"""CURRENT_FORMAT_VERSION まで順に変換する。新しい版のファイルはエラーにする(黙って半分だけ読まない)。"""
|
|
36
|
+
version = data.get('format_version', 0)
|
|
37
|
+
if version > CURRENT_FORMAT_VERSION:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"このプロジェクトファイルはバージョン{version}で保存されていますが、"
|
|
40
|
+
f"このアプリケーションが対応しているのはバージョン{CURRENT_FORMAT_VERSION}までです。"
|
|
41
|
+
"アプリケーションを最新版に更新してください。"
|
|
42
|
+
)
|
|
43
|
+
while version < CURRENT_FORMAT_VERSION:
|
|
44
|
+
migrate = _MIGRATIONS.get(version)
|
|
45
|
+
if migrate is None:
|
|
46
|
+
raise ValueError(f"バージョン{version}からの移行手順が見つかりません。")
|
|
47
|
+
data = migrate(data)
|
|
48
|
+
version += 1
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProjectModel(QObject):
|
|
53
|
+
"""1つの文書(データセット、軸ごとの設定、レイアウト)。
|
|
54
|
+
|
|
55
|
+
既存の変更箇所は PlotterApp._update_plot() を直接呼ぶ。changed は、そのメソッドを知らない新しい経路が
|
|
56
|
+
再描画を頼むためのもので、今は誰も発行しない。
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
changed = Signal()
|
|
60
|
+
|
|
61
|
+
def __init__(self):
|
|
62
|
+
super().__init__()
|
|
63
|
+
# 最後に保存・読み込みしたパス(オートセーブでも変わる。上書き保存先は PlotterApp._current_project_path)
|
|
64
|
+
self.current_filepath = ""
|
|
65
|
+
|
|
66
|
+
self.datasets = []
|
|
67
|
+
# {'name', 'children': [...]} の入れ子。子はフォルダか {'dataset': Dataset}。name='' のルートは表示しない
|
|
68
|
+
self.dataset_group_tree = {'name': '', 'children': []}
|
|
69
|
+
self.all_plot_settings = [] # 軸ごとの設定(core/axis_settings.py)
|
|
70
|
+
self.active_axis_index = 0
|
|
71
|
+
|
|
72
|
+
self.layout_rows = 1
|
|
73
|
+
self.layout_cols = 1
|
|
74
|
+
# 'grid' か 'free'。'free' では all_plot_settings の数がサブプロットの数で、各設定の
|
|
75
|
+
# 'free_rect' に (left, bottom, width, height) を 0〜1 で持つ
|
|
76
|
+
self.layout_mode = 'grid'
|
|
77
|
+
|
|
78
|
+
# (a)(b)(c)… は描くときに並びから決めるので、文字は保存しない
|
|
79
|
+
self.panel_labels_enabled = False
|
|
80
|
+
|
|
81
|
+
# 同じ行(sharex)・同じ列(sharey)のサブプロットの軸を束ね、内側の目盛りラベルを隠す。グリッドのときだけ
|
|
82
|
+
self.share_x_axis = False
|
|
83
|
+
self.share_y_axis = False
|
|
84
|
+
|
|
85
|
+
def notify_changed(self):
|
|
86
|
+
self.changed.emit()
|
|
87
|
+
|
|
88
|
+
def save_project(self, filepath):
|
|
89
|
+
"""JSON で保存する(開いてもコードが実行されない)。拡張子は .gra か .graphica。"""
|
|
90
|
+
ext = os.path.splitext(filepath)[1].lower()
|
|
91
|
+
if ext not in PROJECT_FILE_EXTENSIONS:
|
|
92
|
+
raise ValueError(f"サポートされていない拡張子です: {ext}")
|
|
93
|
+
self._save_project_json(filepath)
|
|
94
|
+
self.current_filepath = filepath
|
|
95
|
+
|
|
96
|
+
def load_project(self, filepath):
|
|
97
|
+
if not os.path.exists(filepath):
|
|
98
|
+
raise FileNotFoundError(f"ファイルが見つかりません: {filepath}")
|
|
99
|
+
|
|
100
|
+
ext = os.path.splitext(filepath)[1].lower()
|
|
101
|
+
if ext == LEGACY_PICKLE_EXTENSION:
|
|
102
|
+
raise ValueError("旧形式(.pkl)のプロジェクトには対応していません。")
|
|
103
|
+
if ext not in PROJECT_FILE_EXTENSIONS:
|
|
104
|
+
raise ValueError(f"サポートされていない拡張子です: {ext}")
|
|
105
|
+
self._load_project_json(filepath)
|
|
106
|
+
self.current_filepath = filepath
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _tree_to_json(node):
|
|
110
|
+
"""Dataset の参照を dataset_id の文字列にする(JSON にするため)。"""
|
|
111
|
+
if 'dataset' in node:
|
|
112
|
+
return {'dataset_id': node['dataset'].dataset_id}
|
|
113
|
+
return {
|
|
114
|
+
'name': node.get('name', ''),
|
|
115
|
+
'children': [ProjectModel._tree_to_json(child) for child in node.get('children', [])],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _tree_from_json(node, dataset_map):
|
|
120
|
+
"""_tree_to_json() の逆。見つからない ID は警告して除く(壊れたファイルでも読めるように)。"""
|
|
121
|
+
if 'dataset_id' in node:
|
|
122
|
+
ds = dataset_map.get(node['dataset_id'])
|
|
123
|
+
if ds is None:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"dataset_group_tree内に存在しないdataset_idがあるため、"
|
|
126
|
+
"このリーフをスキップします: %s", node['dataset_id']
|
|
127
|
+
)
|
|
128
|
+
return None
|
|
129
|
+
return {'dataset': ds}
|
|
130
|
+
|
|
131
|
+
children = []
|
|
132
|
+
for child in node.get('children', []):
|
|
133
|
+
converted = ProjectModel._tree_from_json(child, dataset_map)
|
|
134
|
+
if converted is not None:
|
|
135
|
+
children.append(converted)
|
|
136
|
+
return {'name': node.get('name', ''), 'children': children}
|
|
137
|
+
|
|
138
|
+
def content_fingerprint(self):
|
|
139
|
+
"""保存すると書き出される内容のハッシュ(未保存の変更の判定)。
|
|
140
|
+
|
|
141
|
+
Undo の clean 状態では判定しない(軸の設定・列の計算・フォルダ操作は Undo の対象外)。
|
|
142
|
+
呼ぶ前に UI にしか無い状態を反映しておくこと(PlotterApp._sync_project_from_ui)。
|
|
143
|
+
"""
|
|
144
|
+
payload = self._json_payload()
|
|
145
|
+
# 編集対象のサブプロットを切り替えただけで保存の確認が出ないように
|
|
146
|
+
payload.pop('active_axis_index', None)
|
|
147
|
+
text = json.dumps(payload, cls=GraphicaJSONEncoder, sort_keys=True, ensure_ascii=False)
|
|
148
|
+
return hashlib.sha256(text.encode('utf-8')).hexdigest()
|
|
149
|
+
|
|
150
|
+
def _json_payload(self):
|
|
151
|
+
""".graphica に書き出す辞書(保存と content_fingerprint で共有)。"""
|
|
152
|
+
return {
|
|
153
|
+
'format_version': CURRENT_FORMAT_VERSION,
|
|
154
|
+
'datasets': [ds.to_dict() for ds in self.datasets],
|
|
155
|
+
'dataset_group_tree': self._tree_to_json(self.dataset_group_tree),
|
|
156
|
+
'all_plot_settings': self.all_plot_settings,
|
|
157
|
+
'active_axis_index': self.active_axis_index,
|
|
158
|
+
'layout_rows': self.layout_rows,
|
|
159
|
+
'layout_cols': self.layout_cols,
|
|
160
|
+
'layout_mode': self.layout_mode,
|
|
161
|
+
'panel_labels_enabled': self.panel_labels_enabled,
|
|
162
|
+
'share_x_axis': self.share_x_axis,
|
|
163
|
+
'share_y_axis': self.share_y_axis,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def _save_project_json(self, filepath):
|
|
167
|
+
data = self._json_payload()
|
|
168
|
+
# 名前に日本語が多いので \uXXXX にせず読める形で残す
|
|
169
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
170
|
+
json.dump(data, f, cls=GraphicaJSONEncoder, indent=2, ensure_ascii=False)
|
|
171
|
+
|
|
172
|
+
def _load_project_json(self, filepath):
|
|
173
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
174
|
+
data = json.load(f)
|
|
175
|
+
|
|
176
|
+
data = _migrate_project_data(data)
|
|
177
|
+
|
|
178
|
+
self.datasets = [Dataset.from_dict(d) for d in data.get('datasets', [])]
|
|
179
|
+
dataset_map = {ds.dataset_id: ds for ds in self.datasets}
|
|
180
|
+
|
|
181
|
+
tree_data = data.get('dataset_group_tree')
|
|
182
|
+
if tree_data:
|
|
183
|
+
self.dataset_group_tree = self._tree_from_json(tree_data, dataset_map)
|
|
184
|
+
else:
|
|
185
|
+
# キーが無ければ、.pkl と同じく全部をルートに置く
|
|
186
|
+
self.dataset_group_tree = {
|
|
187
|
+
'name': '', 'children': [{'dataset': ds} for ds in self.datasets]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
self.all_plot_settings = data.get('all_plot_settings', [])
|
|
191
|
+
self.active_axis_index = data.get('active_axis_index', 0)
|
|
192
|
+
self.layout_rows = data.get('layout_rows', 1)
|
|
193
|
+
self.layout_cols = data.get('layout_cols', 1)
|
|
194
|
+
self.layout_mode = data.get('layout_mode', 'grid')
|
|
195
|
+
self.panel_labels_enabled = data.get('panel_labels_enabled', False)
|
|
196
|
+
self.share_x_axis = data.get('share_x_axis', False)
|
|
197
|
+
self.share_y_axis = data.get('share_y_axis', False)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
プラグイン向けの公開の入口(プラグイン API 2.0)。
|
|
3
|
+
|
|
4
|
+
プラグインが本体から import してよいのは、ここと graphica.plugin.testing だけ。
|
|
5
|
+
本体の内部(core / gui / models)は予告なく動くが、ここの名前はプラグイン API の版で守る。
|
|
6
|
+
"""
|
|
7
|
+
from graphica.core.dataset import Dataset
|
|
8
|
+
from graphica.core.plugin_api import GraphicaPluginAPI
|
|
9
|
+
from graphica.core.plugin_context import PluginContext
|
|
10
|
+
from graphica.core.plugin_manifest import PLUGIN_API_VERSION
|
|
11
|
+
from graphica.core.plugin_types import AnalysisResult, PluginExecutionError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PLUGIN_API_VERSION",
|
|
15
|
+
"AnalysisResult",
|
|
16
|
+
"Dataset",
|
|
17
|
+
"GraphicaPluginAPI",
|
|
18
|
+
"PluginContext",
|
|
19
|
+
"PluginExecutionError",
|
|
20
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
プラグインのテスト用の公開の入口。Graphica を起動せずに試せる。
|
|
3
|
+
|
|
4
|
+
from graphica.plugin.testing import FakeGraphicaPluginAPI, FakePluginContext, load_plugin_like_graphica
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from graphica.core.plugin_api import GraphicaPluginAPI, PluginManager
|
|
11
|
+
from graphica.core.plugin_install import PluginInstallError, install_plugin_zip
|
|
12
|
+
from graphica.core.plugin_testing import FakeGraphicaPluginAPI, FakePluginContext
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"FakeGraphicaPluginAPI",
|
|
17
|
+
"FakePluginContext",
|
|
18
|
+
"PluginInstallError",
|
|
19
|
+
"install_zip_like_graphica",
|
|
20
|
+
"load_plugin_like_graphica",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_plugin_like_graphica(plugin_folder: str, work_dir: str | None = None) -> tuple[GraphicaPluginAPI, dict[str, Any]]:
|
|
25
|
+
"""
|
|
26
|
+
本体と同じ経路(manifest の検証 → import → register(api))でプラグインを読み込む。
|
|
27
|
+
相対 import の誤りや api_version の不一致など、偽物の API では見つからない問題を確かめる。
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
plugin_folder (str): plugin.json と __init__.py のあるフォルダ。
|
|
31
|
+
work_dir (str | None): 写しを置く場所(pytest の tmp_path など)。省略時は一時フォルダ。
|
|
32
|
+
Returns:
|
|
33
|
+
tuple[GraphicaPluginAPI, dict]: 登録を受けた API と、読み込み結果
|
|
34
|
+
({"name", "info", "error", "disabled"}。成功なら error は None)。
|
|
35
|
+
フック単位の失敗は api.registration_errors にある。
|
|
36
|
+
"""
|
|
37
|
+
plugin_folder = os.path.normpath(plugin_folder)
|
|
38
|
+
name = os.path.basename(plugin_folder)
|
|
39
|
+
# 本体は探索フォルダの中のサブフォルダをすべてプラグインとみなすので、対象だけを写した場所で読む。
|
|
40
|
+
plugins_dir = tempfile.mkdtemp(prefix="graphica_plugins_", dir=work_dir)
|
|
41
|
+
shutil.copytree(plugin_folder, os.path.join(plugins_dir, name),
|
|
42
|
+
ignore=shutil.ignore_patterns("__pycache__"))
|
|
43
|
+
api = GraphicaPluginAPI()
|
|
44
|
+
records = PluginManager(plugins_dir).load_all(api)
|
|
45
|
+
return api, records[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def install_zip_like_graphica(zip_path: str, target_dir: str) -> str:
|
|
49
|
+
"""
|
|
50
|
+
本体の「プラグインをインストール」と同じ処理で zip を target_dir に展開する。
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
str: インストールされたプラグインのフォルダ名。
|
|
54
|
+
Raises:
|
|
55
|
+
PluginInstallError: zip の中身が本体の期待する形でない。
|
|
56
|
+
"""
|
|
57
|
+
return install_plugin_zip(zip_path, target_dir=target_dir)
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
時間_s,温度_degC
|
|
2
|
+
0.0,90.18
|
|
3
|
+
0.5,83.78
|
|
4
|
+
1.0,79.74
|
|
5
|
+
1.5,75.18
|
|
6
|
+
2.0,69.18
|
|
7
|
+
2.5,65.66
|
|
8
|
+
3.0,62.96
|
|
9
|
+
3.5,59.43
|
|
10
|
+
4.0,56.63
|
|
11
|
+
4.5,53.4
|
|
12
|
+
5.0,51.95
|
|
13
|
+
5.5,49.62
|
|
14
|
+
6.0,47.11
|
|
15
|
+
6.5,45.85
|
|
16
|
+
7.0,43.72
|
|
17
|
+
7.5,41.34
|
|
18
|
+
8.0,40.62
|
|
19
|
+
8.5,38.5
|
|
20
|
+
9.0,38.39
|
|
21
|
+
9.5,36.73
|
|
22
|
+
10.0,35.63
|
|
23
|
+
10.5,34.41
|
|
24
|
+
11.0,34.71
|
|
25
|
+
11.5,33.11
|
|
26
|
+
12.0,32.24
|
|
27
|
+
12.5,31.64
|
|
28
|
+
13.0,31.58
|
|
29
|
+
13.5,30.94
|
|
30
|
+
14.0,30.48
|
|
31
|
+
14.5,30.04
|
|
32
|
+
15.0,30.65
|
|
33
|
+
15.5,28.75
|
|
34
|
+
16.0,28.34
|
|
35
|
+
16.5,27.85
|
|
36
|
+
17.0,28.42
|
|
37
|
+
17.5,28.46
|
|
38
|
+
18.0,27.48
|
|
39
|
+
18.5,26.82
|
|
40
|
+
19.0,26.63
|
|
41
|
+
19.5,27.33
|
|
42
|
+
20.0,27.22
|