Coverage for graphica/core/dataset.py: 96.2%
298 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1import json
2import uuid
3import warnings
4import numpy as np
5import pandas as pd
6from dataclasses import dataclass, field, fields, MISSING
7from typing import Any
9from graphica.core.marker_style import DEFAULT_MARKER_EDGE_WIDTH, MARKER_EDGE_SAME, MARKER_FILL_SOLID
11# 線種は短い記号('-')とコンボの表示名('solid')の両方で保存されている。保存値は書き換えず
12# (既存のプロジェクトのため)、表示と比較の直前に揃える。
13LINESTYLE_NAMES = ('solid', 'dashed', 'dotted', 'dashdot')
14_LINESTYLE_ALIASES = {
15 '-': 'solid', 'solid': 'solid',
16 '--': 'dashed', 'dashed': 'dashed',
17 ':': 'dotted', 'dotted': 'dotted',
18 '-.': 'dashdot', 'dashdot': 'dashdot',
19}
22def linestyle_name(value: Any) -> str | None:
23 """プロパティ欄の表示名に揃える。線を描かない値や未知の値は None。"""
24 if value is None:
25 return None
26 return _LINESTYLE_ALIASES.get(str(value).strip().lower())
28# 列名を参照するフィールド。列名を変えたり文字列にそろえたりするときは、これらも一緒に変える
29COLUMN_REFERENCE_FIELDS = (
30 'x_col_name', 'y_col_name', 'x_err_col_name', 'y_err_col_name', 'point_label_col_name', 'z_col_name',
31)
34def column_key(column: Any) -> str:
35 """列名の文字列。JSON が辞書のキーを文字列にするのと同じ形(1.5 → '1.5'、3 → '3')。
37 Excel の数値の見出しなどは数値の列名のまま読まれるが、保存した JSON では辞書のキーが文字列になる。
38 """
39 if isinstance(column, str):
40 return column
41 try:
42 return str(next(iter(json.loads(json.dumps({column: None})))))
43 except TypeError:
44 # numpy の整数など、json がキーにできない型
45 return str(column)
48def with_string_columns(df: Any) -> Any:
49 """列名をすべて文字列にした DataFrame。もとから文字列ならそのものを返す(呼び出し元の表は変えない)。"""
50 if all(isinstance(c, str) for c in df.columns):
51 return df
52 renamed = df.copy(deep=False)
53 renamed.columns = [column_key(c) for c in df.columns]
54 return renamed
57# z_col_name の値で点を配色する散布図。値は保存されるので変えない(変えると既存のプロジェクトが Line に戻る)。
58# 種類の欄の幅は最長の項目で決まり、長くするとドックに横スクロールバーが出るので 15 文字に収めている。
59COLOR_BY_COLUMN_PLOT_TYPE = 'Z-Color Scatter'
61# ウォーターフォールのオフセットの決め方(waterfall_offset_mode の値。保存されるので変えない)
62# 'step': 段の番号 × オフセット(段ごとに等間隔)。'absolute': その系列をオフセットの分だけそのままずらす
63WATERFALL_OFFSET_STEP = 'step'
64WATERFALL_OFFSET_ABSOLUTE = 'absolute'
67@dataclass
68class Dataset:
69 """1つの系列のデータと見た目。"""
71 name: str # 凡例に出る名前
72 df: pd.DataFrame
73 x_col_name: str
74 y_col_name: str
77 def __setattr__(self, name: str, value: Any) -> None:
78 """df と masked_row_indices の再代入で visible_df のキャッシュを捨てる。
80 df をその場で書き換えたとき(dataset.df[col] = ...)はここを通らないので、invalidate_visible_df_cache() を呼ぶ。
81 """
82 object.__setattr__(self, name, value)
83 if name in ('df', 'masked_row_indices'):
84 self.invalidate_visible_df_cache()
86 def invalidate_visible_df_cache(self) -> None:
87 """df をその場で書き換えた後に呼ぶ(再代入なら自動で捨てられる)。"""
88 self.__dict__['_version'] = self.__dict__.get('_version', 0) + 1
90 @property
91 def visible_df(self) -> pd.DataFrame:
92 """masked_row_indices の行を除いた DataFrame。x_data などはすべてここを通る。
94 1回の描画で x_data / y_data / 誤差が別々に呼ぶので、版が同じ間はキャッシュを使う。
95 """
96 version = self.__dict__.get('_version', 0)
97 if self.__dict__.get('_visible_df_cache_version') != version:
98 if not self.masked_row_indices:
99 cache = self.df
100 else:
101 cache = self.df[~self.df.index.isin(self.masked_row_indices)]
102 self.__dict__['_visible_df_cache'] = cache
103 self.__dict__['_visible_df_cache_version'] = version
104 return self.__dict__['_visible_df_cache']
106 @property
107 def z_grid(self) -> dict[str, Any] | None:
108 """data_kind='2d_grid' の格子({'x_grid', 'y_grid', 'z_grid', 'is_regular'})。それ以外は None。
110 散在データの補間は重いのでキャッシュする。列名や補間の設定は版番号を上げないので、キャッシュのキーに含める。
111 """
112 if self.data_kind != '2d_grid' or not self.z_col_name:
113 return None
115 from graphica.core.grid_data import compute_z_grid, GridDataError
117 resolution_key = tuple(self.grid_resolution) if self.grid_resolution else None
118 version = self.__dict__.get('_version', 0)
119 cache_key = (
120 version, self.x_col_name, self.y_col_name, self.z_col_name,
121 self.grid_interp_method, resolution_key,
122 )
123 if self.__dict__.get('_z_grid_cache_key') != cache_key:
124 df = self.visible_df
125 if self.z_col_name not in df.columns:
126 grid = None
127 else:
128 try:
129 grid = compute_z_grid(
130 df[self.x_col_name].values, df[self.y_col_name].values,
131 df[self.z_col_name].values,
132 interp_method=self.grid_interp_method, resolution=resolution_key,
133 )
134 except GridDataError:
135 grid = None
136 self.__dict__['_z_grid_cache'] = grid
137 self.__dict__['_z_grid_cache_key'] = cache_key
138 return self.__dict__['_z_grid_cache']
140 @property
141 def x_data(self) -> np.ndarray:
142 """X の列の値(マスクした行を除く)。"""
143 return self.visible_df[self.x_col_name].values
145 @property
146 def y_data(self) -> np.ndarray:
147 """Y の列の値(マスクした行を除く)。"""
148 return self.visible_df[self.y_col_name].values
150 @property
151 def x_err_data(self) -> np.ndarray | None:
152 """X の誤差の列の値。列が未設定なら None(エラーバーなし)。x_data と長さが揃う。"""
153 if self.x_err_col_name and self.x_err_col_name in self.df.columns: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 return self.visible_df[self.x_err_col_name].values
155 return None
157 @property
158 def z_data(self) -> np.ndarray | None:
159 """Z の列の値。未設定か列が無ければ None。
161 2D マップと 'Z-Color Scatter' の色が使う。visible_df を通すので、マスクしても点と色がずれない。
162 """
163 if self.z_col_name and self.z_col_name in self.df.columns:
164 return self.visible_df[self.z_col_name].values
165 return None
167 @property
168 def y_err_data(self) -> np.ndarray | None:
169 """Y の誤差の列の値。x_err_data と同じ。"""
170 if self.y_err_col_name and self.y_err_col_name in self.df.columns:
171 return self.visible_df[self.y_err_col_name].values
172 return None
174 # 'Line', 'Scatter', 'Line+Scatter', 'Area', 'Bar', 'Step', 'Density Scatter', 'Z-Color Scatter'、またはプラグインの種類
175 plot_type: str = 'Line'
176 color: str = '#1f77b4'
177 linestyle: str = '-'
178 linewidth: float = 1.5
179 marker: str = 'o'
180 markersize: float = 6.0
181 # 'solid'(系列の色で塗る)/ 'hollow'(白抜き)/ 'color'(marker_face_color で塗る)。core/marker_style.py
182 marker_fill: str = MARKER_FILL_SOLID
183 marker_face_color: str = '#ffffff'
184 # 'same'(系列の色)/ 'color'(marker_edge_color)
185 marker_edge: str = MARKER_EDGE_SAME
186 marker_edge_color: str = '#000000'
187 marker_edge_width: float = DEFAULT_MARKER_EDGE_WIDTH
188 smoothing: bool = False
189 # 'cubic_spline'(200点に補間)/ 'moving_average' / 'median' / 'gaussian'(点の数はそのまま)
190 smoothing_method: str = 'cubic_spline'
191 alpha: float = 1.0
192 # 凡例に出す文字。空ならデータセット名(リストの名前を変えずに凡例だけ変えられる)
193 legend_label: str = ""
194 hide_from_legend: bool = False
196 # 線の色を color から gradient_color2 へ変える。'fill' と 'both' は Area の塗りにも効く
197 gradient_enabled: bool = False
198 gradient_color2: str = '#ffffff'
199 gradient_target: str = 'line' # 'line' / 'fill' / 'both'
201 # ウォーターフォール(積み重ね)。種類とは独立で、同じサブプロットの有効な系列を順に offset ずつずらして重ねる
202 waterfall_enabled: bool = False
203 waterfall_offset_x: float = 0.0
204 waterfall_offset_y: float = 1.0
205 waterfall_offset_mode: str = WATERFALL_OFFSET_STEP
206 # 手前の系列の下を背景色で塗り、奥の系列を隠す
207 waterfall_occlusion_enabled: bool = True
209 # 奥の系列ほど Y をわずかに縮めて奥行きを出す
210 waterfall_depth_shrink_enabled: bool = False
211 # 1段あたりの縮小率(下限は canvas の WATERFALL_DEPTH_SHRINK_MIN_SCALE)
212 waterfall_depth_shrink_ratio: float = 0.03
214 show_point_labels: bool = False
215 # None なら Y の値をラベルにする
216 point_label_col_name: str | None = field(default=None)
218 # None ならその軸のエラーバーは描かない
219 x_err_col_name: str | None = field(default=None)
220 y_err_col_name: str | None = field(default=None)
222 # 'bar' / 'band' / 'both'
223 error_display: str = 'bar'
225 # 描画・フィット・解析から除く行。位置ではなく df.index のラベル
226 masked_row_indices: list = field(default_factory=list)
228 fit_info: str | None = field(default=None) # 表示用のフィット結果の文字列
230 # フィット結果(fit_type, params, param_errors, covariance, r_squared, residuals など)。
231 # pickle と JSON の両方で往復できるよう、Python の素の型だけにする。
232 fit_result: dict | None = field(default=None)
234 # "confidence" / "prediction" / None。df の 'y_lower' / 'y_upper' 列を帯として描く
235 fit_band_display: str | None = field(default=None)
237 # プラグインの処理・解析が作った系列なら、そのプラグイン名
238 source_plugin: str | None = field(default=None)
240 # 読み込んだファイルの絶対パス(「再読み込み」用)。ファイルから作っていない系列は None。
241 # source_sheet は Excel のシート名(CSV と単一シートでは None)
242 source_file: str | None = field(default=None)
243 source_sheet: str | None = field(default=None)
245 # 欠損値の描き方。描くときだけ効き、データは変えない。
246 # 'gap'(線を切る)/ 'ffill'(前の値で埋める)/ 'drop'(除いてつなぐ)
247 nan_policy: str = field(default='gap')
249 # この系列を作った直近1回の操作({operation, params, source_dataset_ids, source_dataset_names, timestamp})。
250 # 全体の履歴は source_dataset_ids を辿って組み立てる。読み込んだデータは None
251 provenance: dict | None = field(default=None)
253 use_secondary_y: bool = field(default=False)
254 subplot_target: int = field(default=0) # 描画先のサブプロット(0始まり)
256 # '2d_grid' なら df は x/y/z の長形式で、z_grid が格子を組み立てる
257 data_kind: str = field(default='1d')
258 z_col_name: str | None = field(default=None)
259 # 散在データの補間: 'linear' / 'cubic' / 'nearest'
260 grid_interp_method: str = field(default='linear')
261 # [nx, ny]、None なら自動。JSON で tuple が list になるので最初から list
262 grid_resolution: list | None = field(default=None)
263 # vmin / vmax が None ならデータの最小・最大
264 colormap: str = field(default='viridis')
265 vmin: float | None = field(default=None)
266 vmax: float | None = field(default=None)
268 # 'heatmap' / 'contour' / 'contour_filled' / 'heatmap_contour'
269 map_display_mode: str = field(default='heatmap')
270 contour_levels: int = field(default=10)
272 # False なら描画とエクスポートから外す(系列は残る)
273 visible: bool = field(default=True)
275 artist: object = field(default=None, repr=False)
277 # 名前に依らずに系列を特定する ID
278 dataset_id: str = field(default_factory=lambda: uuid.uuid4().hex, repr=False)
280 # df はこのクラスのメソッドで変える(Undo のコマンドがこれを呼ぶ)
282 def set_cell(self, row_idx: Any, col_name: str, value: Any) -> None:
283 # 列の型に入らない値(bool 列の NaN、日時列の文字列など)は、列を object 型に
284 # してから入れる。pandas 3 は暗黙の型変換をやめて例外を出す。
285 with warnings.catch_warnings():
286 warnings.simplefilter("error", FutureWarning)
287 try:
288 self.df.loc[row_idx, col_name] = value
289 except (FutureWarning, TypeError):
290 self.df[col_name] = self.df[col_name].astype(object)
291 self.df.loc[row_idx, col_name] = value
292 self.invalidate_visible_df_cache()
294 def add_row(self) -> None:
295 """NaN の行を末尾に足す。
297 全体を振り直すと delete_rows が残した欠番(restore_rows に要る)が消えるので、新しいラベルを1つだけ割り当てる。
298 """
299 new_index = (self.df.index.max() + 1) if len(self.df) > 0 else 0
300 new_row = pd.Series([np.nan] * len(self.df.columns), index=self.df.columns, name=new_index)
301 self.df = pd.concat([self.df, new_row.to_frame().T])
303 def delete_last_row(self) -> None:
304 """末尾の行を削除する(add_row の取り消し用)。"""
305 if len(self.df) > 0:
306 self.df = self.df.drop(self.df.index[-1])
308 def delete_rows(self, row_indices: list[Any]) -> None:
309 """行を削除する。index は振り直さない(restore_rows が元のラベルで戻すため)。
311 削除した行のラベルは masked_row_indices からも消す(残すと、同じラベルで足した新しい行が隠れる)。
312 """
313 self.df = self.df.drop(row_indices)
314 if self.masked_row_indices:
315 deleted_set = set(row_indices)
316 remaining_mask = [idx for idx in self.masked_row_indices if idx not in deleted_set]
317 if len(remaining_mask) != len(self.masked_row_indices):
318 self.masked_row_indices = remaining_mask
320 def restore_rows(self, deleted_data: pd.DataFrame) -> None:
321 """delete_rows で消した行を元のラベルで戻す。
323 振り直すと、ほかの削除で空いた欠番がずれ、後続の Undo が別の行を触る。
324 """
325 restored_df = pd.concat([self.df, deleted_data])
326 self.df = restored_df.sort_index()
328 def is_column_in_use(self, col_name: str) -> bool:
329 """X/Y、または誤差の列として使っているか。"""
330 return col_name in (self.x_col_name, self.y_col_name, self.x_err_col_name, self.y_err_col_name)
332 def add_column(self, col_name: str) -> None:
333 if col_name not in self.df.columns:
334 self.df[col_name] = np.nan
335 self.invalidate_visible_df_cache()
337 def remove_column(self, col_name: str) -> None:
338 if col_name in self.df.columns:
339 self.df = self.df.drop(columns=[col_name])
341 def rename_column(self, old_name: str, new_name: str) -> None:
342 """列名を変える。X/Y・誤差・点のラベルの列として使っていれば、そちらも新しい名前にする。"""
343 if old_name not in self.df.columns or old_name == new_name:
344 return
345 self.df = self.df.rename(columns={old_name: new_name})
346 for field_name in COLUMN_REFERENCE_FIELDS:
347 if getattr(self, field_name) == old_name:
348 setattr(self, field_name, new_name)
350 def restore_column(self, col_name: str, column_data: pd.Series) -> None:
351 """remove_column で消した列を末尾に戻す。"""
352 if col_name not in self.df.columns: 352 ↛ exitline 352 didn't return from function 'restore_column' because the condition on line 352 was always true
353 self.df[col_name] = column_data
354 self.invalidate_visible_df_cache()
357 def to_dict(self) -> dict:
358 """JSON に保存できる dict にする。artist は含めない。"""
359 result = {}
360 for f in fields(self):
361 if f.name in ('artist', 'df'):
362 continue
363 value = getattr(self, f.name)
364 if f.name == 'masked_row_indices':
365 # numpy.int64 が混ざることがある
366 value = [int(v) for v in value]
367 result[f.name] = value
368 result['df'] = self._df_to_dict(self.df)
369 return result
371 @classmethod
372 def from_dict(cls, data: dict) -> 'Dataset':
373 """to_dict() の逆。古いファイルに無いフィールドは既定値で補う。"""
374 obj = cls.__new__(cls)
375 state = {}
376 df_data = data.get('df')
377 state['df'] = cls._df_from_dict(df_data) if df_data is not None else pd.DataFrame()
378 state['artist'] = None
379 for f in fields(cls):
380 if f.name in ('df', 'artist'):
381 continue
382 if f.name in data:
383 value = data[f.name]
384 if f.name == 'masked_row_indices' and value is not None:
385 value = [int(v) for v in value]
386 state[f.name] = value
387 elif f.default is not MISSING:
388 state[f.name] = f.default
389 elif f.default_factory is not MISSING:
390 state[f.name] = f.default_factory()
391 else:
392 # 既定値の無いフィールドが欠けていれば、後の関係ない場所で落ちる前にここで止める
393 raise ValueError(
394 f"Datasetの復元に失敗しました: 必須フィールド '{f.name}' がありません。"
395 "壊れているか、対応していない形式のファイルの可能性があります。"
396 )
397 # 古いファイルの plot_type 'Waterfall' は、今のウォーターフォールのフラグに読み替える
398 if state.get('plot_type') == 'Waterfall':
399 state['plot_type'] = 'Line'
400 state['waterfall_enabled'] = True
402 obj.__dict__.update(state)
403 obj._use_string_column_names()
404 return obj
406 def __post_init__(self) -> None:
407 self._use_string_column_names()
409 def _use_string_column_names(self) -> None:
410 """列名と、列名を参照するフィールドを文字列にそろえる。
412 数値の列名のままだと、保存(辞書のキーを並べ替えて比べる)・読み戻し・列名の入力欄などが文字列を前提に失敗する。
413 """
414 if self.df is not None: 414 ↛ 416line 414 didn't jump to line 416 because the condition on line 414 was always true
415 self.df = with_string_columns(self.df)
416 for field_name in COLUMN_REFERENCE_FIELDS:
417 value = getattr(self, field_name, None)
418 if value is not None and not isinstance(value, str):
419 setattr(self, field_name, column_key(value))
421 @staticmethod
422 def _df_to_dict(df: pd.DataFrame) -> dict:
423 """dtype を保ったまま JSON にできる dict にする。
425 datetime64 は ISO 8601 の文字列(NaT は None)。float の NaN は json が NaN のまま往復する。
426 """
427 # 辞書のキーは文字列にする(JSON はどのみち文字列にし、数値と混ざると並べ替えられない)
428 dtypes = {column_key(col): str(dtype) for col, dtype in df.dtypes.items()}
429 data = {}
430 for col in df.columns:
431 series = df[col]
432 if pd.api.types.is_datetime64_any_dtype(series):
433 data[column_key(col)] = [
434 None if pd.isna(v) else pd.Timestamp(v).isoformat()
435 for v in series
436 ]
437 else:
438 data[column_key(col)] = series.tolist()
439 return {
440 'columns': list(df.columns),
441 'index': list(df.index),
442 'index_dtype': str(df.index.dtype),
443 'data': data,
444 'dtypes': dtypes,
445 }
447 @staticmethod
448 def _df_from_dict(d: dict) -> pd.DataFrame:
449 """_df_to_dict() の逆。"""
450 columns = d.get('columns', [])
451 index = d.get('index', [])
452 data = d.get('data', {})
453 dtypes = d.get('dtypes', {})
454 index_dtype = d.get('index_dtype')
456 df = pd.DataFrame(index=index)
457 if index_dtype: 457 ↛ 463line 457 didn't jump to line 463 because the condition on line 457 was always true
458 # 0 行だと index=[] の dtype が object になって元と食い違う
459 try:
460 df.index = df.index.astype(index_dtype)
461 except (TypeError, ValueError):
462 pass
463 for col in columns:
464 # 数値の列名で保存したファイルは、columns には数値、data のキーには文字列で入っている
465 col_data = data.get(col, data.get(column_key(col), []))
466 dtype_str = dtypes.get(col, dtypes.get(column_key(col)))
467 if dtype_str and dtype_str.startswith('datetime64'):
468 series = pd.to_datetime(pd.Series(col_data, index=index))
469 else:
470 series = pd.Series(col_data, index=index)
471 if dtype_str: 471 ↛ 477line 471 didn't jump to line 477 because the condition on line 471 was always true
472 try:
473 series = series.astype(dtype_str)
474 except (TypeError, ValueError):
475 pass
476 # 数値の列名のまま足していくと、pandas が 1.5 の後の 2 を 2.0 にするので、文字列にしてから入れる
477 df[column_key(col)] = series
478 if columns: 478 ↛ 480line 478 didn't jump to line 480 because the condition on line 478 was always true
479 df = df[[column_key(col) for col in columns]]
480 return df
482 def __getstate__(self) -> dict[str, Any]:
483 """artist は描くたびに作り直す Figure への参照なので保存しない。"""
484 state = self.__dict__.copy()
485 state['artist'] = None
486 for cache_key in ('_visible_df_cache', '_visible_df_cache_version', '_version',
487 '_z_grid_cache', '_z_grid_cache_key'):
488 state.pop(cache_key, None)
489 return state
491 def __setstate__(self, state: dict[str, Any]) -> None:
492 self.__dict__.update(state)
493 # 状態に無いフィールドは既定値で補う(copy.deepcopy もここを通る)
494 for f in fields(self):
495 if f.name not in self.__dict__:
496 if f.default is not MISSING:
497 self.__dict__[f.name] = f.default
498 elif f.default_factory is not MISSING: 498 ↛ 494line 498 didn't jump to line 494 because the condition on line 498 was always true
499 self.__dict__[f.name] = f.default_factory()
500 # from_dict と同じ読み替え
501 if self.__dict__.get('plot_type') == 'Waterfall': 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true
502 self.__dict__['plot_type'] = 'Line'
503 self.__dict__['waterfall_enabled'] = True
506def legend_text(dataset: Any) -> str:
507 """凡例に出す文字。凡例に出さないなら、matplotlib が凡例から外す "_nolegend_"。描画とスクリプトの書き出しで共有する。"""
508 if getattr(dataset, 'hide_from_legend', False):
509 return "_nolegend_"
510 return getattr(dataset, 'legend_label', "") or dataset.name