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

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 

8 

9from graphica.core.marker_style import DEFAULT_MARKER_EDGE_WIDTH, MARKER_EDGE_SAME, MARKER_FILL_SOLID 

10 

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} 

20 

21 

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

27 

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) 

32 

33 

34def column_key(column: Any) -> str: 

35 """列名の文字列。JSON が辞書のキーを文字列にするのと同じ形(1.5 → '1.5'、3 → '3')。 

36 

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) 

46 

47 

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 

55 

56 

57# z_col_name の値で点を配色する散布図。値は保存されるので変えない(変えると既存のプロジェクトが Line に戻る)。 

58# 種類の欄の幅は最長の項目で決まり、長くするとドックに横スクロールバーが出るので 15 文字に収めている。 

59COLOR_BY_COLUMN_PLOT_TYPE = 'Z-Color Scatter' 

60 

61# ウォーターフォールのオフセットの決め方(waterfall_offset_mode の値。保存されるので変えない) 

62# 'step': 段の番号 × オフセット(段ごとに等間隔)。'absolute': その系列をオフセットの分だけそのままずらす 

63WATERFALL_OFFSET_STEP = 'step' 

64WATERFALL_OFFSET_ABSOLUTE = 'absolute' 

65 

66 

67@dataclass 

68class Dataset: 

69 """1つの系列のデータと見た目。""" 

70 

71 name: str # 凡例に出る名前 

72 df: pd.DataFrame 

73 x_col_name: str 

74 y_col_name: str 

75 

76 

77 def __setattr__(self, name: str, value: Any) -> None: 

78 """df と masked_row_indices の再代入で visible_df のキャッシュを捨てる。 

79 

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

85 

86 def invalidate_visible_df_cache(self) -> None: 

87 """df をその場で書き換えた後に呼ぶ(再代入なら自動で捨てられる)。""" 

88 self.__dict__['_version'] = self.__dict__.get('_version', 0) + 1 

89 

90 @property 

91 def visible_df(self) -> pd.DataFrame: 

92 """masked_row_indices の行を除いた DataFrame。x_data などはすべてここを通る。 

93 

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'] 

105 

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。 

109 

110 散在データの補間は重いのでキャッシュする。列名や補間の設定は版番号を上げないので、キャッシュのキーに含める。 

111 """ 

112 if self.data_kind != '2d_grid' or not self.z_col_name: 

113 return None 

114 

115 from graphica.core.grid_data import compute_z_grid, GridDataError 

116 

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'] 

139 

140 @property 

141 def x_data(self) -> np.ndarray: 

142 """X の列の値(マスクした行を除く)。""" 

143 return self.visible_df[self.x_col_name].values 

144 

145 @property 

146 def y_data(self) -> np.ndarray: 

147 """Y の列の値(マスクした行を除く)。""" 

148 return self.visible_df[self.y_col_name].values 

149 

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 

156 

157 @property 

158 def z_data(self) -> np.ndarray | None: 

159 """Z の列の値。未設定か列が無ければ None。 

160 

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 

166 

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 

173 

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 

195 

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' 

200 

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 

208 

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 

213 

214 show_point_labels: bool = False 

215 # None なら Y の値をラベルにする 

216 point_label_col_name: str | None = field(default=None) 

217 

218 # None ならその軸のエラーバーは描かない 

219 x_err_col_name: str | None = field(default=None) 

220 y_err_col_name: str | None = field(default=None) 

221 

222 # 'bar' / 'band' / 'both' 

223 error_display: str = 'bar' 

224 

225 # 描画・フィット・解析から除く行。位置ではなく df.index のラベル 

226 masked_row_indices: list = field(default_factory=list) 

227 

228 fit_info: str | None = field(default=None) # 表示用のフィット結果の文字列 

229 

230 # フィット結果(fit_type, params, param_errors, covariance, r_squared, residuals など)。 

231 # pickle と JSON の両方で往復できるよう、Python の素の型だけにする。 

232 fit_result: dict | None = field(default=None) 

233 

234 # "confidence" / "prediction" / None。df の 'y_lower' / 'y_upper' 列を帯として描く 

235 fit_band_display: str | None = field(default=None) 

236 

237 # プラグインの処理・解析が作った系列なら、そのプラグイン名 

238 source_plugin: str | None = field(default=None) 

239 

240 # 読み込んだファイルの絶対パス(「再読み込み」用)。ファイルから作っていない系列は None。 

241 # source_sheet は Excel のシート名(CSV と単一シートでは None) 

242 source_file: str | None = field(default=None) 

243 source_sheet: str | None = field(default=None) 

244 

245 # 欠損値の描き方。描くときだけ効き、データは変えない。 

246 # 'gap'(線を切る)/ 'ffill'(前の値で埋める)/ 'drop'(除いてつなぐ) 

247 nan_policy: str = field(default='gap') 

248 

249 # この系列を作った直近1回の操作({operation, params, source_dataset_ids, source_dataset_names, timestamp})。 

250 # 全体の履歴は source_dataset_ids を辿って組み立てる。読み込んだデータは None 

251 provenance: dict | None = field(default=None) 

252 

253 use_secondary_y: bool = field(default=False) 

254 subplot_target: int = field(default=0) # 描画先のサブプロット(0始まり) 

255 

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) 

267 

268 # 'heatmap' / 'contour' / 'contour_filled' / 'heatmap_contour' 

269 map_display_mode: str = field(default='heatmap') 

270 contour_levels: int = field(default=10) 

271 

272 # False なら描画とエクスポートから外す(系列は残る) 

273 visible: bool = field(default=True) 

274 

275 artist: object = field(default=None, repr=False) 

276 

277 # 名前に依らずに系列を特定する ID 

278 dataset_id: str = field(default_factory=lambda: uuid.uuid4().hex, repr=False) 

279 

280 # df はこのクラスのメソッドで変える(Undo のコマンドがこれを呼ぶ) 

281 

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

293 

294 def add_row(self) -> None: 

295 """NaN の行を末尾に足す。 

296 

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

302 

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

307 

308 def delete_rows(self, row_indices: list[Any]) -> None: 

309 """行を削除する。index は振り直さない(restore_rows が元のラベルで戻すため)。 

310 

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 

319 

320 def restore_rows(self, deleted_data: pd.DataFrame) -> None: 

321 """delete_rows で消した行を元のラベルで戻す。 

322 

323 振り直すと、ほかの削除で空いた欠番がずれ、後続の Undo が別の行を触る。 

324 """ 

325 restored_df = pd.concat([self.df, deleted_data]) 

326 self.df = restored_df.sort_index() 

327 

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) 

331 

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

336 

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

340 

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) 

349 

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

355 

356 

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 

370 

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 

401 

402 obj.__dict__.update(state) 

403 obj._use_string_column_names() 

404 return obj 

405 

406 def __post_init__(self) -> None: 

407 self._use_string_column_names() 

408 

409 def _use_string_column_names(self) -> None: 

410 """列名と、列名を参照するフィールドを文字列にそろえる。 

411 

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

420 

421 @staticmethod 

422 def _df_to_dict(df: pd.DataFrame) -> dict: 

423 """dtype を保ったまま JSON にできる dict にする。 

424 

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 } 

446 

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

455 

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 

481 

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 

490 

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 

504 

505 

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