scitex 2.5.0__py3-none-any.whl → 2.7.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 (1091) hide show
  1. scitex/__init__.py +19 -8
  2. scitex/__main__.py +2 -1
  3. scitex/__version__.py +1 -1
  4. scitex/_optional_deps.py +13 -20
  5. scitex/ai/__init__.py +5 -0
  6. scitex/ai/_gen_ai/_Anthropic.py +3 -1
  7. scitex/ai/_gen_ai/_BaseGenAI.py +3 -2
  8. scitex/ai/_gen_ai/_DeepSeek.py +1 -1
  9. scitex/ai/_gen_ai/_Google.py +3 -2
  10. scitex/ai/_gen_ai/_Llama.py +4 -2
  11. scitex/ai/_gen_ai/_OpenAI.py +3 -1
  12. scitex/ai/_gen_ai/_PARAMS.py +1 -0
  13. scitex/ai/_gen_ai/_Perplexity.py +3 -1
  14. scitex/ai/_gen_ai/__init__.py +1 -0
  15. scitex/ai/_gen_ai/_format_output_func.py +3 -1
  16. scitex/ai/classification/CrossValidationExperiment.py +8 -14
  17. scitex/ai/classification/examples/timeseries_cv_demo.py +128 -112
  18. scitex/ai/classification/reporters/_BaseClassificationReporter.py +2 -0
  19. scitex/ai/classification/reporters/_ClassificationReporter.py +30 -45
  20. scitex/ai/classification/reporters/_MultiClassificationReporter.py +8 -11
  21. scitex/ai/classification/reporters/_SingleClassificationReporter.py +126 -182
  22. scitex/ai/classification/reporters/__init__.py +1 -1
  23. scitex/ai/classification/reporters/reporter_utils/_Plotter.py +213 -119
  24. scitex/ai/classification/reporters/reporter_utils/__init__.py +28 -36
  25. scitex/ai/classification/reporters/reporter_utils/aggregation.py +125 -143
  26. scitex/ai/classification/reporters/reporter_utils/data_models.py +128 -120
  27. scitex/ai/classification/reporters/reporter_utils/reporting.py +507 -340
  28. scitex/ai/classification/reporters/reporter_utils/storage.py +4 -1
  29. scitex/ai/classification/reporters/reporter_utils/validation.py +141 -154
  30. scitex/ai/classification/timeseries/_TimeSeriesBlockingSplit.py +204 -129
  31. scitex/ai/classification/timeseries/_TimeSeriesCalendarSplit.py +215 -171
  32. scitex/ai/classification/timeseries/_TimeSeriesMetadata.py +17 -17
  33. scitex/ai/classification/timeseries/_TimeSeriesSlidingWindowSplit.py +67 -143
  34. scitex/ai/classification/timeseries/_TimeSeriesSlidingWindowSplit_v01-not-using-n_splits.py +67 -143
  35. scitex/ai/classification/timeseries/_TimeSeriesStrategy.py +12 -13
  36. scitex/ai/classification/timeseries/_TimeSeriesStratifiedSplit.py +231 -144
  37. scitex/ai/classification/timeseries/__init__.py +2 -4
  38. scitex/ai/classification/timeseries/_normalize_timestamp.py +3 -0
  39. scitex/ai/clustering/_pca.py +0 -1
  40. scitex/ai/clustering/_umap.py +1 -2
  41. scitex/ai/feature_extraction/__init__.py +10 -8
  42. scitex/ai/feature_extraction/vit.py +0 -1
  43. scitex/ai/feature_selection/feature_selection.py +3 -8
  44. scitex/ai/metrics/_calc_conf_mat.py +2 -0
  45. scitex/ai/metrics/_calc_feature_importance.py +3 -7
  46. scitex/ai/metrics/_calc_pre_rec_auc.py +5 -5
  47. scitex/ai/metrics/_calc_roc_auc.py +4 -2
  48. scitex/ai/metrics/_calc_seizure_prediction_metrics.py +35 -20
  49. scitex/ai/metrics/_calc_silhouette_score.py +1 -3
  50. scitex/ai/optim/Ranger_Deep_Learning_Optimizer/ranger/ranger.py +0 -3
  51. scitex/ai/optim/Ranger_Deep_Learning_Optimizer/ranger/ranger2020.py +0 -3
  52. scitex/ai/optim/Ranger_Deep_Learning_Optimizer/ranger/ranger913A.py +0 -3
  53. scitex/ai/optim/_optimizers.py +1 -1
  54. scitex/ai/plt/__init__.py +6 -1
  55. scitex/ai/plt/_plot_feature_importance.py +1 -3
  56. scitex/ai/plt/_plot_learning_curve.py +9 -24
  57. scitex/ai/plt/_plot_optuna_study.py +4 -3
  58. scitex/ai/plt/_plot_pre_rec_curve.py +9 -15
  59. scitex/ai/plt/_plot_roc_curve.py +6 -8
  60. scitex/ai/plt/_stx_conf_mat.py +121 -122
  61. scitex/ai/sampling/undersample.py +3 -2
  62. scitex/ai/sklearn/__init__.py +2 -2
  63. scitex/ai/training/_LearningCurveLogger.py +23 -10
  64. scitex/ai/utils/_check_params.py +0 -1
  65. scitex/benchmark/__init__.py +15 -25
  66. scitex/benchmark/benchmark.py +124 -117
  67. scitex/benchmark/monitor.py +117 -107
  68. scitex/benchmark/profiler.py +61 -58
  69. scitex/bridge/__init__.py +110 -0
  70. scitex/bridge/_helpers.py +149 -0
  71. scitex/bridge/_plt_vis.py +529 -0
  72. scitex/bridge/_protocol.py +283 -0
  73. scitex/bridge/_stats_plt.py +261 -0
  74. scitex/bridge/_stats_vis.py +265 -0
  75. scitex/browser/__init__.py +0 -2
  76. scitex/browser/auth/__init__.py +0 -0
  77. scitex/browser/auth/google.py +16 -11
  78. scitex/browser/automation/CookieHandler.py +2 -3
  79. scitex/browser/collaboration/__init__.py +3 -0
  80. scitex/browser/collaboration/auth_helpers.py +3 -1
  81. scitex/browser/collaboration/collaborative_agent.py +2 -0
  82. scitex/browser/collaboration/interactive_panel.py +2 -2
  83. scitex/browser/collaboration/shared_session.py +20 -11
  84. scitex/browser/collaboration/standard_interactions.py +1 -0
  85. scitex/browser/core/BrowserMixin.py +12 -30
  86. scitex/browser/core/ChromeProfileManager.py +9 -24
  87. scitex/browser/debugging/_browser_logger.py +15 -25
  88. scitex/browser/debugging/_failure_capture.py +9 -2
  89. scitex/browser/debugging/_highlight_element.py +15 -6
  90. scitex/browser/debugging/_show_grid.py +5 -6
  91. scitex/browser/debugging/_sync_session.py +4 -3
  92. scitex/browser/debugging/_test_monitor.py +14 -5
  93. scitex/browser/debugging/_visual_cursor.py +46 -35
  94. scitex/browser/interaction/click_center.py +4 -3
  95. scitex/browser/interaction/click_with_fallbacks.py +7 -10
  96. scitex/browser/interaction/close_popups.py +79 -66
  97. scitex/browser/interaction/fill_with_fallbacks.py +8 -8
  98. scitex/browser/pdf/__init__.py +3 -1
  99. scitex/browser/pdf/click_download_for_chrome_pdf_viewer.py +11 -10
  100. scitex/browser/pdf/detect_chrome_pdf_viewer.py +3 -6
  101. scitex/browser/remote/CaptchaHandler.py +109 -96
  102. scitex/browser/remote/ZenRowsAPIClient.py +91 -97
  103. scitex/browser/remote/ZenRowsBrowserManager.py +138 -112
  104. scitex/browser/stealth/HumanBehavior.py +4 -9
  105. scitex/browser/stealth/StealthManager.py +11 -26
  106. scitex/capture/__init__.py +17 -17
  107. scitex/capture/__main__.py +2 -3
  108. scitex/capture/capture.py +23 -51
  109. scitex/capture/cli.py +14 -39
  110. scitex/capture/gif.py +5 -9
  111. scitex/capture/mcp_server.py +7 -20
  112. scitex/capture/session.py +4 -3
  113. scitex/capture/utils.py +18 -53
  114. scitex/cli/__init__.py +1 -1
  115. scitex/cli/cloud.py +158 -116
  116. scitex/cli/config.py +224 -0
  117. scitex/cli/main.py +41 -40
  118. scitex/cli/scholar.py +60 -27
  119. scitex/cli/security.py +14 -20
  120. scitex/cli/web.py +87 -90
  121. scitex/cli/writer.py +51 -45
  122. scitex/cloud/__init__.py +14 -11
  123. scitex/cloud/_matplotlib_hook.py +6 -6
  124. scitex/config/README.md +313 -0
  125. scitex/config/{PriorityConfig.py → _PriorityConfig.py} +114 -17
  126. scitex/config/_ScitexConfig.py +319 -0
  127. scitex/config/__init__.py +41 -9
  128. scitex/config/_paths.py +325 -0
  129. scitex/config/default.yaml +81 -0
  130. scitex/context/_suppress_output.py +2 -3
  131. scitex/db/_BaseMixins/_BaseBackupMixin.py +3 -1
  132. scitex/db/_BaseMixins/_BaseBatchMixin.py +3 -1
  133. scitex/db/_BaseMixins/_BaseBlobMixin.py +3 -1
  134. scitex/db/_BaseMixins/_BaseImportExportMixin.py +1 -3
  135. scitex/db/_BaseMixins/_BaseIndexMixin.py +3 -1
  136. scitex/db/_BaseMixins/_BaseMaintenanceMixin.py +1 -3
  137. scitex/db/_BaseMixins/_BaseQueryMixin.py +3 -1
  138. scitex/db/_BaseMixins/_BaseRowMixin.py +3 -1
  139. scitex/db/_BaseMixins/_BaseTableMixin.py +3 -1
  140. scitex/db/_BaseMixins/_BaseTransactionMixin.py +1 -3
  141. scitex/db/_BaseMixins/__init__.py +1 -1
  142. scitex/db/__init__.py +9 -1
  143. scitex/db/__main__.py +8 -21
  144. scitex/db/_check_health.py +15 -31
  145. scitex/db/_delete_duplicates.py +7 -4
  146. scitex/db/_inspect.py +22 -38
  147. scitex/db/_inspect_optimized.py +89 -85
  148. scitex/db/_postgresql/_PostgreSQL.py +0 -1
  149. scitex/db/_postgresql/_PostgreSQLMixins/_BlobMixin.py +3 -1
  150. scitex/db/_postgresql/_PostgreSQLMixins/_ConnectionMixin.py +1 -3
  151. scitex/db/_postgresql/_PostgreSQLMixins/_ImportExportMixin.py +1 -3
  152. scitex/db/_postgresql/_PostgreSQLMixins/_MaintenanceMixin.py +1 -4
  153. scitex/db/_postgresql/_PostgreSQLMixins/_QueryMixin.py +3 -3
  154. scitex/db/_postgresql/_PostgreSQLMixins/_RowMixin.py +3 -1
  155. scitex/db/_postgresql/_PostgreSQLMixins/_TransactionMixin.py +1 -3
  156. scitex/db/_postgresql/__init__.py +1 -1
  157. scitex/db/_sqlite3/_SQLite3.py +2 -4
  158. scitex/db/_sqlite3/_SQLite3Mixins/_ArrayMixin.py +11 -12
  159. scitex/db/_sqlite3/_SQLite3Mixins/_ArrayMixin_v01-need-_hash-col.py +19 -14
  160. scitex/db/_sqlite3/_SQLite3Mixins/_BatchMixin.py +3 -1
  161. scitex/db/_sqlite3/_SQLite3Mixins/_BlobMixin.py +7 -7
  162. scitex/db/_sqlite3/_SQLite3Mixins/_ColumnMixin.py +118 -111
  163. scitex/db/_sqlite3/_SQLite3Mixins/_ConnectionMixin.py +8 -10
  164. scitex/db/_sqlite3/_SQLite3Mixins/_GitMixin.py +17 -45
  165. scitex/db/_sqlite3/_SQLite3Mixins/_ImportExportMixin.py +1 -3
  166. scitex/db/_sqlite3/_SQLite3Mixins/_IndexMixin.py +3 -1
  167. scitex/db/_sqlite3/_SQLite3Mixins/_QueryMixin.py +3 -4
  168. scitex/db/_sqlite3/_SQLite3Mixins/_RowMixin.py +9 -9
  169. scitex/db/_sqlite3/_SQLite3Mixins/_TableMixin.py +18 -11
  170. scitex/db/_sqlite3/_SQLite3Mixins/__init__.py +1 -0
  171. scitex/db/_sqlite3/__init__.py +1 -1
  172. scitex/db/_sqlite3/_delete_duplicates.py +13 -11
  173. scitex/decorators/__init__.py +29 -4
  174. scitex/decorators/_auto_order.py +43 -43
  175. scitex/decorators/_batch_fn.py +12 -6
  176. scitex/decorators/_cache_disk.py +8 -9
  177. scitex/decorators/_cache_disk_async.py +8 -7
  178. scitex/decorators/_combined.py +19 -13
  179. scitex/decorators/_converters.py +16 -3
  180. scitex/decorators/_deprecated.py +32 -22
  181. scitex/decorators/_numpy_fn.py +18 -4
  182. scitex/decorators/_pandas_fn.py +17 -5
  183. scitex/decorators/_signal_fn.py +17 -3
  184. scitex/decorators/_torch_fn.py +32 -15
  185. scitex/decorators/_xarray_fn.py +23 -9
  186. scitex/dev/_analyze_code_flow.py +0 -2
  187. scitex/dict/_DotDict.py +15 -19
  188. scitex/dict/_flatten.py +1 -0
  189. scitex/dict/_listed_dict.py +1 -0
  190. scitex/dict/_pop_keys.py +1 -0
  191. scitex/dict/_replace.py +1 -0
  192. scitex/dict/_safe_merge.py +1 -0
  193. scitex/dict/_to_str.py +2 -3
  194. scitex/dsp/__init__.py +13 -4
  195. scitex/dsp/_crop.py +3 -1
  196. scitex/dsp/_detect_ripples.py +3 -1
  197. scitex/dsp/_modulation_index.py +3 -1
  198. scitex/dsp/_time.py +3 -1
  199. scitex/dsp/_wavelet.py +0 -1
  200. scitex/dsp/example.py +0 -5
  201. scitex/dsp/filt.py +4 -0
  202. scitex/dsp/utils/__init__.py +4 -1
  203. scitex/dsp/utils/pac.py +3 -3
  204. scitex/dt/_normalize_timestamp.py +4 -1
  205. scitex/errors.py +3 -6
  206. scitex/etc/__init__.py +1 -1
  207. scitex/gen/_DimHandler.py +6 -6
  208. scitex/gen/__init__.py +5 -1
  209. scitex/gen/_deprecated_close.py +1 -0
  210. scitex/gen/_deprecated_start.py +5 -3
  211. scitex/gen/_detect_environment.py +44 -41
  212. scitex/gen/_detect_notebook_path.py +51 -47
  213. scitex/gen/_embed.py +1 -1
  214. scitex/gen/_get_notebook_path.py +81 -62
  215. scitex/gen/_inspect_module.py +0 -1
  216. scitex/gen/_norm.py +16 -7
  217. scitex/gen/_norm_cache.py +78 -65
  218. scitex/gen/_print_config.py +0 -3
  219. scitex/gen/_src.py +2 -3
  220. scitex/gen/_title_case.py +3 -2
  221. scitex/gen/_to_even.py +8 -8
  222. scitex/gen/_transpose.py +3 -3
  223. scitex/gen/misc.py +0 -3
  224. scitex/gists/_SigMacro_processFigure_S.py +2 -2
  225. scitex/gists/_SigMacro_toBlue.py +2 -2
  226. scitex/gists/__init__.py +4 -1
  227. scitex/git/_branch.py +19 -11
  228. scitex/git/_clone.py +23 -15
  229. scitex/git/_commit.py +10 -12
  230. scitex/git/_init.py +15 -38
  231. scitex/git/_remote.py +9 -3
  232. scitex/git/_result.py +3 -0
  233. scitex/git/_retry.py +2 -5
  234. scitex/git/_types.py +4 -0
  235. scitex/git/_validation.py +8 -8
  236. scitex/git/_workflow.py +4 -4
  237. scitex/io/__init__.py +2 -1
  238. scitex/io/_glob.py +2 -2
  239. scitex/io/_json2md.py +3 -3
  240. scitex/io/_load.py +6 -8
  241. scitex/io/_load_cache.py +71 -71
  242. scitex/io/_load_configs.py +2 -3
  243. scitex/io/_load_modules/_H5Explorer.py +6 -12
  244. scitex/io/_load_modules/_ZarrExplorer.py +3 -3
  245. scitex/io/_load_modules/_bibtex.py +62 -63
  246. scitex/io/_load_modules/_canvas.py +4 -9
  247. scitex/io/_load_modules/_catboost.py +7 -2
  248. scitex/io/_load_modules/_hdf5.py +2 -0
  249. scitex/io/_load_modules/_image.py +5 -1
  250. scitex/io/_load_modules/_matlab.py +3 -1
  251. scitex/io/_load_modules/_optuna.py +0 -1
  252. scitex/io/_load_modules/_pdf.py +38 -29
  253. scitex/io/_load_modules/_sqlite3.py +1 -0
  254. scitex/io/_load_modules/_txt.py +2 -0
  255. scitex/io/_load_modules/_xml.py +9 -9
  256. scitex/io/_load_modules/_zarr.py +12 -10
  257. scitex/io/_metadata.py +76 -37
  258. scitex/io/_qr_utils.py +18 -13
  259. scitex/io/_save.py +220 -63
  260. scitex/io/_save_modules/__init__.py +7 -2
  261. scitex/io/_save_modules/_bibtex.py +66 -61
  262. scitex/io/_save_modules/_canvas.py +5 -6
  263. scitex/io/_save_modules/_catboost.py +2 -2
  264. scitex/io/_save_modules/_csv.py +4 -4
  265. scitex/io/_save_modules/_excel.py +5 -9
  266. scitex/io/_save_modules/_hdf5.py +9 -21
  267. scitex/io/_save_modules/_html.py +5 -5
  268. scitex/io/_save_modules/_image.py +105 -8
  269. scitex/io/_save_modules/_joblib.py +2 -2
  270. scitex/io/_save_modules/_json.py +51 -6
  271. scitex/io/_save_modules/_listed_dfs_as_csv.py +2 -1
  272. scitex/io/_save_modules/_listed_scalars_as_csv.py +2 -1
  273. scitex/io/_save_modules/_matlab.py +2 -2
  274. scitex/io/_save_modules/_numpy.py +6 -8
  275. scitex/io/_save_modules/_pickle.py +4 -4
  276. scitex/io/_save_modules/_plotly.py +3 -3
  277. scitex/io/_save_modules/_tex.py +23 -25
  278. scitex/io/_save_modules/_text.py +2 -2
  279. scitex/io/_save_modules/_yaml.py +9 -9
  280. scitex/io/_save_modules/_zarr.py +15 -15
  281. scitex/io/utils/__init__.py +2 -1
  282. scitex/io/utils/h5_to_zarr.py +173 -155
  283. scitex/linalg/__init__.py +1 -1
  284. scitex/linalg/_geometric_median.py +4 -3
  285. scitex/logging/_Tee.py +5 -7
  286. scitex/logging/__init__.py +18 -19
  287. scitex/logging/_config.py +4 -1
  288. scitex/logging/_context.py +6 -5
  289. scitex/logging/_formatters.py +2 -3
  290. scitex/logging/_handlers.py +19 -20
  291. scitex/logging/_levels.py +9 -17
  292. scitex/logging/_logger.py +74 -15
  293. scitex/logging/_print_capture.py +17 -17
  294. scitex/nn/_BNet.py +1 -3
  295. scitex/nn/_Filters.py +6 -2
  296. scitex/nn/_ModulationIndex.py +3 -1
  297. scitex/nn/_PAC.py +3 -2
  298. scitex/nn/_PSD.py +0 -1
  299. scitex/nn/__init__.py +16 -3
  300. scitex/path/_clean.py +10 -8
  301. scitex/path/_find.py +1 -1
  302. scitex/path/_get_spath.py +1 -2
  303. scitex/path/_mk_spath.py +1 -1
  304. scitex/path/_symlink.py +5 -10
  305. scitex/pd/__init__.py +4 -1
  306. scitex/pd/_force_df.py +24 -24
  307. scitex/pd/_get_unique.py +1 -0
  308. scitex/pd/_merge_columns.py +1 -1
  309. scitex/pd/_round.py +11 -7
  310. scitex/pd/_to_xy.py +0 -1
  311. scitex/plt/REQUESTS.md +191 -0
  312. scitex/plt/__init__.py +185 -87
  313. scitex/plt/_subplots/_AxesWrapper.py +22 -6
  314. scitex/plt/_subplots/_AxisWrapper.py +100 -39
  315. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin.py +74 -52
  316. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin.py +183 -73
  317. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin.py +61 -45
  318. scitex/plt/_subplots/_AxisWrapperMixins/_TrackingMixin.py +26 -14
  319. scitex/plt/_subplots/_AxisWrapperMixins/_UnitAwareMixin.py +80 -73
  320. scitex/plt/_subplots/_FigWrapper.py +93 -60
  321. scitex/plt/_subplots/_SubplotsWrapper.py +135 -68
  322. scitex/plt/_subplots/__init__.py +10 -0
  323. scitex/plt/_subplots/_export_as_csv.py +89 -47
  324. scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +1 -0
  325. scitex/plt/_subplots/_export_as_csv_formatters/_format_annotate.py +6 -4
  326. scitex/plt/_subplots/_export_as_csv_formatters/_format_bar.py +88 -38
  327. scitex/plt/_subplots/_export_as_csv_formatters/_format_barh.py +25 -31
  328. scitex/plt/_subplots/_export_as_csv_formatters/_format_boxplot.py +53 -23
  329. scitex/plt/_subplots/_export_as_csv_formatters/_format_contour.py +38 -25
  330. scitex/plt/_subplots/_export_as_csv_formatters/_format_contourf.py +17 -9
  331. scitex/plt/_subplots/_export_as_csv_formatters/_format_errorbar.py +70 -124
  332. scitex/plt/_subplots/_export_as_csv_formatters/_format_eventplot.py +12 -10
  333. scitex/plt/_subplots/_export_as_csv_formatters/_format_fill.py +31 -17
  334. scitex/plt/_subplots/_export_as_csv_formatters/_format_fill_between.py +33 -21
  335. scitex/plt/_subplots/_export_as_csv_formatters/_format_hexbin.py +14 -4
  336. scitex/plt/_subplots/_export_as_csv_formatters/_format_hist.py +43 -29
  337. scitex/plt/_subplots/_export_as_csv_formatters/_format_hist2d.py +14 -4
  338. scitex/plt/_subplots/_export_as_csv_formatters/_format_imshow.py +27 -11
  339. scitex/plt/_subplots/_export_as_csv_formatters/_format_imshow2d.py +7 -5
  340. scitex/plt/_subplots/_export_as_csv_formatters/_format_matshow.py +9 -7
  341. scitex/plt/_subplots/_export_as_csv_formatters/_format_pie.py +15 -6
  342. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +85 -46
  343. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_box.py +52 -27
  344. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_imshow.py +1 -0
  345. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_kde.py +16 -17
  346. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_scatter.py +7 -5
  347. scitex/plt/_subplots/_export_as_csv_formatters/_format_quiver.py +10 -8
  348. scitex/plt/_subplots/_export_as_csv_formatters/_format_scatter.py +17 -6
  349. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_barplot.py +43 -26
  350. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_boxplot.py +68 -47
  351. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_heatmap.py +52 -64
  352. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_histplot.py +55 -50
  353. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_jointplot.py +9 -11
  354. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_kdeplot.py +63 -29
  355. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_lineplot.py +4 -4
  356. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_pairplot.py +6 -4
  357. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_scatterplot.py +44 -40
  358. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_stripplot.py +46 -39
  359. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_swarmplot.py +46 -39
  360. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_violinplot.py +75 -94
  361. scitex/plt/_subplots/_export_as_csv_formatters/_format_stem.py +12 -3
  362. scitex/plt/_subplots/_export_as_csv_formatters/_format_step.py +12 -3
  363. scitex/plt/_subplots/_export_as_csv_formatters/_format_streamplot.py +10 -8
  364. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_conf_mat.py +17 -15
  365. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_ecdf.py +10 -9
  366. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_fillv.py +35 -31
  367. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_heatmap.py +18 -18
  368. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_image.py +24 -18
  369. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_joyplot.py +9 -7
  370. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_line.py +34 -23
  371. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_mean_ci.py +15 -13
  372. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_mean_std.py +12 -10
  373. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_median_iqr.py +15 -13
  374. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_raster.py +11 -9
  375. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_rectangle.py +84 -56
  376. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter_hist.py +35 -32
  377. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_shaded_line.py +46 -30
  378. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_violin.py +51 -51
  379. scitex/plt/_subplots/_export_as_csv_formatters/_format_text.py +32 -31
  380. scitex/plt/_subplots/_export_as_csv_formatters/_format_violin.py +34 -31
  381. scitex/plt/_subplots/_export_as_csv_formatters/_format_violinplot.py +44 -37
  382. scitex/plt/_subplots/_export_as_csv_formatters/verify_formatters.py +91 -74
  383. scitex/plt/_tpl.py +6 -5
  384. scitex/plt/ax/_plot/__init__.py +24 -0
  385. scitex/plt/ax/_plot/_add_fitted_line.py +12 -11
  386. scitex/plt/ax/_plot/_plot_circular_hist.py +3 -1
  387. scitex/plt/ax/_plot/_plot_statistical_shaded_line.py +25 -19
  388. scitex/plt/ax/_plot/_stx_conf_mat.py +6 -3
  389. scitex/plt/ax/_plot/_stx_ecdf.py +5 -3
  390. scitex/plt/ax/_plot/_stx_fillv.py +4 -2
  391. scitex/plt/ax/_plot/_stx_heatmap.py +7 -4
  392. scitex/plt/ax/_plot/_stx_image.py +7 -5
  393. scitex/plt/ax/_plot/_stx_joyplot.py +32 -10
  394. scitex/plt/ax/_plot/_stx_raster.py +26 -11
  395. scitex/plt/ax/_plot/_stx_rectangle.py +2 -2
  396. scitex/plt/ax/_plot/_stx_shaded_line.py +15 -11
  397. scitex/plt/ax/_plot/_stx_violin.py +3 -1
  398. scitex/plt/ax/_style/_add_marginal_ax.py +6 -4
  399. scitex/plt/ax/_style/_auto_scale_axis.py +14 -10
  400. scitex/plt/ax/_style/_extend.py +3 -1
  401. scitex/plt/ax/_style/_force_aspect.py +5 -3
  402. scitex/plt/ax/_style/_format_units.py +2 -2
  403. scitex/plt/ax/_style/_hide_spines.py +5 -1
  404. scitex/plt/ax/_style/_map_ticks.py +5 -3
  405. scitex/plt/ax/_style/_rotate_labels.py +5 -4
  406. scitex/plt/ax/_style/_rotate_labels_v01.py +73 -63
  407. scitex/plt/ax/_style/_set_log_scale.py +120 -85
  408. scitex/plt/ax/_style/_set_meta.py +99 -76
  409. scitex/plt/ax/_style/_set_supxyt.py +33 -16
  410. scitex/plt/ax/_style/_set_xyt.py +27 -18
  411. scitex/plt/ax/_style/_share_axes.py +15 -5
  412. scitex/plt/ax/_style/_show_spines.py +58 -57
  413. scitex/plt/ax/_style/_style_barplot.py +1 -1
  414. scitex/plt/ax/_style/_style_boxplot.py +25 -14
  415. scitex/plt/ax/_style/_style_errorbar.py +0 -0
  416. scitex/plt/ax/_style/_style_scatter.py +1 -1
  417. scitex/plt/ax/_style/_style_suptitles.py +3 -3
  418. scitex/plt/ax/_style/_style_violinplot.py +8 -2
  419. scitex/plt/color/__init__.py +34 -2
  420. scitex/plt/color/_add_hue_col.py +1 -0
  421. scitex/plt/color/_colors.py +0 -1
  422. scitex/plt/color/_get_colors_from_conf_matap.py +3 -1
  423. scitex/plt/color/_vizualize_colors.py +0 -1
  424. scitex/plt/docs/FIGURE_ARCHITECTURE.md +155 -97
  425. scitex/plt/gallery/README.md +75 -0
  426. scitex/plt/gallery/__init__.py +29 -0
  427. scitex/plt/gallery/_generate.py +153 -0
  428. scitex/plt/gallery/_plots.py +594 -0
  429. scitex/plt/gallery/_registry.py +153 -0
  430. scitex/plt/styles/__init__.py +9 -9
  431. scitex/plt/styles/_plot_defaults.py +62 -61
  432. scitex/plt/styles/_plot_postprocess.py +126 -77
  433. scitex/plt/styles/_style_loader.py +0 -0
  434. scitex/plt/styles/presets.py +43 -18
  435. scitex/plt/templates/research-master/scitex/vis/gallery/area/fill_between.json +110 -0
  436. scitex/plt/templates/research-master/scitex/vis/gallery/area/fill_betweenx.json +88 -0
  437. scitex/plt/templates/research-master/scitex/vis/gallery/area/stx_fill_between.json +103 -0
  438. scitex/plt/templates/research-master/scitex/vis/gallery/area/stx_fillv.json +106 -0
  439. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/bar.json +92 -0
  440. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/barh.json +92 -0
  441. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/boxplot.json +92 -0
  442. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_bar.json +84 -0
  443. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_barh.json +84 -0
  444. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_box.json +83 -0
  445. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_boxplot.json +93 -0
  446. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_violin.json +91 -0
  447. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_violinplot.json +91 -0
  448. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/violinplot.json +91 -0
  449. scitex/plt/templates/research-master/scitex/vis/gallery/contour/contour.json +97 -0
  450. scitex/plt/templates/research-master/scitex/vis/gallery/contour/contourf.json +98 -0
  451. scitex/plt/templates/research-master/scitex/vis/gallery/contour/stx_contour.json +84 -0
  452. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/hist.json +101 -0
  453. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/hist2d.json +96 -0
  454. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_ecdf.json +95 -0
  455. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_joyplot.json +95 -0
  456. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_kde.json +93 -0
  457. scitex/plt/templates/research-master/scitex/vis/gallery/grid/imshow.json +95 -0
  458. scitex/plt/templates/research-master/scitex/vis/gallery/grid/matshow.json +95 -0
  459. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_conf_mat.json +83 -0
  460. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_heatmap.json +92 -0
  461. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_image.json +121 -0
  462. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_imshow.json +84 -0
  463. scitex/plt/templates/research-master/scitex/vis/gallery/line/plot.json +110 -0
  464. scitex/plt/templates/research-master/scitex/vis/gallery/line/step.json +92 -0
  465. scitex/plt/templates/research-master/scitex/vis/gallery/line/stx_line.json +95 -0
  466. scitex/plt/templates/research-master/scitex/vis/gallery/line/stx_shaded_line.json +96 -0
  467. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/hexbin.json +95 -0
  468. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/scatter.json +95 -0
  469. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/stem.json +92 -0
  470. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/stx_scatter.json +84 -0
  471. scitex/plt/templates/research-master/scitex/vis/gallery/special/pie.json +94 -0
  472. scitex/plt/templates/research-master/scitex/vis/gallery/special/stx_raster.json +109 -0
  473. scitex/plt/templates/research-master/scitex/vis/gallery/special/stx_rectangle.json +108 -0
  474. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/errorbar.json +93 -0
  475. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_errorbar.json +84 -0
  476. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_mean_ci.json +96 -0
  477. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_mean_std.json +96 -0
  478. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_median_iqr.json +96 -0
  479. scitex/plt/templates/research-master/scitex/vis/gallery/vector/quiver.json +99 -0
  480. scitex/plt/templates/research-master/scitex/vis/gallery/vector/streamplot.json +100 -0
  481. scitex/plt/utils/__init__.py +29 -2
  482. scitex/plt/utils/_close.py +8 -3
  483. scitex/plt/utils/_collect_figure_metadata.py +3031 -265
  484. scitex/plt/utils/_colorbar.py +15 -17
  485. scitex/plt/utils/_configure_mpl.py +22 -14
  486. scitex/plt/utils/_crop.py +60 -27
  487. scitex/plt/utils/_csv_column_naming.py +123 -72
  488. scitex/plt/utils/_dimension_viewer.py +7 -19
  489. scitex/plt/utils/_figure_from_axes_mm.py +70 -16
  490. scitex/plt/utils/_figure_mm.py +3 -2
  491. scitex/plt/utils/_get_actual_font.py +5 -4
  492. scitex/plt/utils/_histogram_utils.py +52 -48
  493. scitex/plt/utils/_is_valid_axis.py +19 -13
  494. scitex/plt/utils/_mk_colorbar.py +3 -3
  495. scitex/plt/utils/_scientific_captions.py +202 -139
  496. scitex/plt/utils/_scitex_config.py +98 -98
  497. scitex/plt/utils/_units.py +0 -0
  498. scitex/plt/utils/metadata/__init__.py +36 -0
  499. scitex/plt/utils/metadata/_artist_extraction.py +119 -0
  500. scitex/plt/utils/metadata/_axes_metadata.py +93 -0
  501. scitex/plt/utils/metadata/_collection_artists.py +292 -0
  502. scitex/plt/utils/metadata/_core.py +208 -0
  503. scitex/plt/utils/metadata/_csv_column_extraction.py +186 -0
  504. scitex/plt/utils/metadata/_csv_hash.py +115 -0
  505. scitex/plt/utils/metadata/_csv_verification.py +95 -0
  506. scitex/plt/utils/metadata/_data_linkage.py +263 -0
  507. scitex/plt/utils/metadata/_dimensions.py +239 -0
  508. scitex/plt/utils/metadata/_figure_metadata.py +58 -0
  509. scitex/plt/utils/metadata/_image_text_artists.py +168 -0
  510. scitex/plt/utils/metadata/_label_parsing.py +82 -0
  511. scitex/plt/utils/metadata/_legend_extraction.py +120 -0
  512. scitex/plt/utils/metadata/_line_artists.py +367 -0
  513. scitex/plt/utils/metadata/_line_semantic_handling.py +173 -0
  514. scitex/plt/utils/metadata/_patch_artists.py +211 -0
  515. scitex/plt/utils/metadata/_plot_content.py +26 -0
  516. scitex/plt/utils/metadata/_plot_type_detection.py +184 -0
  517. scitex/plt/utils/metadata/_precision.py +134 -0
  518. scitex/plt/utils/metadata/_precision_config.py +68 -0
  519. scitex/plt/utils/metadata/_precision_sections.py +211 -0
  520. scitex/plt/utils/metadata/_recipe_extraction.py +267 -0
  521. scitex/plt/utils/metadata/_style_parsing.py +174 -0
  522. scitex/repro/_RandomStateManager.py +33 -38
  523. scitex/repro/__init__.py +16 -7
  524. scitex/repro/_gen_ID.py +7 -9
  525. scitex/repro/_gen_timestamp.py +7 -6
  526. scitex/repro/_hash_array.py +8 -12
  527. scitex/reproduce/__init__.py +1 -1
  528. scitex/resource/_get_processor_usages.py +3 -1
  529. scitex/resource/_log_processor_usages.py +3 -1
  530. scitex/rng/__init__.py +1 -1
  531. scitex/schema/README.md +178 -0
  532. scitex/schema/__init__.py +144 -0
  533. scitex/schema/_canvas.py +444 -0
  534. scitex/schema/_stats.py +762 -0
  535. scitex/schema/_validation.py +590 -0
  536. scitex/scholar/.legacy/Scholar.py +5 -12
  537. scitex/scholar/.legacy/_Scholar.py +66 -99
  538. scitex/scholar/.legacy/_ScholarAPI.py +75 -66
  539. scitex/scholar/.legacy/_tmp/search_engine/_BaseSearchEngine.py +3 -3
  540. scitex/scholar/.legacy/_tmp/search_engine/_UnifiedSearcher.py +4 -9
  541. scitex/scholar/.legacy/_tmp/search_engine/__init__.py +14 -21
  542. scitex/scholar/.legacy/_tmp/search_engine/local/_LocalSearchEngine.py +40 -37
  543. scitex/scholar/.legacy/_tmp/search_engine/local/_VectorSearchEngine.py +31 -28
  544. scitex/scholar/.legacy/_tmp/search_engine/web/_ArxivSearchEngine.py +74 -65
  545. scitex/scholar/.legacy/_tmp/search_engine/web/_CrossRefSearchEngine.py +122 -116
  546. scitex/scholar/.legacy/_tmp/search_engine/web/_GoogleScholarSearchEngine.py +65 -59
  547. scitex/scholar/.legacy/_tmp/search_engine/web/_PubMedSearchEngine.py +121 -107
  548. scitex/scholar/.legacy/_tmp/search_engine/web/_SemanticScholarSearchEngine.py +5 -12
  549. scitex/scholar/.legacy/database/_DatabaseEntry.py +49 -45
  550. scitex/scholar/.legacy/database/_DatabaseIndex.py +131 -94
  551. scitex/scholar/.legacy/database/_LibraryManager.py +65 -63
  552. scitex/scholar/.legacy/database/_PaperDatabase.py +138 -124
  553. scitex/scholar/.legacy/database/_ScholarDatabaseIntegration.py +14 -36
  554. scitex/scholar/.legacy/database/_StorageIntegratedDB.py +192 -156
  555. scitex/scholar/.legacy/database/_ZoteroCompatibleDB.py +300 -237
  556. scitex/scholar/.legacy/database/__init__.py +2 -1
  557. scitex/scholar/.legacy/database/manage.py +92 -84
  558. scitex/scholar/.legacy/lookup/_LookupIndex.py +157 -101
  559. scitex/scholar/.legacy/lookup/__init__.py +2 -1
  560. scitex/scholar/.legacy/metadata/doi/batch/_MetadataHandlerForBatchDOIResolution.py +4 -9
  561. scitex/scholar/.legacy/metadata/doi/batch/_ProgressManagerForBatchDOIResolution.py +10 -23
  562. scitex/scholar/.legacy/metadata/doi/batch/_SourceStatsManagerForBatchDOIResolution.py +4 -9
  563. scitex/scholar/.legacy/metadata/doi/batch/__init__.py +3 -1
  564. scitex/scholar/.legacy/metadata/doi/resolvers/_BatchDOIResolver.py +10 -25
  565. scitex/scholar/.legacy/metadata/doi/resolvers/_BibTeXDOIResolver.py +19 -49
  566. scitex/scholar/.legacy/metadata/doi/resolvers/_DOIResolver.py +1 -0
  567. scitex/scholar/.legacy/metadata/doi/resolvers/_SingleDOIResolver.py +8 -20
  568. scitex/scholar/.legacy/metadata/doi/sources/.combined-SemanticScholarSource/_SemanticScholarSource.py +37 -35
  569. scitex/scholar/.legacy/metadata/doi/sources/.combined-SemanticScholarSource/_SemanticScholarSourceEnhanced.py +49 -37
  570. scitex/scholar/.legacy/metadata/doi/sources/_ArXivSource.py +11 -30
  571. scitex/scholar/.legacy/metadata/doi/sources/_BaseDOISource.py +19 -47
  572. scitex/scholar/.legacy/metadata/doi/sources/_CrossRefLocalSource.py +1 -0
  573. scitex/scholar/.legacy/metadata/doi/sources/_CrossRefSource.py +12 -33
  574. scitex/scholar/.legacy/metadata/doi/sources/_OpenAlexSource.py +8 -20
  575. scitex/scholar/.legacy/metadata/doi/sources/_PubMedSource.py +10 -27
  576. scitex/scholar/.legacy/metadata/doi/sources/_SemanticScholarSource.py +11 -29
  577. scitex/scholar/.legacy/metadata/doi/sources/_SourceManager.py +8 -21
  578. scitex/scholar/.legacy/metadata/doi/sources/_SourceResolutionStrategy.py +24 -55
  579. scitex/scholar/.legacy/metadata/doi/sources/_SourceRotationManager.py +8 -21
  580. scitex/scholar/.legacy/metadata/doi/sources/_URLDOISource.py +9 -16
  581. scitex/scholar/.legacy/metadata/doi/sources/_UnifiedSource.py +8 -22
  582. scitex/scholar/.legacy/metadata/doi/sources/__init__.py +1 -0
  583. scitex/scholar/.legacy/metadata/doi/utils/_PubMedConverter.py +4 -8
  584. scitex/scholar/.legacy/metadata/doi/utils/_RateLimitHandler.py +17 -43
  585. scitex/scholar/.legacy/metadata/doi/utils/_TextNormalizer.py +8 -18
  586. scitex/scholar/.legacy/metadata/doi/utils/_URLDOIExtractor.py +4 -8
  587. scitex/scholar/.legacy/metadata/doi/utils/__init__.py +1 -0
  588. scitex/scholar/.legacy/metadata/doi/utils/_to_complete_metadata_structure.py +1 -0
  589. scitex/scholar/.legacy/metadata/enrichment/_LibraryEnricher.py +2 -3
  590. scitex/scholar/.legacy/metadata/enrichment/enrichers/_ImpactFactorEnricher.py +6 -12
  591. scitex/scholar/.legacy/metadata/enrichment/enrichers/_SmartEnricher.py +5 -10
  592. scitex/scholar/.legacy/metadata/enrichment/sources/_UnifiedMetadataSource.py +4 -5
  593. scitex/scholar/.legacy/metadata/query_to_full_meta_json.py +8 -12
  594. scitex/scholar/.legacy/metadata/urls/_URLMetadataHandler.py +3 -3
  595. scitex/scholar/.legacy/metadata/urls/_ZoteroTranslatorRunner.py +15 -21
  596. scitex/scholar/.legacy/metadata/urls/__init__.py +3 -3
  597. scitex/scholar/.legacy/metadata/urls/_finder.py +4 -6
  598. scitex/scholar/.legacy/metadata/urls/_handler.py +7 -15
  599. scitex/scholar/.legacy/metadata/urls/_resolver.py +6 -12
  600. scitex/scholar/.legacy/search/_Embedder.py +74 -69
  601. scitex/scholar/.legacy/search/_SemanticSearch.py +91 -90
  602. scitex/scholar/.legacy/search/_SemanticSearchEngine.py +104 -109
  603. scitex/scholar/.legacy/search/_UnifiedSearcher.py +530 -471
  604. scitex/scholar/.legacy/search/_VectorDatabase.py +111 -92
  605. scitex/scholar/.legacy/search/__init__.py +1 -0
  606. scitex/scholar/.legacy/storage/_EnhancedStorageManager.py +182 -154
  607. scitex/scholar/.legacy/storage/__init__.py +2 -1
  608. scitex/scholar/__init__.py +0 -2
  609. scitex/scholar/__main__.py +1 -3
  610. scitex/scholar/auth/ScholarAuthManager.py +13 -36
  611. scitex/scholar/auth/core/AuthenticationGateway.py +15 -29
  612. scitex/scholar/auth/core/BrowserAuthenticator.py +22 -57
  613. scitex/scholar/auth/core/StrategyResolver.py +10 -27
  614. scitex/scholar/auth/core/__init__.py +5 -1
  615. scitex/scholar/auth/gateway/_OpenURLLinkFinder.py +11 -21
  616. scitex/scholar/auth/gateway/_OpenURLResolver.py +10 -18
  617. scitex/scholar/auth/gateway/_resolve_functions.py +3 -3
  618. scitex/scholar/auth/providers/BaseAuthenticator.py +1 -0
  619. scitex/scholar/auth/providers/EZProxyAuthenticator.py +7 -14
  620. scitex/scholar/auth/providers/OpenAthensAuthenticator.py +29 -57
  621. scitex/scholar/auth/providers/ShibbolethAuthenticator.py +87 -73
  622. scitex/scholar/auth/session/AuthCacheManager.py +12 -22
  623. scitex/scholar/auth/session/SessionManager.py +4 -6
  624. scitex/scholar/auth/sso/BaseSSOAutomator.py +13 -19
  625. scitex/scholar/auth/sso/OpenAthensSSOAutomator.py +16 -45
  626. scitex/scholar/auth/sso/SSOAutomator.py +8 -15
  627. scitex/scholar/auth/sso/UniversityOfMelbourneSSOAutomator.py +13 -23
  628. scitex/scholar/browser/ScholarBrowserManager.py +31 -56
  629. scitex/scholar/browser/__init__.py +1 -0
  630. scitex/scholar/browser/utils/click_and_wait.py +3 -4
  631. scitex/scholar/browser/utils/close_unwanted_pages.py +4 -7
  632. scitex/scholar/browser/utils/wait_redirects.py +15 -40
  633. scitex/scholar/citation_graph/__init__.py +0 -0
  634. scitex/scholar/citation_graph/builder.py +3 -7
  635. scitex/scholar/citation_graph/database.py +4 -11
  636. scitex/scholar/citation_graph/example.py +5 -10
  637. scitex/scholar/citation_graph/models.py +0 -0
  638. scitex/scholar/cli/_url_utils.py +1 -1
  639. scitex/scholar/cli/chrome.py +5 -3
  640. scitex/scholar/cli/download_pdf.py +13 -14
  641. scitex/scholar/cli/handlers/bibtex_handler.py +4 -12
  642. scitex/scholar/cli/handlers/doi_handler.py +1 -3
  643. scitex/scholar/cli/handlers/project_handler.py +6 -20
  644. scitex/scholar/cli/open_browser.py +41 -39
  645. scitex/scholar/cli/open_browser_auto.py +31 -39
  646. scitex/scholar/cli/open_browser_monitored.py +27 -24
  647. scitex/scholar/config/ScholarConfig.py +5 -8
  648. scitex/scholar/config/__init__.py +1 -0
  649. scitex/scholar/config/core/_CascadeConfig.py +3 -3
  650. scitex/scholar/config/core/_PathManager.py +16 -28
  651. scitex/scholar/core/Paper.py +79 -78
  652. scitex/scholar/core/Papers.py +16 -27
  653. scitex/scholar/core/Scholar.py +98 -229
  654. scitex/scholar/core/journal_normalizer.py +52 -49
  655. scitex/scholar/core/oa_cache.py +27 -23
  656. scitex/scholar/core/open_access.py +17 -8
  657. scitex/scholar/docs/template.py +4 -3
  658. scitex/scholar/docs/to_claude/examples/example-python-project-scitex/scripts/mnist/clf_svm.py +0 -0
  659. scitex/scholar/docs/to_claude/examples/example-python-project-scitex/scripts/mnist/download.py +0 -0
  660. scitex/scholar/docs/to_claude/examples/example-python-project-scitex/scripts/mnist/plot_conf_mat.py +0 -0
  661. scitex/scholar/docs/to_claude/examples/example-python-project-scitex/scripts/mnist/plot_digits.py +0 -0
  662. scitex/scholar/docs/to_claude/examples/example-python-project-scitex/scripts/mnist/plot_umap_space.py +0 -0
  663. scitex/scholar/examples/00_config.py +10 -9
  664. scitex/scholar/examples/01_auth.py +3 -0
  665. scitex/scholar/examples/02_browser.py +14 -10
  666. scitex/scholar/examples/03_01-engine.py +3 -0
  667. scitex/scholar/examples/03_02-engine-for-bibtex.py +4 -3
  668. scitex/scholar/examples/04_01-url.py +9 -9
  669. scitex/scholar/examples/04_02-url-for-bibtex.py +7 -3
  670. scitex/scholar/examples/04_02-url-for-dois.py +87 -97
  671. scitex/scholar/examples/05_download_pdf.py +10 -4
  672. scitex/scholar/examples/06_find_and_download.py +6 -6
  673. scitex/scholar/examples/06_parse_bibtex.py +17 -17
  674. scitex/scholar/examples/07_storage_integration.py +6 -9
  675. scitex/scholar/examples/99_fullpipeline-for-bibtex.py +14 -15
  676. scitex/scholar/examples/99_fullpipeline-for-one-entry.py +31 -23
  677. scitex/scholar/examples/99_maintenance.py +3 -0
  678. scitex/scholar/examples/dev.py +2 -3
  679. scitex/scholar/examples/zotero_integration.py +11 -18
  680. scitex/scholar/impact_factor/ImpactFactorEngine.py +7 -9
  681. scitex/scholar/impact_factor/estimation/__init__.py +4 -4
  682. scitex/scholar/impact_factor/estimation/core/__init__.py +3 -7
  683. scitex/scholar/impact_factor/estimation/core/cache_manager.py +223 -211
  684. scitex/scholar/impact_factor/estimation/core/calculator.py +165 -131
  685. scitex/scholar/impact_factor/estimation/core/journal_matcher.py +217 -172
  686. scitex/scholar/impact_factor/jcr/ImpactFactorJCREngine.py +6 -14
  687. scitex/scholar/impact_factor/jcr/build_database.py +4 -3
  688. scitex/scholar/integration/base.py +9 -17
  689. scitex/scholar/integration/mendeley/exporter.py +2 -4
  690. scitex/scholar/integration/mendeley/importer.py +3 -3
  691. scitex/scholar/integration/mendeley/linker.py +3 -3
  692. scitex/scholar/integration/mendeley/mapper.py +9 -6
  693. scitex/scholar/integration/zotero/__main__.py +26 -43
  694. scitex/scholar/integration/zotero/exporter.py +15 -11
  695. scitex/scholar/integration/zotero/importer.py +12 -10
  696. scitex/scholar/integration/zotero/linker.py +8 -12
  697. scitex/scholar/integration/zotero/mapper.py +17 -12
  698. scitex/scholar/metadata_engines/.combined-SemanticScholarSource/_SemanticScholarSource.py +37 -35
  699. scitex/scholar/metadata_engines/.combined-SemanticScholarSource/_SemanticScholarSourceEnhanced.py +47 -35
  700. scitex/scholar/metadata_engines/ScholarEngine.py +21 -43
  701. scitex/scholar/metadata_engines/__init__.py +1 -0
  702. scitex/scholar/metadata_engines/individual/ArXivEngine.py +15 -37
  703. scitex/scholar/metadata_engines/individual/CrossRefEngine.py +15 -42
  704. scitex/scholar/metadata_engines/individual/CrossRefLocalEngine.py +24 -45
  705. scitex/scholar/metadata_engines/individual/OpenAlexEngine.py +11 -21
  706. scitex/scholar/metadata_engines/individual/PubMedEngine.py +10 -27
  707. scitex/scholar/metadata_engines/individual/SemanticScholarEngine.py +28 -35
  708. scitex/scholar/metadata_engines/individual/URLDOIEngine.py +11 -22
  709. scitex/scholar/metadata_engines/individual/_BaseDOIEngine.py +20 -49
  710. scitex/scholar/metadata_engines/utils/_PubMedConverter.py +4 -8
  711. scitex/scholar/metadata_engines/utils/_URLDOIExtractor.py +5 -10
  712. scitex/scholar/metadata_engines/utils/__init__.py +2 -0
  713. scitex/scholar/metadata_engines/utils/_metadata2bibtex.py +3 -0
  714. scitex/scholar/metadata_engines/utils/_standardize_metadata.py +2 -3
  715. scitex/scholar/pdf_download/ScholarPDFDownloader.py +25 -37
  716. scitex/scholar/pdf_download/strategies/chrome_pdf_viewer.py +11 -19
  717. scitex/scholar/pdf_download/strategies/direct_download.py +5 -9
  718. scitex/scholar/pdf_download/strategies/manual_download_fallback.py +3 -3
  719. scitex/scholar/pdf_download/strategies/manual_download_utils.py +6 -13
  720. scitex/scholar/pdf_download/strategies/open_access_download.py +49 -31
  721. scitex/scholar/pdf_download/strategies/response_body.py +8 -19
  722. scitex/scholar/pipelines/ScholarPipelineBibTeX.py +9 -18
  723. scitex/scholar/pipelines/ScholarPipelineMetadataParallel.py +25 -26
  724. scitex/scholar/pipelines/ScholarPipelineMetadataSingle.py +62 -23
  725. scitex/scholar/pipelines/ScholarPipelineParallel.py +13 -30
  726. scitex/scholar/pipelines/ScholarPipelineSearchParallel.py +299 -220
  727. scitex/scholar/pipelines/ScholarPipelineSearchSingle.py +202 -165
  728. scitex/scholar/pipelines/ScholarPipelineSingle.py +25 -51
  729. scitex/scholar/pipelines/SearchQueryParser.py +55 -55
  730. scitex/scholar/search_engines/ScholarSearchEngine.py +31 -27
  731. scitex/scholar/search_engines/_BaseSearchEngine.py +20 -23
  732. scitex/scholar/search_engines/individual/ArXivSearchEngine.py +53 -35
  733. scitex/scholar/search_engines/individual/CrossRefSearchEngine.py +47 -40
  734. scitex/scholar/search_engines/individual/OpenAlexSearchEngine.py +55 -50
  735. scitex/scholar/search_engines/individual/PubMedSearchEngine.py +8 -10
  736. scitex/scholar/search_engines/individual/SemanticScholarSearchEngine.py +55 -49
  737. scitex/scholar/storage/BibTeXHandler.py +150 -95
  738. scitex/scholar/storage/PaperIO.py +3 -6
  739. scitex/scholar/storage/ScholarLibrary.py +70 -49
  740. scitex/scholar/storage/_DeduplicationManager.py +52 -25
  741. scitex/scholar/storage/_LibraryCacheManager.py +19 -46
  742. scitex/scholar/storage/_LibraryManager.py +65 -175
  743. scitex/scholar/url_finder/ScholarURLFinder.py +9 -25
  744. scitex/scholar/url_finder/strategies/find_pdf_urls_by_direct_links.py +1 -1
  745. scitex/scholar/url_finder/strategies/find_pdf_urls_by_href.py +6 -10
  746. scitex/scholar/url_finder/strategies/find_pdf_urls_by_navigation.py +4 -6
  747. scitex/scholar/url_finder/strategies/find_pdf_urls_by_publisher_patterns.py +8 -15
  748. scitex/scholar/url_finder/strategies/find_pdf_urls_by_zotero_translators.py +3 -3
  749. scitex/scholar/url_finder/strategies/find_supplementary_urls_by_href.py +3 -3
  750. scitex/scholar/url_finder/translators/core/patterns.py +6 -4
  751. scitex/scholar/url_finder/translators/core/registry.py +6 -9
  752. scitex/scholar/url_finder/translators/individual/BOFiP_Impots.py +60 -52
  753. scitex/scholar/url_finder/translators/individual/Baidu_Scholar.py +54 -62
  754. scitex/scholar/url_finder/translators/individual/Bangkok_Post.py +38 -44
  755. scitex/scholar/url_finder/translators/individual/Baruch_Foundation.py +43 -47
  756. scitex/scholar/url_finder/translators/individual/Beobachter.py +46 -50
  757. scitex/scholar/url_finder/translators/individual/Bezneng_Gajit.py +37 -41
  758. scitex/scholar/url_finder/translators/individual/BibLaTeX.py +59 -52
  759. scitex/scholar/url_finder/translators/individual/BibTeX.py +83 -79
  760. scitex/scholar/url_finder/translators/individual/Biblio_com.py +48 -51
  761. scitex/scholar/url_finder/translators/individual/Bibliontology_RDF.py +58 -56
  762. scitex/scholar/url_finder/translators/individual/Camara_Brasileira_do_Livro_ISBN.py +102 -99
  763. scitex/scholar/url_finder/translators/individual/CanLII.py +49 -43
  764. scitex/scholar/url_finder/translators/individual/Canada_com.py +36 -40
  765. scitex/scholar/url_finder/translators/individual/Canadian_Letters_and_Images.py +43 -43
  766. scitex/scholar/url_finder/translators/individual/Canadiana_ca.py +77 -66
  767. scitex/scholar/url_finder/translators/individual/Cascadilla_Proceedings_Project.py +68 -62
  768. scitex/scholar/url_finder/translators/individual/Central_and_Eastern_European_Online_Library_Journals.py +60 -60
  769. scitex/scholar/url_finder/translators/individual/Champlain_Society_Collection.py +63 -61
  770. scitex/scholar/url_finder/translators/individual/Chicago_Journal_of_Theoretical_Computer_Science.py +74 -58
  771. scitex/scholar/url_finder/translators/individual/Christian_Science_Monitor.py +32 -38
  772. scitex/scholar/url_finder/translators/individual/Columbia_University_Press.py +51 -47
  773. scitex/scholar/url_finder/translators/individual/Common_Place.py +66 -57
  774. scitex/scholar/url_finder/translators/individual/Cornell_LII.py +66 -62
  775. scitex/scholar/url_finder/translators/individual/Cornell_University_Press.py +38 -45
  776. scitex/scholar/url_finder/translators/individual/CourtListener.py +52 -56
  777. scitex/scholar/url_finder/translators/individual/DAI_Zenon.py +53 -54
  778. scitex/scholar/url_finder/translators/individual/access_medicine.py +27 -33
  779. scitex/scholar/url_finder/translators/individual/acm.py +1 -1
  780. scitex/scholar/url_finder/translators/individual/acm_digital_library.py +93 -63
  781. scitex/scholar/url_finder/translators/individual/airiti.py +3 -1
  782. scitex/scholar/url_finder/translators/individual/aosic.py +3 -1
  783. scitex/scholar/url_finder/translators/individual/archive_ouverte_aosic.py +3 -1
  784. scitex/scholar/url_finder/translators/individual/archive_ouverte_en_sciences_de_l_information_et_de_la_communication___aosic_.py +6 -2
  785. scitex/scholar/url_finder/translators/individual/artforum.py +35 -27
  786. scitex/scholar/url_finder/translators/individual/arxiv.py +1 -1
  787. scitex/scholar/url_finder/translators/individual/arxiv_org.py +8 -4
  788. scitex/scholar/url_finder/translators/individual/atlanta_journal_constitution.py +22 -18
  789. scitex/scholar/url_finder/translators/individual/atypon_journals.py +19 -11
  790. scitex/scholar/url_finder/translators/individual/austlii_and_nzlii.py +48 -44
  791. scitex/scholar/url_finder/translators/individual/australian_dictionary_of_biography.py +21 -17
  792. scitex/scholar/url_finder/translators/individual/bailii.py +22 -19
  793. scitex/scholar/url_finder/translators/individual/bbc.py +46 -42
  794. scitex/scholar/url_finder/translators/individual/bbc_genome.py +37 -25
  795. scitex/scholar/url_finder/translators/individual/biblioteca_nacional_de_maestros.py +24 -20
  796. scitex/scholar/url_finder/translators/individual/bibliotheque_archives_nationale_quebec_pistard.py +42 -43
  797. scitex/scholar/url_finder/translators/individual/bibliotheque_archives_nationales_quebec.py +87 -81
  798. scitex/scholar/url_finder/translators/individual/bibliotheque_nationale_france.py +39 -37
  799. scitex/scholar/url_finder/translators/individual/bibsys.py +32 -28
  800. scitex/scholar/url_finder/translators/individual/bioconductor.py +58 -52
  801. scitex/scholar/url_finder/translators/individual/biomed_central.py +23 -15
  802. scitex/scholar/url_finder/translators/individual/biorxiv.py +26 -13
  803. scitex/scholar/url_finder/translators/individual/blogger.py +39 -43
  804. scitex/scholar/url_finder/translators/individual/bloomberg.py +48 -52
  805. scitex/scholar/url_finder/translators/individual/bloomsbury_food_library.py +37 -37
  806. scitex/scholar/url_finder/translators/individual/bluesky.py +30 -28
  807. scitex/scholar/url_finder/translators/individual/bnf_isbn.py +1 -1
  808. scitex/scholar/url_finder/translators/individual/bocc.py +66 -60
  809. scitex/scholar/url_finder/translators/individual/boe.py +52 -52
  810. scitex/scholar/url_finder/translators/individual/brill.py +3 -1
  811. scitex/scholar/url_finder/translators/individual/business_standard.py +36 -38
  812. scitex/scholar/url_finder/translators/individual/cabi_cab_abstracts.py +39 -41
  813. scitex/scholar/url_finder/translators/individual/cambridge.py +3 -1
  814. scitex/scholar/url_finder/translators/individual/cambridge_core.py +30 -24
  815. scitex/scholar/url_finder/translators/individual/caod.py +50 -46
  816. scitex/scholar/url_finder/translators/individual/cbc.py +91 -67
  817. scitex/scholar/url_finder/translators/individual/ccfr_bnf.py +49 -53
  818. scitex/scholar/url_finder/translators/individual/cia_world_factbook.py +43 -33
  819. scitex/scholar/url_finder/translators/individual/crossref_rest.py +208 -174
  820. scitex/scholar/url_finder/translators/individual/current_affairs.py +29 -35
  821. scitex/scholar/url_finder/translators/individual/dabi.py +70 -66
  822. scitex/scholar/url_finder/translators/individual/dagens_nyheter.py +3 -1
  823. scitex/scholar/url_finder/translators/individual/dagstuhl.py +10 -15
  824. scitex/scholar/url_finder/translators/individual/dar_almandumah.py +13 -9
  825. scitex/scholar/url_finder/translators/individual/dart_europe.py +19 -22
  826. scitex/scholar/url_finder/translators/individual/data_gov.py +2 -2
  827. scitex/scholar/url_finder/translators/individual/databrary.py +27 -28
  828. scitex/scholar/url_finder/translators/individual/datacite_json.py +152 -137
  829. scitex/scholar/url_finder/translators/individual/dataverse.py +68 -64
  830. scitex/scholar/url_finder/translators/individual/daum_news.py +38 -38
  831. scitex/scholar/url_finder/translators/individual/dblp.py +4 -8
  832. scitex/scholar/url_finder/translators/individual/dblp_computer_science_bibliography.py +8 -3
  833. scitex/scholar/url_finder/translators/individual/dbpia.py +5 -3
  834. scitex/scholar/url_finder/translators/individual/defense_technical_information_center.py +30 -28
  835. scitex/scholar/url_finder/translators/individual/delpher.py +102 -79
  836. scitex/scholar/url_finder/translators/individual/demographic_research.py +35 -31
  837. scitex/scholar/url_finder/translators/individual/denik_cz.py +58 -54
  838. scitex/scholar/url_finder/translators/individual/depatisnet.py +7 -10
  839. scitex/scholar/url_finder/translators/individual/der_freitag.py +81 -66
  840. scitex/scholar/url_finder/translators/individual/der_spiegel.py +56 -54
  841. scitex/scholar/url_finder/translators/individual/digibib_net.py +3 -1
  842. scitex/scholar/url_finder/translators/individual/digizeitschriften.py +3 -1
  843. scitex/scholar/url_finder/translators/individual/dpla.py +13 -14
  844. scitex/scholar/url_finder/translators/individual/dspace.py +2 -2
  845. scitex/scholar/url_finder/translators/individual/ebrary.py +3 -1
  846. scitex/scholar/url_finder/translators/individual/ebscohost.py +3 -1
  847. scitex/scholar/url_finder/translators/individual/electronic_colloquium_on_computational_complexity.py +3 -1
  848. scitex/scholar/url_finder/translators/individual/elife.py +3 -1
  849. scitex/scholar/url_finder/translators/individual/elsevier_health_journals.py +3 -1
  850. scitex/scholar/url_finder/translators/individual/emerald.py +3 -1
  851. scitex/scholar/url_finder/translators/individual/emerald_insight.py +3 -1
  852. scitex/scholar/url_finder/translators/individual/epicurious.py +3 -1
  853. scitex/scholar/url_finder/translators/individual/eurogamerusgamer.py +3 -1
  854. scitex/scholar/url_finder/translators/individual/fachportal_padagogik.py +3 -1
  855. scitex/scholar/url_finder/translators/individual/frontiers.py +1 -1
  856. scitex/scholar/url_finder/translators/individual/gale_databases.py +3 -1
  857. scitex/scholar/url_finder/translators/individual/gms_german_medical_science.py +6 -2
  858. scitex/scholar/url_finder/translators/individual/ieee_computer_society.py +6 -2
  859. scitex/scholar/url_finder/translators/individual/ieee_xplore.py +41 -35
  860. scitex/scholar/url_finder/translators/individual/inter_research_science_center.py +6 -2
  861. scitex/scholar/url_finder/translators/individual/jisc_historical_texts.py +3 -1
  862. scitex/scholar/url_finder/translators/individual/jstor.py +14 -12
  863. scitex/scholar/url_finder/translators/individual/korean_national_library.py +3 -1
  864. scitex/scholar/url_finder/translators/individual/la_times.py +3 -1
  865. scitex/scholar/url_finder/translators/individual/landesbibliographie_baden_wurttemberg.py +3 -1
  866. scitex/scholar/url_finder/translators/individual/legislative_insight.py +3 -1
  867. scitex/scholar/url_finder/translators/individual/libraries_tasmania.py +3 -1
  868. scitex/scholar/url_finder/translators/individual/library_catalog__koha_.py +3 -1
  869. scitex/scholar/url_finder/translators/individual/lingbuzz.py +2 -2
  870. scitex/scholar/url_finder/translators/individual/max_planck_institute_for_the_history_of_science_virtual_laboratory_library.py +3 -1
  871. scitex/scholar/url_finder/translators/individual/mdpi.py +12 -6
  872. scitex/scholar/url_finder/translators/individual/microbiology_society_journals.py +3 -1
  873. scitex/scholar/url_finder/translators/individual/midas_journals.py +3 -1
  874. scitex/scholar/url_finder/translators/individual/nagoya_university_opac.py +3 -1
  875. scitex/scholar/url_finder/translators/individual/nature_publishing_group.py +32 -19
  876. scitex/scholar/url_finder/translators/individual/ntsb_accident_reports.py +3 -1
  877. scitex/scholar/url_finder/translators/individual/openedition_journals.py +8 -4
  878. scitex/scholar/url_finder/translators/individual/orcid.py +16 -15
  879. scitex/scholar/url_finder/translators/individual/oxford.py +25 -19
  880. scitex/scholar/url_finder/translators/individual/oxford_dictionaries_premium.py +3 -1
  881. scitex/scholar/url_finder/translators/individual/ozon_ru.py +3 -1
  882. scitex/scholar/url_finder/translators/individual/plos.py +9 -12
  883. scitex/scholar/url_finder/translators/individual/polygon.py +3 -1
  884. scitex/scholar/url_finder/translators/individual/primo.py +3 -1
  885. scitex/scholar/url_finder/translators/individual/project_muse.py +3 -1
  886. scitex/scholar/url_finder/translators/individual/pubfactory_journals.py +3 -1
  887. scitex/scholar/url_finder/translators/individual/pubmed.py +71 -65
  888. scitex/scholar/url_finder/translators/individual/pubmed_central.py +8 -6
  889. scitex/scholar/url_finder/translators/individual/rechtspraak_nl.py +3 -1
  890. scitex/scholar/url_finder/translators/individual/sage_journals.py +25 -17
  891. scitex/scholar/url_finder/translators/individual/sciencedirect.py +36 -17
  892. scitex/scholar/url_finder/translators/individual/semantics_visual_library.py +3 -1
  893. scitex/scholar/url_finder/translators/individual/silverchair.py +70 -52
  894. scitex/scholar/url_finder/translators/individual/sora.py +3 -1
  895. scitex/scholar/url_finder/translators/individual/springer.py +15 -11
  896. scitex/scholar/url_finder/translators/individual/ssrn.py +3 -3
  897. scitex/scholar/url_finder/translators/individual/stanford_encyclopedia_of_philosophy.py +3 -1
  898. scitex/scholar/url_finder/translators/individual/superlib.py +3 -1
  899. scitex/scholar/url_finder/translators/individual/treesearch.py +3 -1
  900. scitex/scholar/url_finder/translators/individual/university_of_chicago_press_books.py +3 -1
  901. scitex/scholar/url_finder/translators/individual/vlex.py +3 -1
  902. scitex/scholar/url_finder/translators/individual/web_of_science.py +3 -1
  903. scitex/scholar/url_finder/translators/individual/web_of_science_nextgen.py +3 -1
  904. scitex/scholar/url_finder/translators/individual/wiley.py +31 -25
  905. scitex/scholar/url_finder/translators/individual/wilson_center_digital_archive.py +3 -1
  906. scitex/scholar/utils/bibtex/_parse_bibtex.py +3 -3
  907. scitex/scholar/utils/cleanup/_cleanup_scholar_processes.py +5 -9
  908. scitex/scholar/utils/text/_TextNormalizer.py +249 -176
  909. scitex/scholar/utils/validation/DOIValidator.py +31 -28
  910. scitex/scholar/utils/validation/__init__.py +0 -0
  911. scitex/scholar/utils/validation/validate_library_dois.py +61 -57
  912. scitex/scholar/zotero/__init__.py +1 -1
  913. scitex/security/cli.py +7 -20
  914. scitex/security/github.py +45 -32
  915. scitex/session/__init__.py +8 -9
  916. scitex/session/_decorator.py +49 -42
  917. scitex/session/_lifecycle.py +39 -39
  918. scitex/session/_manager.py +24 -20
  919. scitex/sh/__init__.py +4 -3
  920. scitex/sh/_execute.py +10 -7
  921. scitex/sh/_security.py +3 -3
  922. scitex/sh/_types.py +2 -3
  923. scitex/stats/__init__.py +57 -6
  924. scitex/stats/_schema.py +42 -569
  925. scitex/stats/auto/__init__.py +188 -0
  926. scitex/stats/auto/_context.py +331 -0
  927. scitex/stats/auto/_formatting.py +679 -0
  928. scitex/stats/auto/_rules.py +901 -0
  929. scitex/stats/auto/_selector.py +554 -0
  930. scitex/stats/auto/_styles.py +721 -0
  931. scitex/stats/correct/__init__.py +4 -4
  932. scitex/stats/correct/_correct_bonferroni.py +43 -34
  933. scitex/stats/correct/_correct_fdr.py +14 -40
  934. scitex/stats/correct/_correct_fdr_.py +39 -46
  935. scitex/stats/correct/_correct_holm.py +14 -32
  936. scitex/stats/correct/_correct_sidak.py +36 -21
  937. scitex/stats/descriptive/_circular.py +20 -21
  938. scitex/stats/descriptive/_describe.py +19 -5
  939. scitex/stats/descriptive/_nan.py +5 -7
  940. scitex/stats/descriptive/_real.py +4 -3
  941. scitex/stats/effect_sizes/__init__.py +10 -11
  942. scitex/stats/effect_sizes/_cliffs_delta.py +35 -32
  943. scitex/stats/effect_sizes/_cohens_d.py +30 -31
  944. scitex/stats/effect_sizes/_epsilon_squared.py +19 -22
  945. scitex/stats/effect_sizes/_eta_squared.py +23 -27
  946. scitex/stats/effect_sizes/_prob_superiority.py +18 -21
  947. scitex/stats/posthoc/__init__.py +3 -3
  948. scitex/stats/posthoc/_dunnett.py +75 -55
  949. scitex/stats/posthoc/_games_howell.py +61 -43
  950. scitex/stats/posthoc/_tukey_hsd.py +42 -34
  951. scitex/stats/power/__init__.py +2 -2
  952. scitex/stats/power/_power.py +56 -56
  953. scitex/stats/tests/__init__.py +1 -1
  954. scitex/stats/tests/correlation/__init__.py +1 -1
  955. scitex/stats/tests/correlation/_test_pearson.py +28 -38
  956. scitex/stats/utils/__init__.py +14 -17
  957. scitex/stats/utils/_effect_size.py +85 -78
  958. scitex/stats/utils/_formatters.py +49 -43
  959. scitex/stats/utils/_normalizers.py +7 -14
  960. scitex/stats/utils/_power.py +56 -56
  961. scitex/str/__init__.py +1 -0
  962. scitex/str/_clean_path.py +3 -3
  963. scitex/str/_factor_out_digits.py +86 -58
  964. scitex/str/_format_plot_text.py +180 -111
  965. scitex/str/_latex.py +19 -19
  966. scitex/str/_latex_fallback.py +9 -10
  967. scitex/str/_parse.py +3 -6
  968. scitex/str/_print_debug.py +13 -13
  969. scitex/str/_printc.py +2 -0
  970. scitex/str/_search.py +3 -3
  971. scitex/template/.legacy/_clone_project.py +9 -13
  972. scitex/template/__init__.py +10 -2
  973. scitex/template/_clone_project.py +7 -2
  974. scitex/template/_copy.py +1 -0
  975. scitex/template/_customize.py +3 -6
  976. scitex/template/_git_strategy.py +2 -3
  977. scitex/template/_rename.py +1 -0
  978. scitex/template/clone_pip_project.py +6 -7
  979. scitex/template/clone_research.py +7 -10
  980. scitex/template/clone_singularity.py +6 -7
  981. scitex/template/clone_writer_directory.py +6 -7
  982. scitex/tex/_preview.py +26 -11
  983. scitex/tex/_to_vec.py +10 -7
  984. scitex/torch/__init__.py +11 -1
  985. scitex/types/_ArrayLike.py +2 -0
  986. scitex/types/_is_listed_X.py +3 -3
  987. scitex/units.py +110 -77
  988. scitex/utils/_compress_hdf5.py +3 -3
  989. scitex/utils/_email.py +8 -4
  990. scitex/utils/_notify.py +14 -8
  991. scitex/utils/_search.py +6 -6
  992. scitex/utils/_verify_scitex_format.py +17 -42
  993. scitex/utils/_verify_scitex_format_v01.py +12 -34
  994. scitex/utils/template.py +4 -3
  995. scitex/vis/__init__.py +0 -0
  996. scitex/vis/backend/__init__.py +3 -3
  997. scitex/vis/backend/{export.py → _export.py} +1 -1
  998. scitex/vis/backend/{parser.py → _parser.py} +1 -3
  999. scitex/vis/backend/{render.py → _render.py} +1 -1
  1000. scitex/vis/canvas.py +15 -3
  1001. scitex/vis/editor/__init__.py +0 -0
  1002. scitex/vis/editor/_dearpygui_editor.py +450 -304
  1003. scitex/vis/editor/_defaults.py +114 -123
  1004. scitex/vis/editor/_edit.py +38 -26
  1005. scitex/vis/editor/_flask_editor.py +8 -8
  1006. scitex/vis/editor/_mpl_editor.py +63 -48
  1007. scitex/vis/editor/_qt_editor.py +210 -159
  1008. scitex/vis/editor/_tkinter_editor.py +146 -89
  1009. scitex/vis/editor/flask_editor/__init__.py +10 -10
  1010. scitex/vis/editor/flask_editor/_bbox.py +529 -0
  1011. scitex/vis/editor/flask_editor/{core.py → _core.py} +45 -29
  1012. scitex/vis/editor/flask_editor/_plotter.py +567 -0
  1013. scitex/vis/editor/flask_editor/_renderer.py +393 -0
  1014. scitex/vis/editor/flask_editor/{utils.py → _utils.py} +13 -14
  1015. scitex/vis/editor/flask_editor/templates/__init__.py +5 -5
  1016. scitex/vis/editor/flask_editor/templates/{html.py → _html.py} +234 -16
  1017. scitex/vis/editor/flask_editor/templates/_scripts.py +1261 -0
  1018. scitex/vis/editor/flask_editor/templates/{styles.py → _styles.py} +192 -2
  1019. scitex/vis/io/__init__.py +5 -5
  1020. scitex/vis/io/{canvas.py → _canvas.py} +8 -4
  1021. scitex/vis/io/{data.py → _data.py} +13 -9
  1022. scitex/vis/io/{directory.py → _directory.py} +7 -4
  1023. scitex/vis/io/{export.py → _export.py} +15 -12
  1024. scitex/vis/io/{load.py → _load.py} +1 -1
  1025. scitex/vis/io/{panel.py → _panel.py} +21 -13
  1026. scitex/vis/io/{save.py → _save.py} +0 -0
  1027. scitex/vis/model/__init__.py +7 -7
  1028. scitex/vis/model/{annotations.py → _annotations.py} +2 -4
  1029. scitex/vis/model/{axes.py → _axes.py} +1 -1
  1030. scitex/vis/model/{figure.py → _figure.py} +0 -0
  1031. scitex/vis/model/{guides.py → _guides.py} +1 -1
  1032. scitex/vis/model/{plot.py → _plot.py} +2 -4
  1033. scitex/vis/model/{plot_types.py → _plot_types.py} +0 -0
  1034. scitex/vis/model/{styles.py → _styles.py} +0 -0
  1035. scitex/vis/utils/__init__.py +2 -2
  1036. scitex/vis/utils/{defaults.py → _defaults.py} +1 -2
  1037. scitex/vis/utils/{validate.py → _validate.py} +3 -9
  1038. scitex/web/__init__.py +7 -1
  1039. scitex/web/_scraping.py +54 -38
  1040. scitex/web/_search_pubmed.py +30 -14
  1041. scitex/writer/.legacy/Writer_v01-refactored.py +4 -4
  1042. scitex/writer/.legacy/_compile.py +18 -28
  1043. scitex/writer/Writer.py +8 -21
  1044. scitex/writer/__init__.py +11 -11
  1045. scitex/writer/_clone_writer_project.py +2 -6
  1046. scitex/writer/_compile/__init__.py +1 -0
  1047. scitex/writer/_compile/_parser.py +1 -0
  1048. scitex/writer/_compile/_runner.py +35 -38
  1049. scitex/writer/_compile/_validator.py +1 -0
  1050. scitex/writer/_compile/manuscript.py +1 -0
  1051. scitex/writer/_compile/revision.py +1 -0
  1052. scitex/writer/_compile/supplementary.py +1 -0
  1053. scitex/writer/_compile_async.py +5 -12
  1054. scitex/writer/_project/__init__.py +1 -0
  1055. scitex/writer/_project/_create.py +10 -25
  1056. scitex/writer/_project/_trees.py +4 -9
  1057. scitex/writer/_project/_validate.py +2 -3
  1058. scitex/writer/_validate_tree_structures.py +7 -18
  1059. scitex/writer/dataclasses/__init__.py +8 -10
  1060. scitex/writer/dataclasses/config/_CONSTANTS.py +2 -3
  1061. scitex/writer/dataclasses/config/_WriterConfig.py +4 -9
  1062. scitex/writer/dataclasses/contents/_ManuscriptContents.py +14 -25
  1063. scitex/writer/dataclasses/contents/_RevisionContents.py +21 -16
  1064. scitex/writer/dataclasses/contents/_SupplementaryContents.py +21 -24
  1065. scitex/writer/dataclasses/core/_Document.py +2 -3
  1066. scitex/writer/dataclasses/core/_DocumentSection.py +8 -23
  1067. scitex/writer/dataclasses/results/_CompilationResult.py +2 -3
  1068. scitex/writer/dataclasses/results/_LaTeXIssue.py +3 -6
  1069. scitex/writer/dataclasses/results/_SaveSectionsResponse.py +20 -9
  1070. scitex/writer/dataclasses/results/_SectionReadResponse.py +24 -10
  1071. scitex/writer/dataclasses/tree/_ConfigTree.py +7 -4
  1072. scitex/writer/dataclasses/tree/_ManuscriptTree.py +10 -13
  1073. scitex/writer/dataclasses/tree/_RevisionTree.py +16 -17
  1074. scitex/writer/dataclasses/tree/_ScriptsTree.py +10 -5
  1075. scitex/writer/dataclasses/tree/_SharedTree.py +10 -13
  1076. scitex/writer/dataclasses/tree/_SupplementaryTree.py +15 -14
  1077. scitex/writer/utils/.legacy_git_retry.py +3 -8
  1078. scitex/writer/utils/_parse_latex_logs.py +2 -3
  1079. scitex/writer/utils/_parse_script_args.py +20 -23
  1080. scitex/writer/utils/_watch.py +5 -5
  1081. {scitex-2.5.0.dist-info → scitex-2.7.0.dist-info}/METADATA +4 -10
  1082. {scitex-2.5.0.dist-info → scitex-2.7.0.dist-info}/RECORD +1071 -975
  1083. scitex/db/_sqlite3/_SQLite3Mixins/_ColumnMixin_v01-indentation-issues.py +0 -583
  1084. scitex/plt/_subplots/_export_as_csv_formatters.py +0 -112
  1085. scitex/vis/editor/flask_editor/bbox.py +0 -216
  1086. scitex/vis/editor/flask_editor/plotter.py +0 -130
  1087. scitex/vis/editor/flask_editor/renderer.py +0 -184
  1088. scitex/vis/editor/flask_editor/templates/scripts.py +0 -614
  1089. {scitex-2.5.0.dist-info → scitex-2.7.0.dist-info}/WHEEL +0 -0
  1090. {scitex-2.5.0.dist-info → scitex-2.7.0.dist-info}/entry_points.txt +0 -0
  1091. {scitex-2.5.0.dist-info → scitex-2.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -13,23 +13,320 @@ figures self-documenting and reproducible.
13
13
 
14
14
  __FILE__ = __file__
15
15
 
16
- from typing import Dict, Optional
16
+ from typing import Dict, Optional, Union, List
17
17
 
18
+ # Precision settings for JSON output
19
+ PRECISION = {
20
+ "mm": 2, # Millimeters: 0.01mm precision (10 microns)
21
+ "inch": 3, # Inches: 0.001 inch precision
22
+ "position": 3, # Normalized position: 0.001 precision
23
+ "lim": 2, # Axis limits: 2 decimal places
24
+ "linewidth": 2, # Line widths: 0.01 precision
25
+ }
18
26
 
19
- def collect_figure_metadata(fig, ax=None, plot_id=None) -> Dict:
27
+
28
+ class FixedFloat:
29
+ """
30
+ A float wrapper that preserves fixed decimal places in JSON output.
31
+
32
+ Example: FixedFloat(0.25, 3) -> "0.250" in JSON
33
+ """
34
+ def __init__(self, value: float, precision: int):
35
+ self.value = round(value, precision)
36
+ self.precision = precision
37
+
38
+ def __repr__(self):
39
+ return f"{self.value:.{self.precision}f}"
40
+
41
+ def __float__(self):
42
+ return self.value
43
+
44
+
45
+ def _round_value(value: Union[float, int], precision: int, fixed: bool = False) -> Union[float, int, "FixedFloat"]:
46
+ """
47
+ Round a single value to specified precision.
48
+
49
+ Parameters
50
+ ----------
51
+ value : float or int
52
+ Value to round
53
+ precision : int
54
+ Number of decimal places
55
+ fixed : bool
56
+ If True, return FixedFloat with fixed decimal places (e.g., 0.250)
57
+ If False, return float (e.g., 0.25)
58
+ """
59
+ if isinstance(value, int):
60
+ if fixed:
61
+ return FixedFloat(float(value), precision)
62
+ return value
63
+ if isinstance(value, float):
64
+ if fixed:
65
+ return FixedFloat(value, precision)
66
+ return round(value, precision)
67
+ return value
68
+
69
+
70
+ def _round_list(values: List, precision: int, fixed: bool = False) -> List:
71
+ """Round all values in a list."""
72
+ return [_round_value(v, precision, fixed) for v in values]
73
+
74
+
75
+ def _round_dict(d: dict, precision_map: dict = None) -> dict:
76
+ """
77
+ Round all float values in a dict based on key-specific precision.
78
+
79
+ Parameters
80
+ ----------
81
+ d : dict
82
+ Dictionary to process
83
+ precision_map : dict, optional
84
+ Mapping of key patterns to precision values.
85
+ Default uses PRECISION settings based on key names.
86
+ """
87
+ if precision_map is None:
88
+ precision_map = {}
89
+
90
+ result = {}
91
+ for key, value in d.items():
92
+ # Determine precision based on key name
93
+ if "mm" in key.lower():
94
+ prec = PRECISION["mm"]
95
+ elif "inch" in key.lower():
96
+ prec = PRECISION["inch"]
97
+ elif "position" in key.lower() or key in ("left", "bottom", "right", "top"):
98
+ prec = PRECISION["position"]
99
+ elif "lim" in key.lower():
100
+ prec = PRECISION["lim"]
101
+ elif "width" in key.lower() and "line" in key.lower():
102
+ prec = PRECISION["linewidth"]
103
+ else:
104
+ prec = precision_map.get(key, 3) # Default 3 decimals
105
+
106
+ if isinstance(value, dict):
107
+ result[key] = _round_dict(value, precision_map)
108
+ elif isinstance(value, list):
109
+ result[key] = _round_list(value, prec)
110
+ elif isinstance(value, float):
111
+ result[key] = _round_value(value, prec)
112
+ else:
113
+ result[key] = value
114
+
115
+ return result
116
+
117
+
118
+ def _collect_single_axes_metadata(fig, ax, ax_index: int) -> dict:
119
+ """
120
+ Collect metadata for a single axes object.
121
+
122
+ Parameters
123
+ ----------
124
+ fig : matplotlib.figure.Figure
125
+ The parent figure
126
+ ax : matplotlib.axes.Axes
127
+ The axes to collect metadata from
128
+ ax_index : int
129
+ Index of this axes in the figure (for position tracking)
130
+
131
+ Returns
132
+ -------
133
+ dict
134
+ Metadata dictionary for this axes containing:
135
+ - size_mm, size_inch, size_px
136
+ - position_ratio
137
+ - position_in_grid
138
+ - margins_mm, margins_inch
139
+ - bbox_mm, bbox_inch, bbox_px
140
+ - x_axis_bottom, y_axis_left (axis info)
141
+ """
142
+ ax_metadata = {}
143
+
144
+ try:
145
+ from ._figure_from_axes_mm import get_dimension_info
146
+
147
+ dim_info = get_dimension_info(fig, ax)
148
+
149
+ # Size in multiple units
150
+ ax_metadata["size_mm"] = dim_info.get("axes_size_mm", [])
151
+ if "axes_size_inch" in dim_info:
152
+ ax_metadata["size_inch"] = dim_info["axes_size_inch"]
153
+ if "axes_size_px" in dim_info:
154
+ ax_metadata["size_px"] = dim_info["axes_size_px"]
155
+
156
+ # Position in figure coordinates (normalized 0-1 values)
157
+ # Uses matplotlib terminology: bounds_figure_fraction
158
+ if "axes_position" in dim_info:
159
+ ax_metadata["bounds_figure_fraction"] = list(dim_info["axes_position"])
160
+
161
+ # Position in grid (row, col)
162
+ if hasattr(ax, "_scitex_metadata") and "position_in_grid" in ax._scitex_metadata:
163
+ ax_metadata["position_in_grid"] = ax._scitex_metadata["position_in_grid"]
164
+ else:
165
+ # Calculate from ax_index if we have grid info
166
+ ax_metadata["position_in_grid"] = [ax_index, 0] # Default single column
167
+
168
+ # Margins in mm and inch
169
+ if "margins_mm" in dim_info:
170
+ ax_metadata["margins_mm"] = dim_info["margins_mm"]
171
+ if "margins_inch" in dim_info:
172
+ ax_metadata["margins_inch"] = dim_info["margins_inch"]
173
+
174
+ # Bounding box with intuitive keys
175
+ if "axes_bbox_px" in dim_info:
176
+ bbox = dim_info["axes_bbox_px"]
177
+ # Convert from x0/y0/x1/y1 to x_left/y_bottom/x_right/y_top
178
+ ax_metadata["bbox_px"] = {
179
+ "x_left": bbox.get("x0", bbox.get("x_left", 0)),
180
+ "x_right": bbox.get("x1", bbox.get("x_right", 0)),
181
+ "y_top": bbox.get("y0", bbox.get("y_top", 0)),
182
+ "y_bottom": bbox.get("y1", bbox.get("y_bottom", 0)),
183
+ "width": bbox.get("width", 0),
184
+ "height": bbox.get("height", 0),
185
+ }
186
+ if "axes_bbox_mm" in dim_info:
187
+ bbox = dim_info["axes_bbox_mm"]
188
+ ax_metadata["bbox_mm"] = {
189
+ "x_left": bbox.get("x0", bbox.get("x_left", 0)),
190
+ "x_right": bbox.get("x1", bbox.get("x_right", 0)),
191
+ "y_top": bbox.get("y0", bbox.get("y_top", 0)),
192
+ "y_bottom": bbox.get("y1", bbox.get("y_bottom", 0)),
193
+ "width": bbox.get("width", 0),
194
+ "height": bbox.get("height", 0),
195
+ }
196
+ if "axes_bbox_inch" in dim_info:
197
+ bbox = dim_info["axes_bbox_inch"]
198
+ ax_metadata["bbox_inch"] = {
199
+ "x_left": bbox.get("x0", bbox.get("x_left", 0)),
200
+ "x_right": bbox.get("x1", bbox.get("x_right", 0)),
201
+ "y_top": bbox.get("y0", bbox.get("y_top", 0)),
202
+ "y_bottom": bbox.get("y1", bbox.get("y_bottom", 0)),
203
+ "width": bbox.get("width", 0),
204
+ "height": bbox.get("height", 0),
205
+ }
206
+
207
+ except Exception as e:
208
+ import warnings
209
+ warnings.warn(f"Could not extract dimension info for axes {ax_index}: {e}")
210
+
211
+ # Extract axes labels and units
212
+ # X-axis - using matplotlib terminology (xaxis)
213
+ xlabel = ax.get_xlabel()
214
+ x_label, x_unit = _parse_label_unit(xlabel)
215
+ ax_metadata["xaxis"] = {
216
+ "label": x_label,
217
+ "unit": x_unit,
218
+ "scale": ax.get_xscale(),
219
+ "lim": list(ax.get_xlim()),
220
+ }
221
+
222
+ # Y-axis - using matplotlib terminology (yaxis)
223
+ ylabel = ax.get_ylabel()
224
+ y_label, y_unit = _parse_label_unit(ylabel)
225
+ ax_metadata["yaxis"] = {
226
+ "label": y_label,
227
+ "unit": y_unit,
228
+ "scale": ax.get_yscale(),
229
+ "lim": list(ax.get_ylim()),
230
+ }
231
+
232
+ return ax_metadata
233
+
234
+
235
+ def _restructure_style(flat_style: dict) -> dict:
236
+ """
237
+ Restructure flat style_mm dict into hierarchical structure with explicit scopes.
238
+
239
+ Converts:
240
+ {"axis_thickness_mm": 0.2, "tick_length_mm": 0.8, ...}
241
+ To:
242
+ {
243
+ "global": {"fonts": {...}, "padding": {...}},
244
+ "axes_default": {"axes": {...}, "ticks": {...}},
245
+ "artist_default": {"lines": {...}, "markers": {...}}
246
+ }
247
+
248
+ Style scopes:
249
+ - global: rcParams-like settings (fonts, padding) applied to entire figure
250
+ - axes_default: default axes appearance (can be overridden per-axes)
251
+ - artist_default: default artist appearance (can be overridden per-artist)
252
+ """
253
+ result = {
254
+ "global": {
255
+ "fonts": {},
256
+ "padding": {},
257
+ },
258
+ "axes_default": {
259
+ "axes": {},
260
+ "ticks": {},
261
+ },
262
+ "artist_default": {
263
+ "lines": {},
264
+ "markers": {},
265
+ },
266
+ }
267
+
268
+ # Mapping from flat keys to hierarchical structure (scope, category, key)
269
+ key_mapping = {
270
+ # Axes-level defaults
271
+ "axis_thickness_mm": ("axes_default", "axes", "thickness_mm"),
272
+ "axes_thickness_mm": ("axes_default", "axes", "thickness_mm"),
273
+ "tick_length_mm": ("axes_default", "ticks", "length_mm"),
274
+ "tick_thickness_mm": ("axes_default", "ticks", "thickness_mm"),
275
+ "n_ticks": ("axes_default", "ticks", "n_ticks"),
276
+ # Artist-level defaults (Line2D, markers)
277
+ "trace_thickness_mm": ("artist_default", "lines", "thickness_mm"),
278
+ "line_thickness_mm": ("artist_default", "lines", "thickness_mm"),
279
+ "marker_size_mm": ("artist_default", "markers", "size_mm"),
280
+ "scatter_size_mm": ("artist_default", "markers", "scatter_size_mm"),
281
+ # Global defaults (rcParams-like)
282
+ "font_family": ("global", "fonts", "family"),
283
+ "font_family_requested": ("global", "fonts", "family_requested"),
284
+ "font_family_actual": ("global", "fonts", "family_actual"),
285
+ "axis_font_size_pt": ("global", "fonts", "axis_size_pt"),
286
+ "tick_font_size_pt": ("global", "fonts", "tick_size_pt"),
287
+ "title_font_size_pt": ("global", "fonts", "title_size_pt"),
288
+ "legend_font_size_pt": ("global", "fonts", "legend_size_pt"),
289
+ "suptitle_font_size_pt": ("global", "fonts", "suptitle_size_pt"),
290
+ "annotation_font_size_pt": ("global", "fonts", "annotation_size_pt"),
291
+ "label_pad_pt": ("global", "padding", "label_pt"),
292
+ "tick_pad_pt": ("global", "padding", "tick_pt"),
293
+ "title_pad_pt": ("global", "padding", "title_pt"),
294
+ }
295
+
296
+ for key, value in flat_style.items():
297
+ if key in key_mapping:
298
+ scope, category, new_key = key_mapping[key]
299
+ result[scope][category][new_key] = value
300
+ else:
301
+ # Unknown keys go to a misc section or are kept at top level
302
+ # For now, skip unknown keys to keep structure clean
303
+ pass
304
+
305
+ # Remove empty categories within each scope
306
+ for scope in list(result.keys()):
307
+ result[scope] = {k: v for k, v in result[scope].items() if v}
308
+ # Remove empty scopes
309
+ if not result[scope]:
310
+ del result[scope]
311
+
312
+ return result
313
+
314
+
315
+ def collect_figure_metadata(fig, ax=None) -> Dict:
20
316
  """
21
317
  Collect all metadata from figure and axes for embedding in saved images.
22
318
 
23
319
  This function automatically extracts:
24
320
  - Software versions (scitex, matplotlib)
25
321
  - Timestamp
322
+ - Figure UUID (unique identifier)
26
323
  - Figure/axes dimensions (mm, inch, px)
27
324
  - DPI settings
28
325
  - Margins
29
326
  - Styling parameters (if available)
30
327
  - Mode (display/publication)
31
328
  - Creation method
32
- - Plot type and axes information (Phase 1)
329
+ - Plot type and axes information
33
330
 
34
331
  Parameters
35
332
  ----------
@@ -38,9 +335,6 @@ def collect_figure_metadata(fig, ax=None, plot_id=None) -> Dict:
38
335
  ax : matplotlib.axes.Axes, optional
39
336
  Primary axes to collect dimension info from.
40
337
  If not provided, uses first axes in figure.
41
- plot_id : str, optional
42
- Identifier for this plot (e.g., "01_plot"). If not provided,
43
- will be extracted from filename if available.
44
338
 
45
339
  Returns
46
340
  -------
@@ -68,233 +362,595 @@ def collect_figure_metadata(fig, ax=None, plot_id=None) -> Dict:
68
362
  - Debugging dimension/DPI issues
69
363
  """
70
364
  import datetime
365
+ import uuid
71
366
 
72
367
  import matplotlib
73
368
  import scitex
74
369
 
75
- # Base metadata
370
+ # Base metadata with cleaner structure:
371
+ # - runtime: software/creation info
372
+ # - figure: figure-level properties
373
+ # - axes: axes-level properties
374
+ # - style: styling parameters
375
+ # - plot: plot content (title, type, traces, legend)
376
+ # - data: CSV linkage (path, hash, columns)
76
377
  metadata = {
77
- "metadata_version": "1.1.0", # Version of the metadata schema itself (updated for Phase 1)
78
- "scitex": {
79
- "version": scitex.__version__,
378
+ "scitex_schema": "scitex.plt.figure",
379
+ "scitex_schema_version": "0.1.0",
380
+ "figure_uuid": str(uuid.uuid4()),
381
+ "runtime": {
382
+ "scitex_version": scitex.__version__,
383
+ "matplotlib_version": matplotlib.__version__,
80
384
  "created_at": datetime.datetime.now().isoformat(),
81
385
  },
82
- "matplotlib": {
83
- "version": matplotlib.__version__,
84
- },
85
386
  }
86
387
 
87
- # Add plot ID if provided
88
- if plot_id:
89
- metadata["id"] = plot_id
388
+ # Collect all axes from figure
389
+ # Keep AxisWrappers for metadata access, but also track grid shape
390
+ all_axes = [] # List of (ax_wrapper_or_mpl, row, col) tuples
391
+ grid_shape = (1, 1) # Default single axes
90
392
 
91
- # If no axes provided, try to get first axes from figure
92
- if ax is None and hasattr(fig, "axes") and len(fig.axes) > 0:
93
- ax = fig.axes[0]
94
-
95
- # Add dimension info if axes available
96
393
  if ax is not None:
394
+ # Handle AxesWrapper (multi-axes) - extract individual AxisWrappers with positions
395
+ if hasattr(ax, "_axes_scitex"):
396
+ import numpy as np
397
+ axes_array = ax._axes_scitex
398
+ if isinstance(axes_array, np.ndarray):
399
+ grid_shape = axes_array.shape
400
+ for idx, ax_item in enumerate(axes_array.flat):
401
+ row = idx // grid_shape[1]
402
+ col = idx % grid_shape[1]
403
+ all_axes.append((ax_item, row, col))
404
+ else:
405
+ all_axes = [(axes_array, 0, 0)]
406
+ # Handle AxisWrapper (single axes)
407
+ elif hasattr(ax, "_axis_mpl"):
408
+ all_axes = [(ax, 0, 0)]
409
+ else:
410
+ # Assume it's a matplotlib axes
411
+ all_axes = [(ax, 0, 0)]
412
+ elif hasattr(fig, "axes") and len(fig.axes) > 0:
413
+ # Fallback to figure axes (linear indexing)
414
+ for idx, ax_item in enumerate(fig.axes):
415
+ all_axes.append((ax_item, 0, idx))
416
+
417
+ # Helper to unwrap AxisWrapper to matplotlib axes
418
+ def _unwrap_ax(ax_item):
419
+ if hasattr(ax_item, "_axis_mpl"):
420
+ return ax_item._axis_mpl
421
+ return ax_item
422
+
423
+ # Add figure-level properties (extracted from first axes for figure dimensions)
424
+ if all_axes:
97
425
  try:
98
426
  from ._figure_from_axes_mm import get_dimension_info
99
427
 
100
- dim_info = get_dimension_info(fig, ax)
428
+ first_ax_tuple = all_axes[0]
429
+ first_ax_mpl = _unwrap_ax(first_ax_tuple[0])
430
+ dim_info = get_dimension_info(fig, first_ax_mpl)
101
431
 
102
- metadata["dimensions"] = {
103
- "figure_size_mm": dim_info["figure_size_mm"],
104
- "figure_size_inch": dim_info["figure_size_inch"],
105
- "figure_size_px": dim_info["figure_size_px"],
106
- "axes_size_mm": dim_info["axes_size_mm"],
107
- "axes_size_inch": dim_info["axes_size_inch"],
108
- "axes_size_px": dim_info["axes_size_px"],
109
- "axes_position": dim_info["axes_position"],
432
+ metadata["figure"] = {
433
+ "size_mm": dim_info["figure_size_mm"],
434
+ "size_inch": dim_info["figure_size_inch"],
435
+ "size_px": dim_info["figure_size_px"],
110
436
  "dpi": dim_info["dpi"],
111
437
  }
112
438
 
113
- # Calculate margins from dimension info
114
- fig_w_mm, fig_h_mm = dim_info["figure_size_mm"]
115
- axes_w_mm, axes_h_mm = dim_info["axes_size_mm"]
116
- axes_pos = dim_info["axes_position"]
117
- fig_w_px, fig_h_px = dim_info["figure_size_px"]
118
- axes_w_px, axes_h_px = dim_info["axes_size_px"]
119
- dpi = dim_info["dpi"]
120
-
121
- metadata["margins_mm"] = {
122
- "left": axes_pos[0] * fig_w_mm,
123
- "bottom": axes_pos[1] * fig_h_mm,
124
- "right": fig_w_mm - (axes_pos[0] * fig_w_mm + axes_w_mm),
125
- "top": fig_h_mm - (axes_pos[1] * fig_h_mm + axes_h_mm),
126
- }
127
-
128
- # Calculate axes bounding box in pixels and millimeters
129
- # axes_position is (left, bottom, width, height) in figure coordinates (0-1)
130
- # Convert to absolute coordinates
131
- x0_px = int(axes_pos[0] * fig_w_px)
132
- y0_px = int((1 - axes_pos[1] - axes_pos[3]) * fig_h_px) # Flip Y (matplotlib origin is bottom-left)
133
- x1_px = x0_px + axes_w_px
134
- y1_px = y0_px + axes_h_px
135
-
136
- x0_mm = axes_pos[0] * fig_w_mm
137
- y0_mm = (1 - axes_pos[1] - axes_pos[3]) * fig_h_mm # Flip Y
138
- x1_mm = x0_mm + axes_w_mm
139
- y1_mm = y0_mm + axes_h_mm
140
-
141
- metadata["axes_bbox_px"] = {
142
- "x0": x0_px,
143
- "y0": y0_px,
144
- "x1": x1_px,
145
- "y1": y1_px,
146
- "width": axes_w_px,
147
- "height": axes_h_px,
148
- }
149
-
150
- metadata["axes_bbox_mm"] = {
151
- "x0": x0_mm,
152
- "y0": y0_mm,
153
- "x1": x1_mm,
154
- "y1": y1_mm,
155
- "width": axes_w_mm,
156
- "height": axes_h_mm,
157
- }
158
-
439
+ # Add top-level axes_bbox_px for easy access by canvas/web editors
440
+ # Uses x0/y0/x1/y1 format (origin at top-left for web compatibility)
441
+ # x0: left edge (Y-axis position), y1: bottom edge (X-axis position)
442
+ if "axes_bbox_px" in dim_info:
443
+ metadata["axes_bbox_px"] = dim_info["axes_bbox_px"]
444
+ if "axes_bbox_mm" in dim_info:
445
+ metadata["axes_bbox_mm"] = dim_info["axes_bbox_mm"]
159
446
  except Exception as e:
160
- # If dimension extraction fails, continue without it
161
447
  import warnings
162
-
163
- warnings.warn(
164
- f"Could not extract dimension info for metadata: {e}"
165
- )
448
+ warnings.warn(f"Could not extract figure dimension info: {e}")
449
+
450
+ # Collect per-axes metadata
451
+ if all_axes:
452
+ metadata["axes"] = {}
453
+ for ax_item, row, col in all_axes:
454
+ # Use row-col format: ax_00, ax_01, ax_10, ax_11 for 2x2 grid
455
+ ax_key = f"ax_{row}{col}"
456
+ try:
457
+ ax_mpl = _unwrap_ax(ax_item)
458
+ ax_metadata = _collect_single_axes_metadata(fig, ax_mpl, row * grid_shape[1] + col)
459
+ if ax_metadata:
460
+ # Add grid position info
461
+ ax_metadata["grid_position"] = {"row": row, "col": col}
462
+ metadata["axes"][ax_key] = ax_metadata
463
+ except Exception as e:
464
+ import warnings
465
+ warnings.warn(f"Could not extract metadata for {ax_key}: {e}")
166
466
 
167
467
  # Add scitex-specific metadata if axes was tagged
468
+ scitex_meta = None
168
469
  if ax is not None and hasattr(ax, "_scitex_metadata"):
169
470
  scitex_meta = ax._scitex_metadata
170
-
171
- # Extract stats separately for top-level access
172
- if 'stats' in scitex_meta:
173
- metadata['stats'] = scitex_meta['stats']
174
-
175
- # Merge into scitex section
176
- for key, value in scitex_meta.items():
177
- if key not in metadata["scitex"] and key != 'stats': # Don't duplicate stats
178
- metadata["scitex"][key] = value
179
-
180
- # Alternative: check figure for metadata (for multi-axes cases)
181
471
  elif hasattr(fig, "_scitex_metadata"):
182
472
  scitex_meta = fig._scitex_metadata
183
473
 
474
+ if scitex_meta:
184
475
  # Extract stats separately for top-level access
185
- if 'stats' in scitex_meta:
186
- metadata['stats'] = scitex_meta['stats']
187
-
188
- for key, value in scitex_meta.items():
189
- if key not in metadata["scitex"] and key != 'stats': # Don't duplicate stats
190
- metadata["scitex"][key] = value
476
+ if "stats" in scitex_meta:
477
+ stats_list = scitex_meta["stats"]
478
+ # Determine first_ax_key from axes metadata
479
+ first_ax_key = None
480
+ if "axes" in metadata and metadata["axes"]:
481
+ first_ax_key = next(iter(metadata["axes"].keys()), None)
482
+ # Add plot_id and ax_id to each stats entry if not present
483
+ for stat in stats_list:
484
+ if isinstance(stat, dict):
485
+ # Try to get plot info from metadata
486
+ if stat.get("plot_id") is None:
487
+ if "plot" in metadata and "ax_id" in metadata["plot"]:
488
+ stat["plot_id"] = metadata["plot"]["ax_id"]
489
+ elif first_ax_key:
490
+ stat["plot_id"] = first_ax_key
491
+ if "ax_id" not in stat and first_ax_key:
492
+ stat["ax_id"] = first_ax_key
493
+ metadata["stats"] = stats_list
494
+
495
+ # Extract style_mm to dedicated "style" section with hierarchical structure
496
+ if "style_mm" in scitex_meta:
497
+ metadata["style"] = _restructure_style(scitex_meta["style_mm"])
498
+
499
+ # Extract mode to figure section
500
+ if "mode" in scitex_meta:
501
+ if "figure" not in metadata:
502
+ metadata["figure"] = {}
503
+ metadata["figure"]["mode"] = scitex_meta["mode"]
504
+
505
+ # Extract created_with to runtime section
506
+ if "created_with" in scitex_meta:
507
+ metadata["runtime"]["created_with"] = scitex_meta["created_with"]
508
+
509
+ # Note: axes_size_mm and position_in_grid are now handled per-axes
510
+ # in _collect_single_axes_metadata() and stored under axes.ax_00, axes.ax_01, etc.
191
511
 
192
512
  # Add actual font information
193
513
  try:
194
514
  from ._get_actual_font import get_actual_font_name
515
+
195
516
  actual_font = get_actual_font_name()
196
517
 
197
- # Store both requested and actual font
198
- if "style_mm" in metadata.get("scitex", {}):
199
- requested_font = metadata["scitex"]["style_mm"].get("font_family", "Arial")
200
- metadata["scitex"]["style_mm"]["font_family_requested"] = requested_font
201
- metadata["scitex"]["style_mm"]["font_family_actual"] = actual_font
518
+ # Store both requested and actual font in style.global.fonts section
519
+ if "style" in metadata:
520
+ # Ensure global.fonts section exists
521
+ if "global" not in metadata["style"]:
522
+ metadata["style"]["global"] = {}
523
+ if "fonts" not in metadata["style"]["global"]:
524
+ metadata["style"]["global"]["fonts"] = {}
525
+
526
+ # Get requested font from global.fonts.family or default to Arial
527
+ requested_font = metadata["style"]["global"]["fonts"].get("family", "Arial")
528
+ # Remove redundant family - keep only family_requested and family_actual
529
+ if "family" in metadata["style"]["global"]["fonts"]:
530
+ del metadata["style"]["global"]["fonts"]["family"]
531
+ metadata["style"]["global"]["fonts"]["family_requested"] = requested_font
532
+ metadata["style"]["global"]["fonts"]["family_actual"] = actual_font
202
533
 
203
534
  # Warn if requested and actual fonts differ
204
535
  if requested_font != actual_font:
205
536
  try:
206
537
  from scitex.logging import getLogger
538
+
207
539
  logger = getLogger(__name__)
208
540
  logger.warning(
209
541
  f"Font mismatch: Requested '{requested_font}' but using '{actual_font}'. "
210
542
  f"For {requested_font}: sudo apt-get install ttf-mscorefonts-installer && fc-cache -fv"
211
543
  )
212
544
  except ImportError:
213
- # Fallback to warnings if scitex.logging not available
214
545
  import warnings
546
+
215
547
  warnings.warn(
216
548
  f"Font mismatch: Requested '{requested_font}' but using '{actual_font}'",
217
- UserWarning
549
+ UserWarning,
218
550
  )
219
551
  else:
220
- # If no style_mm, add font info to scitex section
221
- if "scitex" in metadata:
222
- metadata["scitex"]["font_family_actual"] = actual_font
552
+ # If no style section, add font info to runtime section
553
+ metadata["runtime"]["font_family_actual"] = actual_font
223
554
  except Exception:
224
555
  # If font detection fails, continue without it
225
556
  pass
226
557
 
227
- # Phase 1: Add plot_type, axes, and style_preset
558
+ # Extract plot content and axes labels
559
+ # For multi-axes figures, we need to handle AxesWrapper specially
560
+ primary_ax = ax
228
561
  if ax is not None:
562
+ # Handle AxesWrapper (multi-axes) - use first axis for primary plot info
563
+ if hasattr(ax, "_axes_scitex"):
564
+ import numpy as np
565
+ axes_array = ax._axes_scitex
566
+ if isinstance(axes_array, np.ndarray) and axes_array.size > 0:
567
+ primary_ax = axes_array.flat[0]
568
+ else:
569
+ primary_ax = axes_array
570
+
571
+ if primary_ax is not None:
229
572
  try:
230
- # Extract axes labels and units
231
- axes_info = {}
232
-
233
- # X-axis
234
- xlabel = ax.get_xlabel()
235
- x_label, x_unit = _parse_label_unit(xlabel)
236
- axes_info["x"] = {
237
- "label": x_label,
238
- "unit": x_unit,
239
- "scale": ax.get_xscale(),
240
- "lim": list(ax.get_xlim()),
241
- }
242
-
243
- # Y-axis
244
- ylabel = ax.get_ylabel()
245
- y_label, y_unit = _parse_label_unit(ylabel)
246
- axes_info["y"] = {
247
- "label": y_label,
248
- "unit": y_unit,
249
- "scale": ax.get_yscale(),
250
- "lim": list(ax.get_ylim()),
251
- }
252
-
253
- # Add n_ticks if available from style
254
- if "scitex" in metadata and "style_mm" in metadata["scitex"]:
255
- if "n_ticks" in metadata["scitex"]["style_mm"]:
256
- n_ticks = metadata["scitex"]["style_mm"]["n_ticks"]
257
- axes_info["x"]["n_ticks"] = n_ticks
258
- axes_info["y"]["n_ticks"] = n_ticks
259
-
260
- metadata["axes"] = axes_info
261
-
262
- # Extract title
263
- title = ax.get_title()
573
+ # Try to get scitex AxisWrapper for history access
574
+ # This is needed because matplotlib axes don't have the tracking history
575
+ ax_for_history = primary_ax
576
+
577
+ # If ax is a raw matplotlib axes, try to find the scitex wrapper
578
+ if not hasattr(primary_ax, 'history'):
579
+ # Check if primary_ax has a scitex wrapper stored on it
580
+ if hasattr(primary_ax, '_scitex_wrapper'):
581
+ ax_for_history = primary_ax._scitex_wrapper
582
+ # Check if figure has scitex axes reference
583
+ elif hasattr(fig, 'axes') and hasattr(fig.axes, 'history'):
584
+ ax_for_history = fig.axes
585
+ # Check for FigWrapper's axes attribute
586
+ elif hasattr(fig, '_fig_scitex') and hasattr(fig._fig_scitex, 'axes'):
587
+ ax_for_history = fig._fig_scitex.axes
588
+ # Check if the figure object itself has scitex_axes
589
+ elif hasattr(fig, '_scitex_axes'):
590
+ ax_for_history = fig._scitex_axes
591
+
592
+ # Add n_ticks to axes metadata if available from style
593
+ if "style" in metadata and "ticks" in metadata["style"] and "n_ticks" in metadata["style"]["ticks"]:
594
+ n_ticks = metadata["style"]["ticks"]["n_ticks"]
595
+ # Add n_ticks to each axes' axis info
596
+ if "axes" in metadata:
597
+ for ax_key in metadata["axes"]:
598
+ ax_data = metadata["axes"][ax_key]
599
+ if "xaxis" in ax_data:
600
+ ax_data["xaxis"]["n_ticks"] = n_ticks
601
+ if "yaxis" in ax_data:
602
+ ax_data["yaxis"]["n_ticks"] = n_ticks
603
+
604
+ # Initialize plot section for plot content
605
+ plot_info = {}
606
+
607
+ # Add ax_id to match the axes key in metadata["axes"]
608
+ # This links plot info to the corresponding axes entry
609
+ ax_row, ax_col = 0, 0 # Default for single axes
610
+ if hasattr(primary_ax, "_scitex_metadata") and "position_in_grid" in primary_ax._scitex_metadata:
611
+ pos = primary_ax._scitex_metadata["position_in_grid"]
612
+ ax_row, ax_col = pos[0], pos[1]
613
+ # Use same format as axes keys: ax_00, ax_01, etc.
614
+ plot_info["ax_id"] = f"ax_{ax_row:02d}" if ax_row == ax_col == 0 else f"ax_{ax_row * 10 + ax_col:02d}"
615
+
616
+ # Extract title - use underlying matplotlib axes if needed
617
+ ax_mpl = primary_ax._axis_mpl if hasattr(primary_ax, '_axis_mpl') else primary_ax
618
+ title = ax_mpl.get_title()
264
619
  if title:
265
- metadata["title"] = title
620
+ plot_info["title"] = title
266
621
 
267
622
  # Detect plot type and method from axes history or lines
268
- plot_type, method = _detect_plot_type(ax)
623
+ # Use ax_for_history which has the scitex history if available
624
+ plot_type, method = _detect_plot_type(ax_for_history)
269
625
  if plot_type:
270
- metadata["plot_type"] = plot_type
626
+ plot_info["type"] = plot_type
271
627
  if method:
272
- metadata["method"] = method
628
+ plot_info["method"] = method
273
629
 
274
630
  # Extract style preset if available
275
- if hasattr(ax, "_scitex_metadata") and "style_preset" in ax._scitex_metadata:
276
- metadata["style_preset"] = ax._scitex_metadata["style_preset"]
277
- elif hasattr(fig, "_scitex_metadata") and "style_preset" in fig._scitex_metadata:
278
- metadata["style_preset"] = fig._scitex_metadata["style_preset"]
279
-
280
- # Phase 2: Extract traces (lines) with their properties and CSV column mapping
281
- traces = _extract_traces(ax)
282
- if traces:
283
- metadata["traces"] = traces
284
-
285
- # Phase 2: Extract legend info
286
- legend_info = _extract_legend_info(ax)
287
- if legend_info:
288
- metadata["legend"] = legend_info
631
+ if (
632
+ hasattr(primary_ax, "_scitex_metadata")
633
+ and "style_preset" in primary_ax._scitex_metadata
634
+ ):
635
+ if "style" not in metadata:
636
+ metadata["style"] = {}
637
+ metadata["style"]["preset"] = primary_ax._scitex_metadata["style_preset"]
638
+ elif (
639
+ hasattr(fig, "_scitex_metadata")
640
+ and "style_preset" in fig._scitex_metadata
641
+ ):
642
+ if "style" not in metadata:
643
+ metadata["style"] = {}
644
+ metadata["style"]["preset"] = fig._scitex_metadata["style_preset"]
645
+
646
+ # Extract artists and legend - add to axes section (matplotlib terminology)
647
+ # Artists and legend belong to axes, not a separate plot section
648
+ ax_row, ax_col = 0, 0
649
+ if hasattr(primary_ax, "_scitex_metadata") and "position_in_grid" in primary_ax._scitex_metadata:
650
+ pos = primary_ax._scitex_metadata["position_in_grid"]
651
+ ax_row, ax_col = pos[0], pos[1]
652
+ ax_key = f"ax_{ax_row:02d}" if ax_row == ax_col == 0 else f"ax_{ax_row * 10 + ax_col:02d}"
653
+
654
+ if "axes" in metadata and ax_key in metadata["axes"]:
655
+ # Add artists to axes
656
+ artists = _extract_artists(primary_ax)
657
+ if artists:
658
+ metadata["axes"][ax_key]["artists"] = artists
659
+
660
+ # Add legend to axes
661
+ legend_info = _extract_legend_info(primary_ax)
662
+ if legend_info:
663
+ metadata["axes"][ax_key]["legend"] = legend_info
664
+
665
+ # Add plot section if we have content
666
+ if plot_info:
667
+ metadata["plot"] = plot_info
668
+
669
+ # Data section for CSV linkage
670
+ # Note: Per-trace column mappings are in plot.traces[i].csv_columns
671
+ # This section provides:
672
+ # - csv_hash: for verifying data integrity
673
+ # - csv_path: path to CSV file (added by _save.py)
674
+ # - columns_actual: actual column names in CSV (added by _save.py after export)
675
+ data_info = {}
676
+
677
+ # Compute CSV data hash for reproducibility verification
678
+ csv_hash = _compute_csv_hash(ax_for_history)
679
+ if csv_hash:
680
+ data_info["csv_hash"] = csv_hash
681
+
682
+ # csv_path and columns_actual will be added by _save.py after actual CSV export
683
+ # This ensures single source of truth - actual columns, not predictions
684
+
685
+ # Add data section if we have content
686
+ if data_info:
687
+ metadata["data"] = data_info
289
688
 
290
689
  except Exception as e:
291
690
  # If Phase 1 extraction fails, continue without it
292
691
  import warnings
692
+
293
693
  warnings.warn(f"Could not extract Phase 1 metadata: {e}")
294
694
 
695
+ # Apply precision rounding to all numeric values
696
+ metadata = _round_metadata(metadata)
697
+
295
698
  return metadata
296
699
 
297
700
 
701
+ def _round_metadata(metadata: dict) -> dict:
702
+ """
703
+ Apply appropriate precision rounding to all numeric values in metadata.
704
+
705
+ Precision rules:
706
+ - mm values: 2 decimal places (0.01mm = 10 microns)
707
+ - inch values: 3 decimal places
708
+ - position values: 3 decimal places
709
+ - axis limits: 2 decimal places
710
+ - linewidth: 2 decimal places
711
+ - px values: integers (no decimals)
712
+ """
713
+ result = {}
714
+
715
+ for key, value in metadata.items():
716
+ if key in ("scitex_schema", "scitex_schema_version", "figure_uuid"):
717
+ # String fields - no rounding
718
+ result[key] = value
719
+ elif key == "runtime":
720
+ # Runtime section - no numeric values to round
721
+ result[key] = value
722
+ elif key == "figure":
723
+ result[key] = _round_figure_section(value)
724
+ elif key == "axes":
725
+ result[key] = _round_axes_section(value)
726
+ elif key == "style":
727
+ result[key] = _round_style_section(value)
728
+ elif key == "plot":
729
+ result[key] = _round_plot_section(value)
730
+ elif key == "data":
731
+ # Data section - no numeric values to round (hashes, paths, column names)
732
+ result[key] = value
733
+ elif key == "stats":
734
+ # Stats section - preserve precision for statistical values
735
+ result[key] = value
736
+ else:
737
+ result[key] = value
738
+
739
+ return result
740
+
741
+
742
+ def _round_figure_section(fig_data: dict) -> dict:
743
+ """Round values in figure section."""
744
+ result = {}
745
+ for key, value in fig_data.items():
746
+ if key == "size_mm":
747
+ # Fixed 2 decimals for mm: [80.00, 68.00]
748
+ result[key] = _round_list(value, PRECISION["mm"], fixed=True)
749
+ elif key == "size_inch":
750
+ # Fixed 3 decimals for inch: [3.150, 2.677]
751
+ result[key] = _round_list(value, PRECISION["inch"], fixed=True)
752
+ elif key == "size_px":
753
+ result[key] = [int(v) for v in value] # Pixels are integers
754
+ elif key == "dpi":
755
+ result[key] = int(value)
756
+ else:
757
+ result[key] = value
758
+ return result
759
+
760
+
761
+ def _round_axes_section(axes_data: dict) -> dict:
762
+ """Round values in axes section.
763
+
764
+ Handles both flat structure (legacy) and nested structure (ax_00, ax_01, ...).
765
+ """
766
+ result = {}
767
+ for key, value in axes_data.items():
768
+ # Check if this is a nested axes key (ax_00, ax_01, etc.)
769
+ if key.startswith("ax_") and isinstance(value, dict):
770
+ # Recursively round the nested axes data
771
+ result[key] = _round_single_axes_data(value)
772
+ else:
773
+ # Handle flat structure (legacy) or non-axes keys
774
+ result[key] = _round_single_axes_data({key: value}).get(key, value)
775
+ return result
776
+
777
+
778
+ def _round_single_axes_data(ax_data: dict) -> dict:
779
+ """Round values for a single axes' data."""
780
+ result = {}
781
+ for key, value in ax_data.items():
782
+ if key == "size_mm":
783
+ # Fixed 2 decimals: [40.00, 28.00]
784
+ result[key] = _round_list(value, PRECISION["mm"], fixed=True)
785
+ elif key == "size_inch":
786
+ # Fixed 3 decimals: [1.575, 1.102]
787
+ result[key] = _round_list(value, PRECISION["inch"], fixed=True)
788
+ elif key == "size_px":
789
+ result[key] = [int(v) for v in value]
790
+ elif key in ("position", "position_ratio", "bounds_figure_fraction"):
791
+ # Fixed 3 decimals: [0.250, 0.294, 0.500, 0.412]
792
+ result[key] = _round_list(value, PRECISION["position"], fixed=True)
793
+ elif key == "position_in_grid":
794
+ result[key] = [int(v) for v in value]
795
+ elif key == "margins_mm":
796
+ # Fixed 2 decimals: {"left": 20.00, ...}
797
+ result[key] = {k: _round_value(v, PRECISION["mm"], fixed=True) for k, v in value.items()}
798
+ elif key == "margins_inch":
799
+ # Fixed 3 decimals: {"left": 0.787, ...}
800
+ result[key] = {k: _round_value(v, PRECISION["inch"], fixed=True) for k, v in value.items()}
801
+ elif key == "bbox_mm":
802
+ # Fixed 2 decimals
803
+ result[key] = {k: _round_value(v, PRECISION["mm"], fixed=True) for k, v in value.items()}
804
+ elif key == "bbox_inch":
805
+ # Fixed 3 decimals
806
+ result[key] = {k: _round_value(v, PRECISION["inch"], fixed=True) for k, v in value.items()}
807
+ elif key == "bbox_px":
808
+ result[key] = {k: int(v) for k, v in value.items()}
809
+ elif key in ("xaxis", "yaxis", "xaxis_top", "yaxis_right"):
810
+ # Axis info (label, unit, scale, lim, n_ticks) - using matplotlib terminology
811
+ axis_result = {}
812
+ for ak, av in value.items():
813
+ if ak == "lim":
814
+ # Fixed 2 decimals for limits: [-0.31, 6.60]
815
+ axis_result[ak] = _round_list(av, PRECISION["lim"], fixed=True)
816
+ elif ak == "n_ticks":
817
+ axis_result[ak] = int(av)
818
+ else:
819
+ axis_result[ak] = av
820
+ result[key] = axis_result
821
+ elif key == "legend":
822
+ # Legend has no floats to round, pass through
823
+ result[key] = value
824
+ elif key == "artists":
825
+ # Round artist values
826
+ result[key] = [_round_artist(a) for a in value]
827
+ else:
828
+ result[key] = value
829
+ return result
830
+
831
+
832
+ def _round_style_section(style_data: dict) -> dict:
833
+ """Round values in hierarchical style section with scopes.
834
+
835
+ Handles structure like:
836
+ {
837
+ "global": {"fonts": {...}, "padding": {...}},
838
+ "axes_default": {"axes": {...}, "ticks": {...}},
839
+ "artist_default": {"lines": {...}, "markers": {...}}
840
+ }
841
+ """
842
+ result = {}
843
+ for scope, scope_data in style_data.items():
844
+ if scope in ("global", "axes_default", "artist_default"):
845
+ # Handle scope-level dict
846
+ result[scope] = {}
847
+ for category, category_data in scope_data.items():
848
+ if isinstance(category_data, dict):
849
+ result[scope][category] = _round_style_subsection(category, category_data)
850
+ else:
851
+ result[scope][category] = category_data
852
+ elif isinstance(scope_data, dict):
853
+ # Fallback for flat structure (backward compatibility)
854
+ result[scope] = _round_style_subsection(scope, scope_data)
855
+ elif isinstance(scope_data, float):
856
+ if "_mm" in scope:
857
+ result[scope] = _round_value(scope_data, PRECISION["mm"], fixed=True)
858
+ elif "_pt" in scope:
859
+ result[scope] = _round_value(scope_data, 1, fixed=True)
860
+ else:
861
+ result[scope] = _round_value(scope_data, 2)
862
+ elif isinstance(scope_data, int):
863
+ result[scope] = scope_data
864
+ else:
865
+ result[scope] = scope_data
866
+ return result
867
+
868
+
869
+ def _round_style_subsection(category: str, data: dict) -> dict:
870
+ """Round values in a style subsection based on category."""
871
+ result = {}
872
+ for key, value in data.items():
873
+ if isinstance(value, float):
874
+ if "_mm" in key or category in ("axes", "ticks", "lines", "markers"):
875
+ # mm values: 2 decimals
876
+ result[key] = _round_value(value, PRECISION["mm"], fixed=True)
877
+ elif "_pt" in key or category in ("fonts", "padding"):
878
+ # pt values: 1 decimal
879
+ result[key] = _round_value(value, 1, fixed=True)
880
+ else:
881
+ result[key] = _round_value(value, 2)
882
+ elif isinstance(value, int):
883
+ result[key] = value
884
+ else:
885
+ result[key] = value
886
+ return result
887
+
888
+
889
+ def _round_plot_section(plot_data: dict) -> dict:
890
+ """Round values in plot section."""
891
+ result = {}
892
+ for key, value in plot_data.items():
893
+ if key == "artists":
894
+ result[key] = [_round_artist(a) for a in value]
895
+ elif key == "legend":
896
+ result[key] = value # Legend has no floats to round
897
+ else:
898
+ result[key] = value
899
+ return result
900
+
901
+
902
+ def _round_artist(artist: dict) -> dict:
903
+ """Round values in a single artist."""
904
+ result = {}
905
+ for key, value in artist.items():
906
+ if key == "style" and isinstance(value, dict):
907
+ # Legacy: Round values in style dict (for backward compatibility)
908
+ style_result = {}
909
+ for sk, sv in value.items():
910
+ if sk in ("linewidth_pt", "markersize_pt"):
911
+ # Fixed 2 decimals: 0.57
912
+ style_result[sk] = _round_value(sv, PRECISION["linewidth"], fixed=True)
913
+ else:
914
+ style_result[sk] = sv
915
+ result[key] = style_result
916
+ elif key == "backend" and isinstance(value, dict):
917
+ # New two-layer structure: round values in backend.props
918
+ backend_result = {"name": value.get("name", "matplotlib")}
919
+ if "artist_class" in value:
920
+ backend_result["artist_class"] = value["artist_class"]
921
+ if "props" in value and isinstance(value["props"], dict):
922
+ props_result = {}
923
+ for pk, pv in value["props"].items():
924
+ if pk in ("linewidth_pt", "markersize_pt"):
925
+ # Fixed 2 decimals: 0.57
926
+ props_result[pk] = _round_value(pv, PRECISION["linewidth"], fixed=True)
927
+ elif pk == "size":
928
+ # Scatter size: 1 decimal
929
+ props_result[pk] = _round_value(pv, 1, fixed=True)
930
+ else:
931
+ props_result[pk] = pv
932
+ backend_result["props"] = props_result
933
+ result[key] = backend_result
934
+ elif key == "geometry" and isinstance(value, dict):
935
+ # Round geometry values (for bar charts)
936
+ geom_result = {}
937
+ for gk, gv in value.items():
938
+ if isinstance(gv, float):
939
+ geom_result[gk] = _round_value(gv, 4, fixed=False)
940
+ else:
941
+ geom_result[gk] = gv
942
+ result[key] = geom_result
943
+ elif key == "zorder":
944
+ result[key] = int(value) if isinstance(value, (int, float)) else value
945
+ else:
946
+ result[key] = value
947
+ return result
948
+
949
+
950
+ # Backward compatibility alias
951
+ _round_trace = _round_artist
952
+
953
+
298
954
  def _parse_label_unit(label_text: str) -> tuple:
299
955
  """
300
956
  Parse label text to extract label and unit.
@@ -320,12 +976,12 @@ def _parse_label_unit(label_text: str) -> tuple:
320
976
  return "", ""
321
977
 
322
978
  # Try to match [...] pattern first (preferred format)
323
- match = re.match(r'^(.+?)\s*\[([^\]]+)\]$', label_text)
979
+ match = re.match(r"^(.+?)\s*\[([^\]]+)\]$", label_text)
324
980
  if match:
325
981
  return match.group(1).strip(), match.group(2).strip()
326
982
 
327
983
  # Try to match (...) pattern
328
- match = re.match(r'^(.+?)\s*\(([^\)]+)\)$', label_text)
984
+ match = re.match(r"^(.+?)\s*\(([^\)]+)\)$", label_text)
329
985
  if match:
330
986
  return match.group(1).strip(), match.group(2).strip()
331
987
 
@@ -333,96 +989,1017 @@ def _parse_label_unit(label_text: str) -> tuple:
333
989
  return label_text.strip(), ""
334
990
 
335
991
 
336
- def _extract_traces(ax) -> list:
992
+ def _get_csv_column_names(trace_id: str, ax_row: int = 0, ax_col: int = 0, variables: list = None) -> dict:
993
+ """
994
+ Get CSV column names using the single source of truth naming convention.
995
+
996
+ Format: ax-row-{row}-col-{col}_trace-id-{id}_variable-{var}
997
+
998
+ Parameters
999
+ ----------
1000
+ trace_id : str
1001
+ The trace identifier (e.g., "sine", "step")
1002
+ ax_row : int
1003
+ Row position of axes in grid (default: 0)
1004
+ ax_col : int
1005
+ Column position of axes in grid (default: 0)
1006
+ variables : list, optional
1007
+ List of variable names (default: ["x", "y"])
1008
+
1009
+ Returns
1010
+ -------
1011
+ dict
1012
+ Dictionary mapping variable names to CSV column names
1013
+ """
1014
+ from ._csv_column_naming import get_csv_column_name
1015
+
1016
+ if variables is None:
1017
+ variables = ["x", "y"]
1018
+
1019
+ data_ref = {}
1020
+ for var in variables:
1021
+ data_ref[var] = get_csv_column_name(var, ax_row, ax_col, trace_id=trace_id)
1022
+
1023
+ return data_ref
1024
+
1025
+
1026
+ def _extract_artists(ax) -> list:
337
1027
  """
338
- Extract trace (line) information including properties and CSV column mapping.
1028
+ Extract artist information including properties and CSV column mapping.
1029
+
1030
+ Uses matplotlib terminology: each drawable element is an Artist.
1031
+ Only includes artists that were explicitly created via scitex tracking (top-level calls),
1032
+ not internal artists created by matplotlib functions like boxplot() which internally
1033
+ call plot() multiple times.
339
1034
 
340
1035
  Parameters
341
1036
  ----------
342
1037
  ax : matplotlib.axes.Axes
343
- The axes to extract traces from
1038
+ The axes to extract artists from
344
1039
 
345
1040
  Returns
346
1041
  -------
347
1042
  list
348
- List of trace dictionaries with id, label, color, linestyle, linewidth,
349
- and csv_columns mapping
1043
+ List of artist dictionaries with:
1044
+ - id: unique identifier
1045
+ - artist_class: matplotlib class name (Line2D, PathCollection, etc.)
1046
+ - label: legend label
1047
+ - style: color, linestyle, linewidth, etc.
1048
+ - data_ref: CSV column mapping (matches columns_actual exactly)
350
1049
  """
351
1050
  import matplotlib.colors as mcolors
352
- from ._csv_column_naming import get_csv_column_name, sanitize_trace_id
353
1051
 
354
- traces = []
1052
+ artists = []
355
1053
 
356
1054
  # Get axes position for CSV column naming
357
1055
  ax_row, ax_col = 0, 0 # Default for single axes
358
- if hasattr(ax, '_scitex_metadata') and 'position_in_grid' in ax._scitex_metadata:
359
- pos = ax._scitex_metadata['position_in_grid']
1056
+ if hasattr(ax, "_scitex_metadata") and "position_in_grid" in ax._scitex_metadata:
1057
+ pos = ax._scitex_metadata["position_in_grid"]
360
1058
  ax_row, ax_col = pos[0], pos[1]
361
1059
 
362
- for i, line in enumerate(ax.lines):
363
- trace = {}
1060
+ # Get the raw matplotlib axes for accessing lines
1061
+ mpl_ax = ax._axis_mpl if hasattr(ax, "_axis_mpl") else ax
1062
+
1063
+ # Try to find scitex wrapper for plot type detection and history access
1064
+ ax_for_detection = ax
1065
+ if not hasattr(ax, 'history') and hasattr(mpl_ax, '_scitex_wrapper'):
1066
+ ax_for_detection = mpl_ax._scitex_wrapper
1067
+
1068
+ # Check if we should filter to only tracked artists
1069
+ # For plot types that internally call plot (boxplot, errorbar, etc.),
1070
+ # we don't export the internal artists EXCEPT explicitly tracked ones
1071
+ plot_type, method = _detect_plot_type(ax_for_detection)
1072
+
1073
+ # Plot types where internal line artists should be hidden
1074
+ # But we still export artists that have explicit _scitex_id set
1075
+ # These plot types create Line2D objects internally that don't have
1076
+ # corresponding data in the CSV export
1077
+ # NOTE: scatter is NOT included here because scatter plots often have
1078
+ # regression lines that should be exported
1079
+ internal_plot_types = {
1080
+ "boxplot", "violin", "hist", "bar", "image", "heatmap", "kde", "ecdf",
1081
+ "errorbar", "fill", "stem", "contour", "pie", "quiver", "stream"
1082
+ }
364
1083
 
1084
+ skip_unlabeled = plot_type in internal_plot_types
1085
+
1086
+ # Build a map from scitex_id to full record from history
1087
+ # Record format: (tracking_id, method, tracked_dict, kwargs)
1088
+ id_to_history = {}
1089
+ if hasattr(ax_for_detection, "history"):
1090
+ for record_id, record in ax_for_detection.history.items():
1091
+ if isinstance(record, tuple) and len(record) >= 2:
1092
+ tracking_id = record[0] # The id used in tracking
1093
+ id_to_history[tracking_id] = record # Store full record
1094
+
1095
+ # Special handling for boxplot and violin - extract semantic components
1096
+ # Boxplot creates lines in a specific pattern: for n boxes, there are
1097
+ # typically: whiskers (2*n), caps (2*n), median (n), fliers (n)
1098
+ is_boxplot = plot_type == "boxplot"
1099
+ is_violin = plot_type == "violin"
1100
+ is_stem = plot_type == "stem"
1101
+
1102
+ # For boxplot, try to determine the number of boxes and compute stats from history
1103
+ num_boxes = 0
1104
+ boxplot_stats = [] # Will hold stats for each box
1105
+ boxplot_data = None
1106
+ if is_boxplot and hasattr(ax_for_detection, "history"):
1107
+ for record in ax_for_detection.history.values():
1108
+ if isinstance(record, tuple) and len(record) >= 3:
1109
+ method_name = record[1]
1110
+ if method_name == "boxplot":
1111
+ tracked_dict = record[2]
1112
+ args = tracked_dict.get("args", [])
1113
+ if args and len(args) > 0:
1114
+ data = args[0]
1115
+ if hasattr(data, '__len__') and not isinstance(data, str):
1116
+ # Check if it's list of arrays or single array
1117
+ if hasattr(data[0], '__len__') and not isinstance(data[0], str):
1118
+ num_boxes = len(data)
1119
+ boxplot_data = data
1120
+ else:
1121
+ num_boxes = 1
1122
+ boxplot_data = [data]
1123
+ break
1124
+
1125
+ # Compute boxplot statistics
1126
+ if boxplot_data is not None:
1127
+ import numpy as np
1128
+ for box_idx, box_data in enumerate(boxplot_data):
1129
+ try:
1130
+ arr = np.asarray(box_data)
1131
+ arr = arr[~np.isnan(arr)] # Remove NaN values
1132
+ if len(arr) > 0:
1133
+ q1 = float(np.percentile(arr, 25))
1134
+ median = float(np.median(arr))
1135
+ q3 = float(np.percentile(arr, 75))
1136
+ iqr = q3 - q1
1137
+ whisker_low = float(max(arr.min(), q1 - 1.5 * iqr))
1138
+ whisker_high = float(min(arr.max(), q3 + 1.5 * iqr))
1139
+ # Fliers are points outside whiskers
1140
+ fliers = arr[(arr < whisker_low) | (arr > whisker_high)]
1141
+ boxplot_stats.append({
1142
+ "box_index": box_idx,
1143
+ "median": median,
1144
+ "q1": q1,
1145
+ "q3": q3,
1146
+ "whisker_low": whisker_low,
1147
+ "whisker_high": whisker_high,
1148
+ "n_fliers": int(len(fliers)),
1149
+ "n_samples": int(len(arr)),
1150
+ })
1151
+ except (ValueError, TypeError):
1152
+ pass
1153
+
1154
+ for i, line in enumerate(mpl_ax.lines):
365
1155
  # Get ID from _scitex_id attribute (set by scitex plotting functions)
366
1156
  # This matches the id= kwarg passed to ax.plot()
367
- scitex_id = getattr(line, '_scitex_id', None)
1157
+ scitex_id = getattr(line, "_scitex_id", None)
368
1158
 
369
1159
  # Get label for legend
370
1160
  label = line.get_label()
371
1161
 
372
- # Determine trace_id for CSV column matching
373
- # Use index-based ID to match CSV export (single source of truth)
374
- trace_id_for_csv = None # Will use trace_index in get_csv_column_name
375
-
376
- # Store display id/label separately
377
- if scitex_id:
378
- trace["id"] = scitex_id
379
- elif not label.startswith('_'):
380
- trace["id"] = label
1162
+ # For internal plot types (boxplot, violin, etc.), skip Line2D artists
1163
+ # that were created internally by matplotlib (not explicitly tracked).
1164
+ # These internal artists don't have corresponding data in the CSV.
1165
+ # BUT: for boxplot/violin/stem, we want to export with semantic labels
1166
+ semantic_type = None
1167
+ semantic_id = None
1168
+ has_boxplot_stats = False
1169
+ box_idx = None
1170
+
1171
+ # For stem, always detect semantic type (even with scitex_id)
1172
+ if is_stem:
1173
+ marker = line.get_marker()
1174
+ linestyle = line.get_linestyle()
1175
+ if marker and marker != "None" and linestyle == "None":
1176
+ # This is the marker line (markers only, no connecting line)
1177
+ semantic_type = "stem_marker"
1178
+ semantic_id = "stem_markers"
1179
+ elif linestyle and linestyle != "None":
1180
+ # This is either stemlines or baseline
1181
+ # Check if it looks like a baseline (horizontal line at y=0)
1182
+ ydata = line.get_ydata()
1183
+ if len(ydata) >= 2 and len(set(ydata)) == 1:
1184
+ semantic_type = "stem_baseline"
1185
+ semantic_id = "stem_baseline"
1186
+ else:
1187
+ semantic_type = "stem_stem"
1188
+ semantic_id = "stem_lines"
1189
+ else:
1190
+ semantic_type = "stem_component"
1191
+ semantic_id = f"stem_{i}"
1192
+
1193
+ if skip_unlabeled and not scitex_id and label.startswith("_"):
1194
+ # For boxplot, assign semantic roles based on position in lines list
1195
+ if is_boxplot and num_boxes > 0:
1196
+ # Boxplot line order: whiskers (2*n), caps (2*n), medians (n), fliers (n)
1197
+ total_whiskers = 2 * num_boxes
1198
+ total_caps = 2 * num_boxes
1199
+ total_medians = num_boxes
1200
+
1201
+ if i < total_whiskers:
1202
+ box_idx = i // 2
1203
+ whisker_idx = i % 2
1204
+ semantic_type = "boxplot_whisker"
1205
+ semantic_id = f"box_{box_idx}_whisker_{whisker_idx}"
1206
+ elif i < total_whiskers + total_caps:
1207
+ cap_i = i - total_whiskers
1208
+ box_idx = cap_i // 2
1209
+ cap_idx = cap_i % 2
1210
+ semantic_type = "boxplot_cap"
1211
+ semantic_id = f"box_{box_idx}_cap_{cap_idx}"
1212
+ elif i < total_whiskers + total_caps + total_medians:
1213
+ box_idx = i - total_whiskers - total_caps
1214
+ semantic_type = "boxplot_median"
1215
+ semantic_id = f"box_{box_idx}_median"
1216
+ # Mark this as the primary element to hold stats
1217
+ has_boxplot_stats = True
1218
+ else:
1219
+ flier_idx = i - total_whiskers - total_caps - total_medians
1220
+ # Distribute fliers across boxes if we have fewer flier lines than boxes
1221
+ box_idx = flier_idx if flier_idx < num_boxes else num_boxes - 1
1222
+ semantic_type = "boxplot_flier"
1223
+ semantic_id = f"box_{box_idx}_flier"
1224
+ elif is_violin:
1225
+ # Violin typically has: bodies (patches), then optional lines
1226
+ semantic_type = "violin_component"
1227
+ semantic_id = f"violin_line_{i}"
1228
+ elif is_stem:
1229
+ # Already handled above
1230
+ pass
1231
+ else:
1232
+ continue # Skip for other internal plot types
1233
+
1234
+ artist = {}
1235
+
1236
+ # For scatter plots, check if this Line2D is a regression line
1237
+ is_regression_line = False
1238
+ if plot_type == "scatter" and label.startswith("_"):
1239
+ # Check if this looks like a regression line (straight line with few points)
1240
+ xdata = line.get_xdata()
1241
+ ydata = line.get_ydata()
1242
+ if len(xdata) == 2: # Regression line typically has 2 points
1243
+ is_regression_line = True
1244
+
1245
+ # Store display id/label
1246
+ # For stem, use semantic_id as the primary ID to ensure uniqueness
1247
+ if semantic_id and is_stem:
1248
+ artist["id"] = semantic_id
1249
+ if scitex_id:
1250
+ artist["group_id"] = scitex_id # Store original trace id as group
1251
+ elif scitex_id:
1252
+ artist["id"] = scitex_id
1253
+ elif semantic_id:
1254
+ artist["id"] = semantic_id
1255
+ elif is_regression_line:
1256
+ artist["id"] = f"regression_{i}"
1257
+ elif not label.startswith("_"):
1258
+ artist["id"] = label
381
1259
  else:
382
- trace["id"] = f"line_{i}"
1260
+ artist["id"] = f"line_{i}"
1261
+
1262
+ # Semantic layer: mark (plot type) and role (component role)
1263
+ # mark: line, scatter, bar, boxplot, violin, heatmap, etc.
1264
+ # role: specific component like boxplot_median, violin_body, etc.
1265
+ artist["mark"] = "line" # Line2D is always a line mark
1266
+ if semantic_type:
1267
+ artist["role"] = semantic_type
1268
+ elif is_regression_line:
1269
+ artist["role"] = "regression_line"
383
1270
 
384
1271
  # Label (for legend) - use label if not internal
385
- if not label.startswith('_'):
386
- trace["label"] = label
1272
+ # legend_included indicates if this artist appears in legend
1273
+ if not label.startswith("_"):
1274
+ artist["label"] = label
1275
+ artist["legend_included"] = True
1276
+ else:
1277
+ artist["legend_included"] = False
1278
+
1279
+ # zorder for layering
1280
+ artist["zorder"] = line.get_zorder()
1281
+
1282
+ # Backend layer: matplotlib-specific properties
1283
+ backend = {
1284
+ "name": "matplotlib",
1285
+ "artist_class": type(line).__name__, # e.g., "Line2D"
1286
+ "props": {}
1287
+ }
387
1288
 
388
1289
  # Color - always convert to hex for consistent JSON storage
389
1290
  color = line.get_color()
390
1291
  try:
391
1292
  # mcolors.to_hex handles strings, RGB tuples, RGBA tuples
392
1293
  color_hex = mcolors.to_hex(color, keep_alpha=False)
393
- trace["color"] = color_hex
1294
+ backend["props"]["color"] = color_hex
394
1295
  except (ValueError, TypeError):
395
1296
  # Fallback: store as-is
396
- trace["color"] = color
1297
+ backend["props"]["color"] = color
397
1298
 
398
1299
  # Line style
399
- trace["linestyle"] = line.get_linestyle()
1300
+ backend["props"]["linestyle"] = line.get_linestyle()
400
1301
 
401
1302
  # Line width
402
- trace["linewidth"] = line.get_linewidth()
1303
+ backend["props"]["linewidth_pt"] = line.get_linewidth()
403
1304
 
404
- # Marker
1305
+ # Marker - always include (null if no marker)
405
1306
  marker = line.get_marker()
406
- if marker and marker != 'None':
407
- trace["marker"] = marker
408
- trace["markersize"] = line.get_markersize()
409
-
410
- # CSV column mapping - use single source of truth
411
- # Uses trace_index to match what _export_as_csv generates
412
- trace["csv_columns"] = {
413
- "x": get_csv_column_name("plot_x", ax_row, ax_col, trace_index=i),
414
- "y": get_csv_column_name("plot_y", ax_row, ax_col, trace_index=i),
1307
+ if marker and marker != "None" and marker != "none":
1308
+ backend["props"]["marker"] = marker
1309
+ backend["props"]["markersize_pt"] = line.get_markersize()
1310
+ else:
1311
+ backend["props"]["marker"] = None
1312
+
1313
+ artist["backend"] = backend
1314
+
1315
+ # data_ref - CSV column mapping using single source of truth naming
1316
+ # Format: ax-row-{row}-col-{col}_trace-id-{id}_variable-{var}
1317
+ # Only add data_ref if this is NOT a boxplot/violin internal element
1318
+ # (those have semantic_type set but no corresponding CSV data)
1319
+ if not semantic_type:
1320
+ # Try to find the correct trace_id for data_ref
1321
+ # Priority: 1) _scitex_id, 2) History record trace_id, 3) Artist ID
1322
+ trace_id_for_ref = None
1323
+
1324
+ if scitex_id:
1325
+ # Artist has explicit _scitex_id set
1326
+ trace_id_for_ref = scitex_id
1327
+ else:
1328
+ # Try to find matching history record for this Line2D
1329
+ # Look for "plot" method records and match by index
1330
+ if hasattr(ax_for_detection, "history"):
1331
+ plot_records = []
1332
+ for record_id, record in ax_for_detection.history.items():
1333
+ if isinstance(record, tuple) and len(record) >= 2:
1334
+ if record[1] == "plot":
1335
+ # Extract trace_id from tracking_id (e.g., "ax_00_plot_0" -> "0")
1336
+ tracking_id = record[0]
1337
+ if tracking_id.startswith("ax_"):
1338
+ parts = tracking_id.split("_")
1339
+ if len(parts) >= 4:
1340
+ trace_id_for_ref = "_".join(parts[3:])
1341
+ elif len(parts) == 4:
1342
+ trace_id_for_ref = parts[3]
1343
+ elif tracking_id.startswith("plot_"):
1344
+ trace_id_for_ref = tracking_id[5:] if len(tracking_id) > 5 else str(i)
1345
+ else:
1346
+ # User-provided ID like "sine"
1347
+ trace_id_for_ref = tracking_id
1348
+ plot_records.append(trace_id_for_ref)
1349
+
1350
+ # Match by line index if we have plot records
1351
+ if plot_records:
1352
+ # Find the index of this line among all non-semantic lines
1353
+ non_semantic_line_idx = 0
1354
+ for j, l in enumerate(mpl_ax.lines[:i]):
1355
+ l_label = l.get_label()
1356
+ l_scitex_id = getattr(l, "_scitex_id", None)
1357
+ l_semantic_id = getattr(l, "_scitex_semantic_id", None)
1358
+ # Count only lines that would get data_ref (non-semantic)
1359
+ if not l_semantic_id and not l_label.startswith("_"):
1360
+ non_semantic_line_idx += 1
1361
+ elif l_scitex_id:
1362
+ non_semantic_line_idx += 1
1363
+
1364
+ if non_semantic_line_idx < len(plot_records):
1365
+ trace_id_for_ref = plot_records[non_semantic_line_idx]
1366
+
1367
+ # Fallback to artist ID
1368
+ if not trace_id_for_ref:
1369
+ trace_id_for_ref = artist.get("id", str(i))
1370
+
1371
+ artist["data_ref"] = _get_csv_column_names(trace_id_for_ref, ax_row, ax_col)
1372
+ elif is_stem and scitex_id:
1373
+ # For stem artists, add data_ref pointing to the original trace's columns
1374
+ artist["data_ref"] = _get_csv_column_names(scitex_id, ax_row, ax_col)
1375
+ # For baseline, mark it as derived (not directly from CSV)
1376
+ if semantic_type == "stem_baseline":
1377
+ artist["derived"] = True
1378
+ artist["data_ref"]["derived_from"] = "y=0"
1379
+
1380
+ # Add boxplot statistics to the median artist
1381
+ if has_boxplot_stats and box_idx is not None and box_idx < len(boxplot_stats):
1382
+ artist["stats"] = boxplot_stats[box_idx]
1383
+
1384
+ artists.append(artist)
1385
+
1386
+ # Also extract PathCollection artists (scatter points)
1387
+ for i, coll in enumerate(mpl_ax.collections):
1388
+ if "PathCollection" not in type(coll).__name__:
1389
+ continue
1390
+
1391
+ artist = {}
1392
+
1393
+ # Get ID from _scitex_id attribute
1394
+ scitex_id = getattr(coll, "_scitex_id", None)
1395
+ label = coll.get_label()
1396
+
1397
+ if scitex_id:
1398
+ artist["id"] = scitex_id
1399
+ elif label and not label.startswith("_"):
1400
+ artist["id"] = label
1401
+ else:
1402
+ artist["id"] = f"scatter_{i}"
1403
+
1404
+ # Semantic layer
1405
+ artist["mark"] = "scatter"
1406
+
1407
+ # Legend inclusion
1408
+ if label and not label.startswith("_"):
1409
+ artist["label"] = label
1410
+ artist["legend_included"] = True
1411
+ else:
1412
+ artist["legend_included"] = False
1413
+
1414
+ artist["zorder"] = coll.get_zorder()
1415
+
1416
+ # Backend layer: matplotlib-specific properties
1417
+ backend = {
1418
+ "name": "matplotlib",
1419
+ "artist_class": type(coll).__name__, # "PathCollection"
1420
+ "props": {}
1421
+ }
1422
+
1423
+ try:
1424
+ facecolors = coll.get_facecolor()
1425
+ if len(facecolors) > 0:
1426
+ backend["props"]["facecolor"] = mcolors.to_hex(facecolors[0], keep_alpha=False)
1427
+ except (ValueError, TypeError, IndexError):
1428
+ pass
1429
+
1430
+ try:
1431
+ edgecolors = coll.get_edgecolor()
1432
+ if len(edgecolors) > 0:
1433
+ backend["props"]["edgecolor"] = mcolors.to_hex(edgecolors[0], keep_alpha=False)
1434
+ except (ValueError, TypeError, IndexError):
1435
+ pass
1436
+
1437
+ try:
1438
+ sizes = coll.get_sizes()
1439
+ if len(sizes) > 0:
1440
+ backend["props"]["size"] = float(sizes[0])
1441
+ except (ValueError, TypeError, IndexError):
1442
+ pass
1443
+
1444
+ artist["backend"] = backend
1445
+
1446
+ # data_ref - CSV column mapping using single source of truth naming
1447
+ # Format: ax-row-{row}-col-{col}_trace-id-{id}_variable-{var}
1448
+ artist_id = artist.get("id", str(i))
1449
+ artist["data_ref"] = _get_csv_column_names(artist_id, ax_row, ax_col)
1450
+
1451
+ artists.append(artist)
1452
+
1453
+ # Extract Rectangle patches (bar/barh/hist charts)
1454
+ # First, collect all rectangles to determine group info
1455
+ rectangles = []
1456
+ for i, patch in enumerate(mpl_ax.patches):
1457
+ patch_type = type(patch).__name__
1458
+ if patch_type == "Rectangle":
1459
+ rectangles.append((i, patch))
1460
+
1461
+ # Determine if this is bar, barh, or hist based on plot_type
1462
+ is_bar = plot_type in ("bar", "barh")
1463
+ is_hist = plot_type == "hist"
1464
+
1465
+ # Get trace_id from history for data_ref
1466
+ trace_id_for_bars = None
1467
+ if hasattr(ax_for_detection, "history"):
1468
+ for record in ax_for_detection.history.values():
1469
+ if isinstance(record, tuple) and len(record) >= 2:
1470
+ method_name = record[1]
1471
+ if method_name in ("bar", "barh", "hist"):
1472
+ trace_id_for_bars = record[0]
1473
+ break
1474
+
1475
+ bar_count = 0
1476
+ for rect_idx, (i, patch) in enumerate(rectangles):
1477
+ patch_type = type(patch).__name__
1478
+
1479
+ # Skip internal unlabeled patches for non-bar/hist types
1480
+ scitex_id = getattr(patch, "_scitex_id", None)
1481
+ label = patch.get_label() if hasattr(patch, "get_label") else ""
1482
+
1483
+ # For bar/hist, we want ALL rectangles even if unlabeled
1484
+ if not (is_bar or is_hist):
1485
+ if skip_unlabeled and not scitex_id and (not label or label.startswith("_")):
1486
+ continue
1487
+
1488
+ artist = {}
1489
+
1490
+ # Generate unique ID with index
1491
+ base_id = scitex_id or (label if label and not label.startswith("_") else trace_id_for_bars or "bar")
1492
+ artist["id"] = f"{base_id}_{bar_count}"
1493
+
1494
+ # Add group_id for referencing the whole group
1495
+ artist["group_id"] = base_id
1496
+
1497
+ # Semantic layer
1498
+ artist["mark"] = "bar"
1499
+ if is_hist:
1500
+ artist["role"] = "hist_bin"
1501
+ else:
1502
+ artist["role"] = "bar_body"
1503
+
1504
+ # Legend inclusion - only first bar of a group should be in legend
1505
+ if label and not label.startswith("_") and bar_count == 0:
1506
+ artist["label"] = label
1507
+ artist["legend_included"] = True
1508
+ else:
1509
+ artist["legend_included"] = False
1510
+
1511
+ artist["zorder"] = patch.get_zorder()
1512
+
1513
+ # Backend layer: matplotlib-specific properties
1514
+ backend = {
1515
+ "name": "matplotlib",
1516
+ "artist_class": patch_type,
1517
+ "props": {}
1518
+ }
1519
+
1520
+ try:
1521
+ backend["props"]["facecolor"] = mcolors.to_hex(patch.get_facecolor(), keep_alpha=False)
1522
+ except (ValueError, TypeError):
1523
+ pass
1524
+ try:
1525
+ backend["props"]["edgecolor"] = mcolors.to_hex(patch.get_edgecolor(), keep_alpha=False)
1526
+ except (ValueError, TypeError):
1527
+ pass
1528
+ try:
1529
+ backend["props"]["linewidth_pt"] = patch.get_linewidth()
1530
+ except (ValueError, TypeError):
1531
+ pass
1532
+
1533
+ artist["backend"] = backend
1534
+
1535
+ # Bar geometry
1536
+ try:
1537
+ artist["geometry"] = {
1538
+ "x": patch.get_x(),
1539
+ "y": patch.get_y(),
1540
+ "width": patch.get_width(),
1541
+ "height": patch.get_height(),
1542
+ }
1543
+ except (ValueError, TypeError):
1544
+ pass
1545
+
1546
+ # data_ref with row_index for individual bars
1547
+ if trace_id_for_bars:
1548
+ if is_hist:
1549
+ # Histogram uses specific column names: bin-centers (x), bin-counts (y)
1550
+ prefix = f"ax-row-{ax_row}-col-{ax_col}_trace-id-{trace_id_for_bars}_variable-"
1551
+ artist["data_ref"] = {
1552
+ "x": f"{prefix}bin-centers",
1553
+ "y": f"{prefix}bin-counts",
1554
+ "row_index": bar_count,
1555
+ "bin_index": bar_count,
1556
+ }
1557
+ else:
1558
+ artist["data_ref"] = _get_csv_column_names(trace_id_for_bars, ax_row, ax_col)
1559
+ artist["data_ref"]["row_index"] = bar_count
1560
+
1561
+ bar_count += 1
1562
+ artists.append(artist)
1563
+
1564
+ # Extract Wedge patches (pie charts)
1565
+ wedge_count = 0
1566
+ for i, patch in enumerate(mpl_ax.patches):
1567
+ patch_type = type(patch).__name__
1568
+
1569
+ if patch_type != "Wedge":
1570
+ continue
1571
+
1572
+ artist = {}
1573
+
1574
+ scitex_id = getattr(patch, "_scitex_id", None)
1575
+ label = patch.get_label() if hasattr(patch, "get_label") else ""
1576
+
1577
+ if scitex_id:
1578
+ artist["id"] = scitex_id
1579
+ elif label and not label.startswith("_"):
1580
+ artist["id"] = label
1581
+ else:
1582
+ artist["id"] = f"wedge_{wedge_count}"
1583
+ wedge_count += 1
1584
+
1585
+ # Semantic layer
1586
+ artist["mark"] = "pie"
1587
+ artist["role"] = "pie_wedge"
1588
+
1589
+ if label and not label.startswith("_"):
1590
+ artist["label"] = label
1591
+ artist["legend_included"] = True
1592
+ else:
1593
+ artist["legend_included"] = False
1594
+
1595
+ artist["zorder"] = patch.get_zorder()
1596
+
1597
+ # Backend layer
1598
+ backend = {
1599
+ "name": "matplotlib",
1600
+ "artist_class": patch_type,
1601
+ "props": {}
1602
+ }
1603
+ try:
1604
+ backend["props"]["facecolor"] = mcolors.to_hex(patch.get_facecolor(), keep_alpha=False)
1605
+ except (ValueError, TypeError):
1606
+ pass
1607
+
1608
+ artist["backend"] = backend
1609
+ artists.append(artist)
1610
+
1611
+ # Extract QuadMesh (hist2d) and PolyCollection (hexbin/violin) with colormap info
1612
+ # Try to get hist2d result data from history
1613
+ hist2d_result = None
1614
+ hexbin_result = None
1615
+ if hasattr(ax_for_detection, "history"):
1616
+ for record in ax_for_detection.history.values():
1617
+ if isinstance(record, tuple) and len(record) >= 3:
1618
+ method_name = record[1]
1619
+ tracked_dict = record[2]
1620
+ if method_name == "hist2d" and "result" in tracked_dict:
1621
+ hist2d_result = tracked_dict["result"]
1622
+ elif method_name == "hexbin" and "result" in tracked_dict:
1623
+ hexbin_result = tracked_dict["result"]
1624
+
1625
+ for i, coll in enumerate(mpl_ax.collections):
1626
+ coll_type = type(coll).__name__
1627
+
1628
+ if coll_type == "QuadMesh":
1629
+ artist = {}
1630
+ artist["id"] = f"hist2d_{i}"
1631
+
1632
+ # Semantic layer
1633
+ artist["mark"] = "heatmap"
1634
+ artist["role"] = "hist2d"
1635
+
1636
+ artist["legend_included"] = False
1637
+ artist["zorder"] = coll.get_zorder()
1638
+
1639
+ # Backend layer
1640
+ backend = {
1641
+ "name": "matplotlib",
1642
+ "artist_class": coll_type,
1643
+ "props": {}
1644
+ }
1645
+ try:
1646
+ cmap = coll.get_cmap()
1647
+ if cmap:
1648
+ backend["props"]["cmap"] = cmap.name
1649
+ except (ValueError, TypeError, AttributeError):
1650
+ pass
1651
+ try:
1652
+ backend["props"]["vmin"] = float(coll.norm.vmin) if coll.norm else None
1653
+ backend["props"]["vmax"] = float(coll.norm.vmax) if coll.norm else None
1654
+ except (ValueError, TypeError, AttributeError):
1655
+ pass
1656
+
1657
+ artist["backend"] = backend
1658
+
1659
+ # Extract hist2d result data directly from QuadMesh
1660
+ try:
1661
+ # Get the count array from the QuadMesh
1662
+ arr = coll.get_array()
1663
+ if arr is not None and len(arr) > 0:
1664
+ import numpy as np
1665
+ # QuadMesh from hist2d has counts as flattened array
1666
+ # Try to get coordinates from the mesh
1667
+ coords = coll.get_coordinates()
1668
+ if coords is not None and len(coords) > 0:
1669
+ # coords shape is (n_rows+1, n_cols+1, 2) for 2D hist
1670
+ n_ybins = coords.shape[0] - 1
1671
+ n_xbins = coords.shape[1] - 1
1672
+
1673
+ # Get edges from coordinates
1674
+ xedges = coords[0, :, 0] # First row, all cols, x-coord
1675
+ yedges = coords[:, 0, 1] # All rows, first col, y-coord
1676
+
1677
+ artist["result"] = {
1678
+ "H_shape": [n_ybins, n_xbins],
1679
+ "n_xbins": int(n_xbins),
1680
+ "n_ybins": int(n_ybins),
1681
+ "xedges_range": [float(xedges[0]), float(xedges[-1])],
1682
+ "yedges_range": [float(yedges[0]), float(yedges[-1])],
1683
+ "count_range": [float(arr.min()), float(arr.max())],
1684
+ "total_count": int(arr.sum()),
1685
+ }
1686
+ except (IndexError, TypeError, AttributeError, ValueError):
1687
+ pass
1688
+
1689
+ artists.append(artist)
1690
+
1691
+ elif coll_type == "PolyCollection" or (coll_type == "FillBetweenPolyCollection" and plot_type == "violin"):
1692
+ arr = coll.get_array() if hasattr(coll, "get_array") else None
1693
+
1694
+ # Check if this is hexbin (has array data for counts) or violin body
1695
+ if arr is not None and len(arr) > 0 and plot_type == "hexbin":
1696
+ artist = {}
1697
+ artist["id"] = f"hexbin_{i}"
1698
+
1699
+ # Semantic layer
1700
+ artist["mark"] = "heatmap"
1701
+ artist["role"] = "hexbin"
1702
+
1703
+ artist["legend_included"] = False
1704
+ artist["zorder"] = coll.get_zorder()
1705
+
1706
+ # Backend layer
1707
+ backend = {
1708
+ "name": "matplotlib",
1709
+ "artist_class": coll_type,
1710
+ "props": {}
1711
+ }
1712
+ try:
1713
+ cmap = coll.get_cmap()
1714
+ if cmap:
1715
+ backend["props"]["cmap"] = cmap.name
1716
+ except (ValueError, TypeError, AttributeError):
1717
+ pass
1718
+ try:
1719
+ backend["props"]["vmin"] = float(coll.norm.vmin) if coll.norm else None
1720
+ backend["props"]["vmax"] = float(coll.norm.vmax) if coll.norm else None
1721
+ except (ValueError, TypeError, AttributeError):
1722
+ pass
1723
+
1724
+ artist["backend"] = backend
1725
+
1726
+ # Add hexbin result info directly from the PolyCollection
1727
+ try:
1728
+ artist["result"] = {
1729
+ "n_hexagons": int(len(arr)),
1730
+ "count_range": [float(arr.min()), float(arr.max())] if len(arr) > 0 else None,
1731
+ "total_count": int(arr.sum()),
1732
+ }
1733
+ except (TypeError, AttributeError, ValueError):
1734
+ pass
1735
+
1736
+ artists.append(artist)
1737
+
1738
+ elif plot_type == "violin":
1739
+ # This is a violin body (PolyCollection for violin shape)
1740
+ artist = {}
1741
+ scitex_id = getattr(coll, "_scitex_id", None)
1742
+ label = coll.get_label() if hasattr(coll, "get_label") else ""
1743
+
1744
+ if scitex_id:
1745
+ artist["id"] = f"{scitex_id}_body_{i}"
1746
+ artist["group_id"] = scitex_id
1747
+ else:
1748
+ artist["id"] = f"violin_body_{i}"
1749
+
1750
+ # Semantic layer
1751
+ artist["mark"] = "polygon"
1752
+ artist["role"] = "violin_body"
1753
+
1754
+ artist["legend_included"] = False
1755
+ artist["zorder"] = coll.get_zorder()
1756
+
1757
+ # Backend layer
1758
+ backend = {
1759
+ "name": "matplotlib",
1760
+ "artist_class": coll_type,
1761
+ "props": {}
1762
+ }
1763
+ try:
1764
+ facecolors = coll.get_facecolor()
1765
+ if len(facecolors) > 0:
1766
+ backend["props"]["facecolor"] = mcolors.to_hex(facecolors[0], keep_alpha=False)
1767
+ except (ValueError, TypeError, IndexError):
1768
+ pass
1769
+ try:
1770
+ edgecolors = coll.get_edgecolor()
1771
+ if len(edgecolors) > 0:
1772
+ backend["props"]["edgecolor"] = mcolors.to_hex(edgecolors[0], keep_alpha=False)
1773
+ except (ValueError, TypeError, IndexError):
1774
+ pass
1775
+
1776
+ artist["backend"] = backend
1777
+ artists.append(artist)
1778
+
1779
+ # Extract AxesImage (imshow)
1780
+ for i, img in enumerate(mpl_ax.images):
1781
+ img_type = type(img).__name__
1782
+
1783
+ artist = {}
1784
+
1785
+ scitex_id = getattr(img, "_scitex_id", None)
1786
+ label = img.get_label() if hasattr(img, "get_label") else ""
1787
+
1788
+ if scitex_id:
1789
+ artist["id"] = scitex_id
1790
+ elif label and not label.startswith("_"):
1791
+ artist["id"] = label
1792
+ else:
1793
+ artist["id"] = f"image_{i}"
1794
+
1795
+ # Semantic layer
1796
+ artist["mark"] = "image"
1797
+ artist["role"] = "image"
1798
+
1799
+ artist["legend_included"] = False
1800
+ artist["zorder"] = img.get_zorder()
1801
+
1802
+ # Backend layer
1803
+ backend = {
1804
+ "name": "matplotlib",
1805
+ "artist_class": img_type,
1806
+ "props": {}
1807
+ }
1808
+ try:
1809
+ cmap = img.get_cmap()
1810
+ if cmap:
1811
+ backend["props"]["cmap"] = cmap.name
1812
+ except (ValueError, TypeError, AttributeError):
1813
+ pass
1814
+ try:
1815
+ backend["props"]["vmin"] = float(img.norm.vmin) if img.norm else None
1816
+ backend["props"]["vmax"] = float(img.norm.vmax) if img.norm else None
1817
+ except (ValueError, TypeError, AttributeError):
1818
+ pass
1819
+ try:
1820
+ backend["props"]["interpolation"] = img.get_interpolation()
1821
+ except (ValueError, TypeError, AttributeError):
1822
+ pass
1823
+
1824
+ artist["backend"] = backend
1825
+ artists.append(artist)
1826
+
1827
+ # Extract Text artists (annotations, stats text, etc.)
1828
+ text_count = 0
1829
+ for i, text_obj in enumerate(mpl_ax.texts):
1830
+ text_content = text_obj.get_text()
1831
+ if not text_content or text_content.strip() == "":
1832
+ continue
1833
+
1834
+ artist = {}
1835
+
1836
+ scitex_id = getattr(text_obj, "_scitex_id", None)
1837
+
1838
+ if scitex_id:
1839
+ artist["id"] = scitex_id
1840
+ else:
1841
+ artist["id"] = f"text_{text_count}"
1842
+
1843
+ # Semantic layer
1844
+ artist["mark"] = "text"
1845
+
1846
+ # Try to determine role from content or position
1847
+ pos = text_obj.get_position()
1848
+ # Check if this looks like stats annotation (contains r=, p=, etc.)
1849
+ if any(kw in text_content.lower() for kw in ['r=', 'p=', 'r²=', 'n=']):
1850
+ artist["role"] = "stats_annotation"
1851
+ else:
1852
+ artist["role"] = "annotation"
1853
+
1854
+ artist["legend_included"] = False
1855
+ artist["zorder"] = text_obj.get_zorder()
1856
+
1857
+ # Geometry - text position
1858
+ artist["geometry"] = {
1859
+ "x": pos[0],
1860
+ "y": pos[1],
415
1861
  }
416
1862
 
417
- traces.append(trace)
1863
+ # Text content
1864
+ artist["text"] = text_content
1865
+
1866
+ # Backend layer
1867
+ backend = {
1868
+ "name": "matplotlib",
1869
+ "artist_class": type(text_obj).__name__,
1870
+ "props": {}
1871
+ }
1872
+
1873
+ try:
1874
+ color = text_obj.get_color()
1875
+ backend["props"]["color"] = mcolors.to_hex(color, keep_alpha=False)
1876
+ except (ValueError, TypeError):
1877
+ pass
1878
+
1879
+ try:
1880
+ backend["props"]["fontsize_pt"] = text_obj.get_fontsize()
1881
+ except (ValueError, TypeError):
1882
+ pass
1883
+
1884
+ try:
1885
+ backend["props"]["ha"] = text_obj.get_ha()
1886
+ backend["props"]["va"] = text_obj.get_va()
1887
+ except (ValueError, TypeError):
1888
+ pass
1889
+
1890
+ artist["backend"] = backend
1891
+
1892
+ # data_ref for text position - only if text was explicitly tracked (has _scitex_id)
1893
+ # Auto-generated text (like contour clabels, pie labels) doesn't have CSV data
1894
+ if scitex_id:
1895
+ artist["data_ref"] = {
1896
+ "x": f"text_{text_count}_x",
1897
+ "y": f"text_{text_count}_y",
1898
+ "content": f"text_{text_count}_content"
1899
+ }
1900
+
1901
+ text_count += 1
1902
+ artists.append(artist)
1903
+
1904
+ # Extract LineCollection artists (errorbar lines, etc.)
1905
+ for i, coll in enumerate(mpl_ax.collections):
1906
+ coll_type = type(coll).__name__
1907
+
1908
+ if coll_type == "LineCollection":
1909
+ # LineCollection is used for errorbar caps/lines
1910
+ artist = {}
1911
+
1912
+ scitex_id = getattr(coll, "_scitex_id", None)
1913
+ label = coll.get_label() if hasattr(coll, "get_label") else ""
1914
+
1915
+ if scitex_id:
1916
+ artist["id"] = scitex_id
1917
+ elif label and not label.startswith("_"):
1918
+ artist["id"] = label
1919
+ else:
1920
+ artist["id"] = f"linecollection_{i}"
1921
+
1922
+ # Semantic layer - determine role
1923
+ artist["mark"] = "line"
1924
+ # Check if this is an errorbar based on context
1925
+ if plot_type == "bar" or method == "barh":
1926
+ artist["role"] = "errorbar"
1927
+ elif plot_type == "stem":
1928
+ artist["role"] = "stem_stem"
1929
+ artist["id"] = "stem_lines" # Override ID for stem
1930
+ else:
1931
+ artist["role"] = "line_collection"
1932
+
1933
+ artist["legend_included"] = False
1934
+ artist["zorder"] = coll.get_zorder()
1935
+
1936
+ # Backend layer
1937
+ backend = {
1938
+ "name": "matplotlib",
1939
+ "artist_class": coll_type,
1940
+ "props": {}
1941
+ }
418
1942
 
419
- return traces
1943
+ try:
1944
+ colors = coll.get_colors()
1945
+ if len(colors) > 0:
1946
+ backend["props"]["color"] = mcolors.to_hex(colors[0], keep_alpha=False)
1947
+ except (ValueError, TypeError, IndexError):
1948
+ pass
1949
+
1950
+ try:
1951
+ linewidths = coll.get_linewidths()
1952
+ if len(linewidths) > 0:
1953
+ backend["props"]["linewidth_pt"] = float(linewidths[0])
1954
+ except (ValueError, TypeError, IndexError):
1955
+ pass
1956
+
1957
+ artist["backend"] = backend
1958
+
1959
+ # Add data_ref for errorbar LineCollections
1960
+ if artist["role"] == "errorbar":
1961
+ # Try to find the trace_id from history
1962
+ errorbar_trace_id = None
1963
+ error_var = "yerr" if method == "bar" else "xerr"
1964
+ if hasattr(ax_for_detection, "history"):
1965
+ for record in ax_for_detection.history.values():
1966
+ if isinstance(record, tuple) and len(record) >= 2:
1967
+ method_name = record[1]
1968
+ if method_name in ("bar", "barh"):
1969
+ errorbar_trace_id = record[0]
1970
+ break
1971
+ if errorbar_trace_id:
1972
+ base_ref = _get_csv_column_names(errorbar_trace_id, ax_row, ax_col)
1973
+ artist["data_ref"] = {
1974
+ "x": base_ref.get("x"),
1975
+ "y": base_ref.get("y"),
1976
+ error_var: f"ax-row-{ax_row}-col-{ax_col}_trace-id-{errorbar_trace_id}_variable-{error_var}"
1977
+ }
1978
+ elif artist["role"] == "stem_stem" and hasattr(ax_for_detection, "history"):
1979
+ # Add data_ref for stem LineCollection
1980
+ for record in ax_for_detection.history.values():
1981
+ if isinstance(record, tuple) and len(record) >= 2:
1982
+ method_name = record[1]
1983
+ if method_name == "stem":
1984
+ stem_trace_id = record[0]
1985
+ artist["data_ref"] = _get_csv_column_names(stem_trace_id, ax_row, ax_col)
1986
+ break
1987
+
1988
+ artists.append(artist)
1989
+
1990
+ return artists
1991
+
1992
+
1993
+ # Backward compatibility alias
1994
+ _extract_traces = _extract_artists
420
1995
 
421
1996
 
422
1997
  def _extract_legend_info(ax) -> Optional[dict]:
423
1998
  """
424
1999
  Extract legend information from axes.
425
2000
 
2001
+ Uses matplotlib terminology for legend properties.
2002
+
426
2003
  Parameters
427
2004
  ----------
428
2005
  ax : matplotlib.axes.Axes
@@ -431,7 +2008,7 @@ def _extract_legend_info(ax) -> Optional[dict]:
431
2008
  Returns
432
2009
  -------
433
2010
  dict or None
434
- Legend info dictionary or None if no legend
2011
+ Legend info dictionary with matplotlib properties, or None if no legend
435
2012
  """
436
2013
  legend = ax.get_legend()
437
2014
  if legend is None:
@@ -439,14 +2016,89 @@ def _extract_legend_info(ax) -> Optional[dict]:
439
2016
 
440
2017
  legend_info = {
441
2018
  "visible": legend.get_visible(),
442
- "loc": legend._loc if hasattr(legend, '_loc') else "best",
443
- "frameon": legend.get_frame_on() if hasattr(legend, 'get_frame_on') else True,
2019
+ "loc": legend._loc if hasattr(legend, "_loc") else "best",
2020
+ "frameon": legend.get_frame_on() if hasattr(legend, "get_frame_on") else True,
444
2021
  }
445
2022
 
446
- # Extract legend entries (labels)
2023
+ # ncol - number of columns
2024
+ if hasattr(legend, "_ncols"):
2025
+ legend_info["ncol"] = legend._ncols
2026
+ elif hasattr(legend, "_ncol"):
2027
+ legend_info["ncol"] = legend._ncol
2028
+
2029
+ # Extract legend handles with artist references
2030
+ # This allows reconstructing the legend by referencing artists
2031
+ handles = []
447
2032
  texts = legend.get_texts()
448
- if texts:
449
- legend_info["labels"] = [t.get_text() for t in texts]
2033
+ legend_handles = legend.legend_handles if hasattr(legend, 'legend_handles') else []
2034
+
2035
+ # Get the raw matplotlib axes for accessing lines to match IDs
2036
+ mpl_ax = ax._axis_mpl if hasattr(ax, "_axis_mpl") else ax
2037
+
2038
+ for i, text in enumerate(texts):
2039
+ label_text = text.get_text()
2040
+ handle_entry = {"label": label_text}
2041
+
2042
+ # Try to get artist_id from corresponding handle
2043
+ artist_id = None
2044
+ if i < len(legend_handles):
2045
+ handle = legend_handles[i]
2046
+ # Check if handle has scitex_id
2047
+ if hasattr(handle, "_scitex_id"):
2048
+ artist_id = handle._scitex_id
2049
+
2050
+ # Fallback: find matching artist by label in axes artists
2051
+ if artist_id is None:
2052
+ # Check lines
2053
+ for line in mpl_ax.lines:
2054
+ line_label = line.get_label()
2055
+ if line_label == label_text:
2056
+ if hasattr(line, "_scitex_id"):
2057
+ artist_id = line._scitex_id
2058
+ elif not line_label.startswith("_"):
2059
+ artist_id = line_label
2060
+ break
2061
+
2062
+ # Check collections (scatter)
2063
+ if artist_id is None:
2064
+ for coll in mpl_ax.collections:
2065
+ coll_label = coll.get_label() if hasattr(coll, "get_label") else ""
2066
+ if coll_label == label_text:
2067
+ if hasattr(coll, "_scitex_id"):
2068
+ artist_id = coll._scitex_id
2069
+ elif coll_label and not coll_label.startswith("_"):
2070
+ artist_id = coll_label
2071
+ break
2072
+
2073
+ # Check patches (bar/hist/pie)
2074
+ if artist_id is None:
2075
+ for patch in mpl_ax.patches:
2076
+ patch_label = patch.get_label() if hasattr(patch, "get_label") else ""
2077
+ if patch_label == label_text:
2078
+ if hasattr(patch, "_scitex_id"):
2079
+ artist_id = patch._scitex_id
2080
+ elif patch_label and not patch_label.startswith("_"):
2081
+ artist_id = patch_label
2082
+ break
2083
+
2084
+ # Check images (imshow)
2085
+ if artist_id is None:
2086
+ for img in mpl_ax.images:
2087
+ img_label = img.get_label() if hasattr(img, "get_label") else ""
2088
+ if img_label == label_text:
2089
+ if hasattr(img, "_scitex_id"):
2090
+ artist_id = img._scitex_id
2091
+ elif img_label and not img_label.startswith("_"):
2092
+ artist_id = img_label
2093
+ break
2094
+
2095
+ if artist_id:
2096
+ handle_entry["artist_id"] = artist_id
2097
+
2098
+ handles.append(handle_entry)
2099
+
2100
+ if handles:
2101
+ legend_info["handles"] = handles
450
2102
 
451
2103
  return legend_info
452
2104
 
@@ -478,93 +2130,1209 @@ def _detect_plot_type(ax) -> tuple:
478
2130
  or (None, None) if unclear
479
2131
  """
480
2132
  # Check scitex history FIRST (most reliable for scitex plots)
481
- if hasattr(ax, 'history') and len(ax.history) > 0:
482
- # Get the first plotting command
483
- first_cmd = ax.history[0].get('command', '')
484
- if 'stx_heatmap' in first_cmd:
485
- return "heatmap", "stx_heatmap"
486
- elif 'stx_kde' in first_cmd:
487
- return "kde", "stx_kde"
488
- elif 'stx_ecdf' in first_cmd:
489
- return "ecdf", "stx_ecdf"
490
- elif 'stx_violin' in first_cmd:
491
- return "violin", "stx_violin"
492
- elif 'stx_box' in first_cmd or 'boxplot' in first_cmd:
493
- return "boxplot", "stx_box"
494
- elif 'stx_line' in first_cmd:
495
- return "line", "stx_line"
496
- elif 'plot_scatter' in first_cmd:
497
- return "scatter", "plot_scatter"
498
- elif 'stx_mean_std' in first_cmd:
499
- return "line", "stx_mean_std"
500
- elif 'stx_shaded_line' in first_cmd:
501
- return "line", "stx_shaded_line"
502
- elif 'sns_boxplot' in first_cmd:
503
- return "boxplot", "sns_boxplot"
504
- elif 'sns_violinplot' in first_cmd:
505
- return "violin", "sns_violinplot"
506
- elif 'sns_scatterplot' in first_cmd:
507
- return "scatter", "sns_scatterplot"
508
- elif 'sns_lineplot' in first_cmd:
509
- return "line", "sns_lineplot"
510
- elif 'sns_histplot' in first_cmd:
511
- return "hist", "sns_histplot"
512
- elif 'sns_barplot' in first_cmd:
513
- return "bar", "sns_barplot"
514
- elif 'sns_stripplot' in first_cmd:
515
- return "scatter", "sns_stripplot"
516
- elif 'sns_kdeplot' in first_cmd:
517
- return "kde", "sns_kdeplot"
518
- elif 'scatter' in first_cmd:
519
- return "scatter", "scatter"
520
- elif 'bar' in first_cmd:
521
- return "bar", "bar"
522
- elif 'hist' in first_cmd:
523
- return "hist", "hist"
2133
+ # History format: dict with keys as IDs and values as tuples (id, method, tracked_dict, kwargs)
2134
+ if hasattr(ax, "history") and len(ax.history) > 0:
2135
+ # Get all methods from history
2136
+ methods = []
2137
+ for record in ax.history.values():
2138
+ if isinstance(record, tuple) and len(record) >= 2:
2139
+ methods.append(record[1]) # record[1] is the method name
2140
+
2141
+ # Check methods in priority order (more specific first)
2142
+ for method in methods:
2143
+ if method == "stx_heatmap":
2144
+ return "heatmap", "stx_heatmap"
2145
+ elif method == "stx_kde":
2146
+ return "kde", "stx_kde"
2147
+ elif method == "stx_ecdf":
2148
+ return "ecdf", "stx_ecdf"
2149
+ elif method == "stx_violin":
2150
+ return "violin", "stx_violin"
2151
+ elif method in ("stx_box", "boxplot"):
2152
+ return "boxplot", method
2153
+ elif method == "stx_line":
2154
+ return "line", "stx_line"
2155
+ elif method == "plot_scatter":
2156
+ return "scatter", "plot_scatter"
2157
+ elif method == "stx_mean_std":
2158
+ return "line", "stx_mean_std"
2159
+ elif method == "stx_mean_ci":
2160
+ return "line", "stx_mean_ci"
2161
+ elif method == "stx_median_iqr":
2162
+ return "line", "stx_median_iqr"
2163
+ elif method == "stx_shaded_line":
2164
+ return "line", "stx_shaded_line"
2165
+ elif method == "sns_boxplot":
2166
+ return "boxplot", "sns_boxplot"
2167
+ elif method == "sns_violinplot":
2168
+ return "violin", "sns_violinplot"
2169
+ elif method == "sns_scatterplot":
2170
+ return "scatter", "sns_scatterplot"
2171
+ elif method == "sns_lineplot":
2172
+ return "line", "sns_lineplot"
2173
+ elif method == "sns_histplot":
2174
+ return "hist", "sns_histplot"
2175
+ elif method == "sns_barplot":
2176
+ return "bar", "sns_barplot"
2177
+ elif method == "sns_stripplot":
2178
+ return "scatter", "sns_stripplot"
2179
+ elif method == "sns_kdeplot":
2180
+ return "kde", "sns_kdeplot"
2181
+ elif method == "scatter":
2182
+ return "scatter", "scatter"
2183
+ elif method == "bar":
2184
+ return "bar", "bar"
2185
+ elif method == "barh":
2186
+ return "bar", "barh"
2187
+ elif method == "hist":
2188
+ return "hist", "hist"
2189
+ elif method == "hist2d":
2190
+ return "hist2d", "hist2d"
2191
+ elif method == "hexbin":
2192
+ return "hexbin", "hexbin"
2193
+ elif method == "violinplot":
2194
+ return "violin", "violinplot"
2195
+ elif method == "errorbar":
2196
+ return "errorbar", "errorbar"
2197
+ elif method == "fill_between":
2198
+ return "fill", "fill_between"
2199
+ elif method == "fill_betweenx":
2200
+ return "fill", "fill_betweenx"
2201
+ elif method == "imshow":
2202
+ return "image", "imshow"
2203
+ elif method == "matshow":
2204
+ return "image", "matshow"
2205
+ elif method == "contour":
2206
+ return "contour", "contour"
2207
+ elif method == "contourf":
2208
+ return "contour", "contourf"
2209
+ elif method == "stem":
2210
+ return "stem", "stem"
2211
+ elif method == "step":
2212
+ return "step", "step"
2213
+ elif method == "pie":
2214
+ return "pie", "pie"
2215
+ elif method == "quiver":
2216
+ return "quiver", "quiver"
2217
+ elif method == "streamplot":
2218
+ return "stream", "streamplot"
2219
+ elif method == "plot":
2220
+ return "line", "plot"
2221
+ # Note: "plot" method is handled last as a fallback since boxplot uses it internally
524
2222
 
525
2223
  # Check for images (takes priority)
526
2224
  if len(ax.images) > 0:
527
2225
  return "image", "imshow"
528
2226
 
2227
+ # Check for 2D density plots (hist2d, hexbin) - QuadMesh or PolyCollection
2228
+ if hasattr(ax, "collections"):
2229
+ for coll in ax.collections:
2230
+ coll_type = type(coll).__name__
2231
+ if "QuadMesh" in coll_type:
2232
+ return "hist2d", "hist2d"
2233
+ if "PolyCollection" in coll_type and hasattr(coll, "get_array"):
2234
+ # hexbin creates PolyCollection with array data
2235
+ arr = coll.get_array()
2236
+ if arr is not None and len(arr) > 0:
2237
+ return "hexbin", "hexbin"
2238
+
529
2239
  # Check for contours
530
- if hasattr(ax, 'collections'):
2240
+ if hasattr(ax, "collections"):
531
2241
  for coll in ax.collections:
532
- if 'Contour' in type(coll).__name__:
2242
+ if "Contour" in type(coll).__name__:
533
2243
  return "contour", "contour"
534
2244
 
535
2245
  # Check for bar plots
536
2246
  if len(ax.containers) > 0:
537
2247
  # Check if it's a boxplot (has multiple containers with specific structure)
538
- if any('boxplot' in str(type(c)).lower() for c in ax.containers):
2248
+ if any("boxplot" in str(type(c)).lower() for c in ax.containers):
539
2249
  return "boxplot", "boxplot"
540
2250
  # Otherwise assume bar plot
541
2251
  return "bar", "bar"
542
2252
 
543
- # Check for patches (could be histogram, violin, etc.)
2253
+ # Check for patches (could be histogram, violin, pie, etc.)
544
2254
  if len(ax.patches) > 0:
2255
+ # Check for pie chart (Wedge patches)
2256
+ if any("Wedge" in type(p).__name__ for p in ax.patches):
2257
+ return "pie", "pie"
545
2258
  # If there are many rectangular patches, likely histogram
546
2259
  if len(ax.patches) > 5:
547
2260
  return "hist", "hist"
548
2261
  # Check for violin plot
549
- if any('Poly' in type(p).__name__ for p in ax.patches):
2262
+ if any("Poly" in type(p).__name__ for p in ax.patches):
550
2263
  return "violin", "violinplot"
551
2264
 
552
2265
  # Check for scatter plots (PathCollection)
553
- if hasattr(ax, 'collections') and len(ax.collections) > 0:
2266
+ if hasattr(ax, "collections") and len(ax.collections) > 0:
554
2267
  for coll in ax.collections:
555
- if 'PathCollection' in type(coll).__name__:
2268
+ if "PathCollection" in type(coll).__name__:
556
2269
  return "scatter", "scatter"
557
2270
 
558
2271
  # Check for line plots
559
2272
  if len(ax.lines) > 0:
560
2273
  # If there are error bars, it might be errorbar plot
561
- if any(hasattr(line, '_mpl_error') for line in ax.lines):
2274
+ if any(hasattr(line, "_mpl_error") for line in ax.lines):
562
2275
  return "errorbar", "errorbar"
563
2276
  return "line", "plot"
564
2277
 
565
2278
  return None, None
566
2279
 
567
2280
 
2281
+ def _extract_csv_columns_from_history(ax) -> list:
2282
+ """
2283
+ Extract CSV column names from scitex history for all plot types.
2284
+
2285
+ This function generates the exact column names that will be produced
2286
+ by export_as_csv(), providing a mapping between JSON metadata and CSV data.
2287
+
2288
+ Parameters
2289
+ ----------
2290
+ ax : AxisWrapper or matplotlib.axes.Axes
2291
+ The axes to extract CSV column info from
2292
+
2293
+ Returns
2294
+ -------
2295
+ list
2296
+ List of dictionaries containing CSV column mappings for each tracked plot,
2297
+ e.g., [{"id": "boxplot_0", "method": "boxplot", "columns": ["ax_00_boxplot_0_boxplot_0", "ax_00_boxplot_0_boxplot_1"]}]
2298
+ """
2299
+ from ._csv_column_naming import get_csv_column_name
2300
+
2301
+ # Get axes position for CSV column naming
2302
+ ax_row, ax_col = 0, 0 # Default for single axes
2303
+ if hasattr(ax, "_scitex_metadata") and "position_in_grid" in ax._scitex_metadata:
2304
+ pos = ax._scitex_metadata["position_in_grid"]
2305
+ ax_row, ax_col = pos[0], pos[1]
2306
+
2307
+ csv_columns_list = []
2308
+
2309
+ # Check if we have scitex history
2310
+ if not hasattr(ax, "history") or len(ax.history) == 0:
2311
+ return csv_columns_list
2312
+
2313
+ # Iterate through history to extract column names
2314
+ # Use enumerate to track trace index for proper CSV column naming
2315
+ for trace_index, (record_id, record) in enumerate(ax.history.items()):
2316
+ if not isinstance(record, tuple) or len(record) < 4:
2317
+ continue
2318
+
2319
+ id_val, method, tracked_dict, kwargs = record
2320
+
2321
+ # Generate column names using the same function as _extract_traces
2322
+ # This ensures consistency between plot.traces.csv_columns and data.columns
2323
+ columns = _get_csv_columns_for_method_with_index(
2324
+ id_val, method, tracked_dict, kwargs, ax_row, ax_col, trace_index
2325
+ )
2326
+
2327
+ if columns:
2328
+ csv_columns_list.append({
2329
+ "id": id_val,
2330
+ "method": method,
2331
+ "columns": columns,
2332
+ })
2333
+
2334
+ return csv_columns_list
2335
+
2336
+
2337
+ def _get_csv_columns_for_method_with_index(
2338
+ id_val, method, tracked_dict, kwargs, ax_row: int, ax_col: int, trace_index: int
2339
+ ) -> list:
2340
+ """
2341
+ Get CSV column names for a specific plotting method using trace index.
2342
+
2343
+ This function uses the same naming convention as _extract_traces to ensure
2344
+ consistency between plot.traces.csv_columns and data.columns.
2345
+
2346
+ Parameters
2347
+ ----------
2348
+ id_val : str
2349
+ The plot ID (e.g., "sine", "cosine")
2350
+ method : str
2351
+ The plotting method name (e.g., "plot", "scatter")
2352
+ tracked_dict : dict
2353
+ The tracked data dictionary
2354
+ kwargs : dict
2355
+ The keyword arguments passed to the plot
2356
+ ax_row : int
2357
+ Row index of axes in grid
2358
+ ax_col : int
2359
+ Column index of axes in grid
2360
+ trace_index : int
2361
+ Index of this trace (for deduplication)
2362
+
2363
+ Returns
2364
+ -------
2365
+ list
2366
+ List of column names that will be in the CSV
2367
+ """
2368
+ from ._csv_column_naming import get_csv_column_name
2369
+
2370
+ columns = []
2371
+
2372
+ # Use simplified variable names (x, y, bins, counts, etc.)
2373
+ # The full context comes from the column name structure:
2374
+ # ax-row_{row}_ax-col_{col}_trace-id_{id}_variable_{var}
2375
+ if method in ("plot", "stx_line"):
2376
+ columns = [
2377
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2378
+ get_csv_column_name("y", ax_row, ax_col, trace_index=trace_index),
2379
+ ]
2380
+ elif method in ("scatter", "plot_scatter"):
2381
+ columns = [
2382
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2383
+ get_csv_column_name("y", ax_row, ax_col, trace_index=trace_index),
2384
+ ]
2385
+ elif method in ("bar", "barh"):
2386
+ columns = [
2387
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2388
+ get_csv_column_name("height", ax_row, ax_col, trace_index=trace_index),
2389
+ ]
2390
+ elif method == "hist":
2391
+ columns = [
2392
+ get_csv_column_name("bins", ax_row, ax_col, trace_index=trace_index),
2393
+ get_csv_column_name("counts", ax_row, ax_col, trace_index=trace_index),
2394
+ ]
2395
+ elif method in ("boxplot", "stx_box"):
2396
+ columns = [
2397
+ get_csv_column_name("data", ax_row, ax_col, trace_index=trace_index),
2398
+ ]
2399
+ elif method in ("violinplot", "stx_violin"):
2400
+ columns = [
2401
+ get_csv_column_name("data", ax_row, ax_col, trace_index=trace_index),
2402
+ ]
2403
+ elif method == "errorbar":
2404
+ columns = [
2405
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2406
+ get_csv_column_name("y", ax_row, ax_col, trace_index=trace_index),
2407
+ get_csv_column_name("yerr", ax_row, ax_col, trace_index=trace_index),
2408
+ ]
2409
+ elif method == "fill_between":
2410
+ columns = [
2411
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2412
+ get_csv_column_name("y1", ax_row, ax_col, trace_index=trace_index),
2413
+ get_csv_column_name("y2", ax_row, ax_col, trace_index=trace_index),
2414
+ ]
2415
+ elif method in ("imshow", "stx_heatmap", "stx_image"):
2416
+ columns = [
2417
+ get_csv_column_name("data", ax_row, ax_col, trace_index=trace_index),
2418
+ ]
2419
+ elif method in ("stx_kde", "stx_ecdf"):
2420
+ columns = [
2421
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2422
+ get_csv_column_name("y", ax_row, ax_col, trace_index=trace_index),
2423
+ ]
2424
+ elif method in ("stx_mean_std", "stx_mean_ci", "stx_median_iqr", "stx_shaded_line"):
2425
+ columns = [
2426
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2427
+ get_csv_column_name("y", ax_row, ax_col, trace_index=trace_index),
2428
+ get_csv_column_name("lower", ax_row, ax_col, trace_index=trace_index),
2429
+ get_csv_column_name("upper", ax_row, ax_col, trace_index=trace_index),
2430
+ ]
2431
+ elif method.startswith("sns_"):
2432
+ sns_type = method.replace("sns_", "")
2433
+ if sns_type in ("boxplot", "violinplot"):
2434
+ columns = [
2435
+ get_csv_column_name("data", ax_row, ax_col, trace_index=trace_index),
2436
+ ]
2437
+ elif sns_type in ("scatterplot", "lineplot"):
2438
+ columns = [
2439
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2440
+ get_csv_column_name("y", ax_row, ax_col, trace_index=trace_index),
2441
+ ]
2442
+ elif sns_type == "barplot":
2443
+ columns = [
2444
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2445
+ get_csv_column_name("y", ax_row, ax_col, trace_index=trace_index),
2446
+ ]
2447
+ elif sns_type == "histplot":
2448
+ columns = [
2449
+ get_csv_column_name("bins", ax_row, ax_col, trace_index=trace_index),
2450
+ get_csv_column_name("counts", ax_row, ax_col, trace_index=trace_index),
2451
+ ]
2452
+ elif sns_type == "kdeplot":
2453
+ columns = [
2454
+ get_csv_column_name("x", ax_row, ax_col, trace_index=trace_index),
2455
+ get_csv_column_name("y", ax_row, ax_col, trace_index=trace_index),
2456
+ ]
2457
+
2458
+ return columns
2459
+
2460
+
2461
+ def _compute_csv_hash_from_df(df) -> Optional[str]:
2462
+ """
2463
+ Compute a hash of CSV data from a DataFrame.
2464
+
2465
+ This is used after actual CSV export to compute the hash from the
2466
+ exact data that was written.
2467
+
2468
+ Parameters
2469
+ ----------
2470
+ df : pandas.DataFrame
2471
+ The DataFrame to compute hash from
2472
+
2473
+ Returns
2474
+ -------
2475
+ str or None
2476
+ SHA256 hash of the CSV data (first 16 chars), or None if unable to compute
2477
+ """
2478
+ import hashlib
2479
+
2480
+ try:
2481
+ if df is None or df.empty:
2482
+ return None
2483
+
2484
+ # Convert to CSV string for hashing
2485
+ csv_string = df.to_csv(index=False)
2486
+
2487
+ # Compute SHA256 hash
2488
+ hash_obj = hashlib.sha256(csv_string.encode("utf-8"))
2489
+ hash_hex = hash_obj.hexdigest()
2490
+
2491
+ # Return first 16 characters for readability
2492
+ return hash_hex[:16]
2493
+
2494
+ except Exception:
2495
+ return None
2496
+
2497
+
2498
+ def _compute_csv_hash(ax_or_df) -> Optional[str]:
2499
+ """
2500
+ Compute a hash of the CSV data for reproducibility verification.
2501
+
2502
+ The hash is computed from the actual data that would be exported to CSV,
2503
+ allowing verification that JSON and CSV files are in sync.
2504
+
2505
+ Note: The hash is computed from the AxisWrapper's export_as_csv(), which
2506
+ does NOT include the ax_{index:02d}_ prefix. The FigWrapper.export_as_csv()
2507
+ adds this prefix. We replicate this prefix addition here.
2508
+
2509
+ Parameters
2510
+ ----------
2511
+ ax_or_df : AxisWrapper, matplotlib.axes.Axes, or pandas.DataFrame
2512
+ The axes to compute CSV hash from, or a pre-exported DataFrame
2513
+
2514
+ Returns
2515
+ -------
2516
+ str or None
2517
+ SHA256 hash of the CSV data (first 16 chars), or None if unable to compute
2518
+ """
2519
+ import hashlib
2520
+
2521
+ import pandas as pd
2522
+
2523
+ # If it's already a DataFrame, use the direct hash function
2524
+ if isinstance(ax_or_df, pd.DataFrame):
2525
+ return _compute_csv_hash_from_df(ax_or_df)
2526
+
2527
+ ax = ax_or_df
2528
+
2529
+ # Check if we have scitex history with export capability
2530
+ if not hasattr(ax, "export_as_csv"):
2531
+ return None
2532
+
2533
+ try:
2534
+ # For single axes figures (most common case), ax_index = 0
2535
+ ax_index = 0
2536
+
2537
+ # Export the data as CSV from the AxisWrapper
2538
+ df = ax.export_as_csv()
2539
+
2540
+ if df is None or df.empty:
2541
+ return None
2542
+
2543
+ # Add axis prefix to match what FigWrapper.export_as_csv produces
2544
+ # Uses zero-padded index: ax_00_, ax_01_, etc.
2545
+ prefix = f"ax_{ax_index:02d}_"
2546
+ new_cols = []
2547
+ for col in df.columns:
2548
+ col_str = str(col)
2549
+ if not col_str.startswith(prefix):
2550
+ col_str = f"{prefix}{col_str}"
2551
+ new_cols.append(col_str)
2552
+ df.columns = new_cols
2553
+
2554
+ # Convert to CSV string for hashing
2555
+ csv_string = df.to_csv(index=False)
2556
+
2557
+ # Compute SHA256 hash
2558
+ hash_obj = hashlib.sha256(csv_string.encode("utf-8"))
2559
+ hash_hex = hash_obj.hexdigest()
2560
+
2561
+ # Return first 16 characters for readability
2562
+ return hash_hex[:16]
2563
+
2564
+ except Exception:
2565
+ return None
2566
+
2567
+
2568
+ def _get_csv_columns_for_method(id_val, method, tracked_dict, kwargs, ax_index: int) -> list:
2569
+ """
2570
+ Get CSV column names for a specific plotting method.
2571
+
2572
+ This simulates the actual CSV export to get exact column names.
2573
+ It uses the same formatters that generate the CSV to ensure consistency.
2574
+
2575
+ Architecture note:
2576
+ - CSV formatters (e.g., _format_boxplot) generate columns WITHOUT ax_ prefix
2577
+ - FigWrapper.export_as_csv() adds the ax_{index:02d}_ prefix
2578
+ - This function simulates that process to get the final column names
2579
+
2580
+ Parameters
2581
+ ----------
2582
+ id_val : str
2583
+ The plot ID (e.g., "boxplot_0", "plot_0")
2584
+ method : str
2585
+ The plotting method name (e.g., "boxplot", "plot", "scatter")
2586
+ tracked_dict : dict
2587
+ The tracked data dictionary
2588
+ kwargs : dict
2589
+ The keyword arguments passed to the plot
2590
+ ax_index : int
2591
+ Flattened index of axes (0 for single axes, 0-N for multi-axes)
2592
+
2593
+ Returns
2594
+ -------
2595
+ list
2596
+ List of column names that will be in the CSV (exact match)
2597
+ """
2598
+ # Import the actual formatters to ensure consistency
2599
+ # This is the single source of truth - we use the same code path as CSV export
2600
+ try:
2601
+ from scitex.plt._subplots._export_as_csv import format_record
2602
+ import pandas as pd
2603
+
2604
+ # Construct the record tuple as used in tracking
2605
+ record = (id_val, method, tracked_dict, kwargs)
2606
+
2607
+ # Call the actual formatter to get the DataFrame
2608
+ df = format_record(record)
2609
+
2610
+ if df is not None and not df.empty:
2611
+ # Add the axis prefix (this is what FigWrapper.export_as_csv does)
2612
+ # Uses zero-padded index: ax_00_, ax_01_, etc.
2613
+ prefix = f"ax_{ax_index:02d}_"
2614
+ columns = []
2615
+ for col in df.columns:
2616
+ col_str = str(col)
2617
+ if not col_str.startswith(prefix):
2618
+ col_str = f"{prefix}{col_str}"
2619
+ columns.append(col_str)
2620
+ return columns
2621
+
2622
+ except Exception:
2623
+ # If formatters fail, fall back to pattern-based generation
2624
+ pass
2625
+
2626
+ # Fallback: Pattern-based column name generation
2627
+ # This should rarely be used since we prefer the actual formatter
2628
+ import numpy as np
2629
+
2630
+ prefix = f"ax_{ax_index:02d}_"
2631
+ columns = []
2632
+
2633
+ # Get args from tracked_dict
2634
+ args = tracked_dict.get("args", []) if tracked_dict else []
2635
+
2636
+ if method in ("boxplot", "stx_box"):
2637
+ # Boxplot: one column per box (mirrors _format_boxplot)
2638
+ if len(args) >= 1:
2639
+ data = args[0]
2640
+ labels = kwargs.get("labels", None) if kwargs else None
2641
+
2642
+ from scitex.types import is_listed_X as scitex_types_is_listed_X
2643
+
2644
+ if isinstance(data, np.ndarray) or scitex_types_is_listed_X(data, [float, int]):
2645
+ # Single box
2646
+ if labels and len(labels) == 1:
2647
+ columns.append(f"{prefix}{id_val}_{labels[0]}")
2648
+ else:
2649
+ columns.append(f"{prefix}{id_val}_boxplot_0")
2650
+ else:
2651
+ # Multiple boxes
2652
+ try:
2653
+ num_boxes = len(data)
2654
+ if labels and len(labels) == num_boxes:
2655
+ for label in labels:
2656
+ columns.append(f"{prefix}{id_val}_{label}")
2657
+ else:
2658
+ for i in range(num_boxes):
2659
+ columns.append(f"{prefix}{id_val}_boxplot_{i}")
2660
+ except TypeError:
2661
+ columns.append(f"{prefix}{id_val}_boxplot_0")
2662
+
2663
+ elif method in ("plot", "stx_line"):
2664
+ # Line plot: x and y columns
2665
+ # For single axes (ax_index=0), use simple prefix
2666
+ columns.append(f"{prefix}{id_val}_plot_x")
2667
+ columns.append(f"{prefix}{id_val}_plot_y")
2668
+
2669
+ elif method in ("scatter", "plot_scatter"):
2670
+ columns.append(f"{prefix}{id_val}_scatter_x")
2671
+ columns.append(f"{prefix}{id_val}_scatter_y")
2672
+
2673
+ elif method in ("bar", "barh"):
2674
+ columns.append(f"{prefix}{id_val}_bar_x")
2675
+ columns.append(f"{prefix}{id_val}_bar_height")
2676
+
2677
+ elif method == "hist":
2678
+ columns.append(f"{prefix}{id_val}_hist_bins")
2679
+ columns.append(f"{prefix}{id_val}_hist_counts")
2680
+
2681
+ elif method in ("violinplot", "stx_violin"):
2682
+ if len(args) >= 1:
2683
+ data = args[0]
2684
+ try:
2685
+ num_violins = len(data)
2686
+ for i in range(num_violins):
2687
+ columns.append(f"{prefix}{id_val}_violin_{i}")
2688
+ except TypeError:
2689
+ columns.append(f"{prefix}{id_val}_violin_0")
2690
+
2691
+ elif method == "errorbar":
2692
+ columns.append(f"{prefix}{id_val}_errorbar_x")
2693
+ columns.append(f"{prefix}{id_val}_errorbar_y")
2694
+ columns.append(f"{prefix}{id_val}_errorbar_yerr")
2695
+
2696
+ elif method == "fill_between":
2697
+ columns.append(f"{prefix}{id_val}_fill_x")
2698
+ columns.append(f"{prefix}{id_val}_fill_y1")
2699
+ columns.append(f"{prefix}{id_val}_fill_y2")
2700
+
2701
+ elif method in ("imshow", "stx_heatmap", "stx_image"):
2702
+ if len(args) >= 1:
2703
+ data = args[0]
2704
+ try:
2705
+ if hasattr(data, "shape") and len(data.shape) >= 2:
2706
+ columns.append(f"{prefix}{id_val}_image_data")
2707
+ except (TypeError, AttributeError):
2708
+ pass
2709
+
2710
+ elif method in ("stx_kde", "stx_ecdf"):
2711
+ suffix = method.replace("stx_", "")
2712
+ columns.append(f"{prefix}{id_val}_{suffix}_x")
2713
+ columns.append(f"{prefix}{id_val}_{suffix}_y")
2714
+
2715
+ elif method in ("stx_mean_std", "stx_mean_ci", "stx_median_iqr", "stx_shaded_line"):
2716
+ suffix = method.replace("stx_", "")
2717
+ columns.append(f"{prefix}{id_val}_{suffix}_x")
2718
+ columns.append(f"{prefix}{id_val}_{suffix}_y")
2719
+ columns.append(f"{prefix}{id_val}_{suffix}_lower")
2720
+ columns.append(f"{prefix}{id_val}_{suffix}_upper")
2721
+
2722
+ elif method.startswith("sns_"):
2723
+ sns_type = method.replace("sns_", "")
2724
+ if sns_type in ("boxplot", "violinplot"):
2725
+ columns.append(f"{prefix}{id_val}_{sns_type}_data")
2726
+ elif sns_type in ("scatterplot", "lineplot"):
2727
+ columns.append(f"{prefix}{id_val}_{sns_type}_x")
2728
+ columns.append(f"{prefix}{id_val}_{sns_type}_y")
2729
+ elif sns_type == "barplot":
2730
+ columns.append(f"{prefix}{id_val}_barplot_x")
2731
+ columns.append(f"{prefix}{id_val}_barplot_y")
2732
+ elif sns_type == "histplot":
2733
+ columns.append(f"{prefix}{id_val}_histplot_bins")
2734
+ columns.append(f"{prefix}{id_val}_histplot_counts")
2735
+ elif sns_type == "kdeplot":
2736
+ columns.append(f"{prefix}{id_val}_kdeplot_x")
2737
+ columns.append(f"{prefix}{id_val}_kdeplot_y")
2738
+
2739
+ return columns
2740
+
2741
+
2742
+ def assert_csv_json_consistency(csv_path: str, json_path: str = None) -> None:
2743
+ """
2744
+ Assert that CSV data file and its JSON metadata are consistent.
2745
+
2746
+ Raises AssertionError if the column names don't match.
2747
+
2748
+ Parameters
2749
+ ----------
2750
+ csv_path : str
2751
+ Path to the CSV data file
2752
+ json_path : str, optional
2753
+ Path to the JSON metadata file. If not provided, assumes
2754
+ the JSON is at the same location with .json extension.
2755
+
2756
+ Raises
2757
+ ------
2758
+ AssertionError
2759
+ If CSV and JSON column names don't match
2760
+ FileNotFoundError
2761
+ If CSV or JSON files don't exist
2762
+
2763
+ Examples
2764
+ --------
2765
+ >>> assert_csv_json_consistency('/tmp/plot.csv') # Passes silently if valid
2766
+ >>> # Or use in tests:
2767
+ >>> try:
2768
+ ... assert_csv_json_consistency('/tmp/plot.csv')
2769
+ ... except AssertionError as e:
2770
+ ... print(f"Validation failed: {e}")
2771
+ """
2772
+ result = verify_csv_json_consistency(csv_path, json_path)
2773
+
2774
+ if result['errors']:
2775
+ raise FileNotFoundError('\n'.join(result['errors']))
2776
+
2777
+ if not result['valid']:
2778
+ msg_parts = ["CSV/JSON consistency check failed:"]
2779
+ if result['missing_in_csv']:
2780
+ msg_parts.append(f" columns_actual missing in CSV: {result['missing_in_csv']}")
2781
+ if result['extra_in_csv']:
2782
+ msg_parts.append(f" Extra columns in CSV: {result['extra_in_csv']}")
2783
+ if result.get('data_ref_missing'):
2784
+ msg_parts.append(f" data_ref columns missing in CSV: {result['data_ref_missing']}")
2785
+ raise AssertionError('\n'.join(msg_parts))
2786
+
2787
+
2788
+ def verify_csv_json_consistency(csv_path: str, json_path: str = None) -> dict:
2789
+ """
2790
+ Verify consistency between CSV data file and its JSON metadata.
2791
+
2792
+ This function checks that:
2793
+ 1. Column names in the CSV file match those declared in JSON's columns_actual
2794
+ 2. Artist data_ref values in JSON match actual CSV column names
2795
+
2796
+ Parameters
2797
+ ----------
2798
+ csv_path : str
2799
+ Path to the CSV data file
2800
+ json_path : str, optional
2801
+ Path to the JSON metadata file. If not provided, assumes
2802
+ the JSON is at the same location with .json extension.
2803
+
2804
+ Returns
2805
+ -------
2806
+ dict
2807
+ Verification result with keys:
2808
+ - 'valid': bool - True if CSV and JSON are consistent
2809
+ - 'csv_columns': list - Column names found in CSV
2810
+ - 'json_columns': list - Column names declared in JSON
2811
+ - 'data_ref_columns': list - Column names from artist data_ref
2812
+ - 'missing_in_csv': list - Columns in JSON but not in CSV
2813
+ - 'extra_in_csv': list - Columns in CSV but not in JSON
2814
+ - 'data_ref_missing': list - data_ref columns not found in CSV
2815
+ - 'errors': list - Any error messages
2816
+
2817
+ Examples
2818
+ --------
2819
+ >>> result = verify_csv_json_consistency('/tmp/plot.csv')
2820
+ >>> print(result['valid'])
2821
+ True
2822
+ >>> print(result['missing_in_csv'])
2823
+ []
2824
+ """
2825
+ import json
2826
+ import os
2827
+ import pandas as pd
2828
+
2829
+ result = {
2830
+ 'valid': False,
2831
+ 'csv_columns': [],
2832
+ 'json_columns': [],
2833
+ 'data_ref_columns': [],
2834
+ 'missing_in_csv': [],
2835
+ 'extra_in_csv': [],
2836
+ 'data_ref_missing': [],
2837
+ 'errors': [],
2838
+ }
2839
+
2840
+ # Determine JSON path
2841
+ if json_path is None:
2842
+ base, _ = os.path.splitext(csv_path)
2843
+ json_path = base + '.json'
2844
+
2845
+ # Check files exist
2846
+ if not os.path.exists(csv_path):
2847
+ result['errors'].append(f"CSV file not found: {csv_path}")
2848
+ return result
2849
+ if not os.path.exists(json_path):
2850
+ result['errors'].append(f"JSON file not found: {json_path}")
2851
+ return result
2852
+
2853
+ try:
2854
+ # Read CSV columns
2855
+ df = pd.read_csv(csv_path, nrows=0) # Just read header
2856
+ csv_columns = list(df.columns)
2857
+ result['csv_columns'] = csv_columns
2858
+ except Exception as e:
2859
+ result['errors'].append(f"Error reading CSV: {e}")
2860
+ return result
2861
+
2862
+ try:
2863
+ # Read JSON metadata
2864
+ with open(json_path, 'r') as f:
2865
+ metadata = json.load(f)
2866
+
2867
+ # Get columns_actual from data section
2868
+ json_columns = []
2869
+ if 'data' in metadata and 'columns_actual' in metadata['data']:
2870
+ json_columns = metadata['data']['columns_actual']
2871
+ result['json_columns'] = json_columns
2872
+
2873
+ # Extract data_ref columns from artists
2874
+ # Skip 'derived_from' key as it contains descriptive text, not CSV column names
2875
+ # Also skip 'row_index' as it's a numeric index, not a column name
2876
+ data_ref_columns = []
2877
+ skip_keys = {'derived_from', 'row_index'}
2878
+ if 'axes' in metadata:
2879
+ for ax_key, ax_data in metadata['axes'].items():
2880
+ if 'artists' in ax_data:
2881
+ for artist in ax_data['artists']:
2882
+ if 'data_ref' in artist:
2883
+ for key, val in artist['data_ref'].items():
2884
+ if key not in skip_keys and isinstance(val, str):
2885
+ data_ref_columns.append(val)
2886
+ result['data_ref_columns'] = data_ref_columns
2887
+
2888
+ except Exception as e:
2889
+ result['errors'].append(f"Error reading JSON: {e}")
2890
+ return result
2891
+
2892
+ # Compare columns_actual with CSV
2893
+ csv_set = set(csv_columns)
2894
+ json_set = set(json_columns)
2895
+
2896
+ result['missing_in_csv'] = list(json_set - csv_set)
2897
+ result['extra_in_csv'] = list(csv_set - json_set)
2898
+
2899
+ # Check data_ref columns exist in CSV (if there are any)
2900
+ if data_ref_columns:
2901
+ data_ref_set = set(data_ref_columns)
2902
+ result['data_ref_missing'] = list(data_ref_set - csv_set)
2903
+
2904
+ # Valid only if columns_actual matches AND data_ref columns are found in CSV
2905
+ result['valid'] = (
2906
+ len(result['missing_in_csv']) == 0 and
2907
+ len(result['extra_in_csv']) == 0 and
2908
+ len(result['data_ref_missing']) == 0
2909
+ )
2910
+
2911
+ return result
2912
+
2913
+
2914
+ def collect_recipe_metadata(
2915
+ fig,
2916
+ ax=None,
2917
+ auto_crop: bool = True,
2918
+ crop_margin_mm: float = 1.0,
2919
+ ) -> Dict:
2920
+ """
2921
+ Collect minimal "recipe" metadata from figure - method calls + data refs.
2922
+
2923
+ Unlike `collect_figure_metadata()` which captures every rendered artist,
2924
+ this function captures only what's needed to reproduce the figure:
2925
+ - Figure/axes dimensions and limits
2926
+ - Method calls with arguments (from ax.history)
2927
+ - Data column references for CSV linkage
2928
+ - Cropping settings
2929
+
2930
+ This produces much smaller JSON files (e.g., 60 lines vs 1300 for histogram).
2931
+
2932
+ Parameters
2933
+ ----------
2934
+ fig : matplotlib.figure.Figure
2935
+ Figure to collect metadata from
2936
+ ax : matplotlib.axes.Axes or AxisWrapper, optional
2937
+ Primary axes to collect from. If not provided, uses first axes.
2938
+ auto_crop : bool, optional
2939
+ Whether auto-cropping is enabled. Default is True.
2940
+ crop_margin_mm : float, optional
2941
+ Margin in mm for auto-cropping. Default is 1.0.
2942
+
2943
+ Returns
2944
+ -------
2945
+ dict
2946
+ Minimal metadata dictionary with structure:
2947
+ - scitex_schema: "scitex.plt.figure.recipe"
2948
+ - scitex_schema_version: "0.2.0"
2949
+ - figure: {size_mm, dpi, mode, auto_crop, crop_margin_mm}
2950
+ - axes: {ax_00: {xaxis, yaxis, calls: [...]}}
2951
+ - data: {csv_path, columns}
2952
+
2953
+ Examples
2954
+ --------
2955
+ >>> fig, ax = scitex.plt.subplots()
2956
+ >>> ax.hist(data, bins=40, id="histogram")
2957
+ >>> metadata = collect_recipe_metadata(fig, ax)
2958
+ >>> # Result has ~60 lines instead of ~1300
2959
+ """
2960
+ import datetime
2961
+ import uuid
2962
+
2963
+ import matplotlib
2964
+ import scitex
2965
+
2966
+ metadata = {
2967
+ "scitex_schema": "scitex.plt.figure.recipe",
2968
+ "scitex_schema_version": "0.2.0",
2969
+ "figure_uuid": str(uuid.uuid4()),
2970
+ "runtime": {
2971
+ "scitex_version": scitex.__version__,
2972
+ "matplotlib_version": matplotlib.__version__,
2973
+ "created_at": datetime.datetime.now().isoformat(),
2974
+ },
2975
+ }
2976
+
2977
+ # Collect axes - handle AxesWrapper (multi-axes) properly
2978
+ all_axes = [] # List of (ax_wrapper, row, col) tuples
2979
+ grid_shape = (1, 1)
2980
+
2981
+ if ax is not None:
2982
+ # Handle AxesWrapper (multi-axes) - extract individual AxisWrappers with positions
2983
+ if hasattr(ax, "_axes_scitex"):
2984
+ import numpy as np
2985
+ axes_array = ax._axes_scitex
2986
+ if isinstance(axes_array, np.ndarray):
2987
+ grid_shape = axes_array.shape
2988
+ for idx, ax_item in enumerate(axes_array.flat):
2989
+ row = idx // grid_shape[1]
2990
+ col = idx % grid_shape[1]
2991
+ all_axes.append((ax_item, row, col))
2992
+ else:
2993
+ all_axes = [(axes_array, 0, 0)]
2994
+ # Handle AxisWrapper (single axes)
2995
+ elif hasattr(ax, "_axis_mpl"):
2996
+ all_axes = [(ax, 0, 0)]
2997
+ else:
2998
+ # Assume it's a matplotlib axes
2999
+ all_axes = [(ax, 0, 0)]
3000
+ elif hasattr(fig, "axes") and len(fig.axes) > 0:
3001
+ # Fallback to figure axes (linear indexing)
3002
+ for idx, ax_item in enumerate(fig.axes):
3003
+ all_axes.append((ax_item, 0, idx))
3004
+
3005
+ # Figure-level properties
3006
+ if all_axes:
3007
+ try:
3008
+ from ._figure_from_axes_mm import get_dimension_info
3009
+ first_ax_tuple = all_axes[0]
3010
+ first_ax = first_ax_tuple[0]
3011
+ # Get underlying matplotlib axis if wrapped
3012
+ mpl_ax = getattr(first_ax, '_axis_mpl', first_ax)
3013
+ dim_info = get_dimension_info(fig, mpl_ax)
3014
+
3015
+ # Convert to plain lists/floats for JSON serialization
3016
+ size_mm = dim_info["figure_size_mm"]
3017
+ if hasattr(size_mm, 'tolist'):
3018
+ size_mm = size_mm.tolist()
3019
+ elif isinstance(size_mm, (list, tuple)):
3020
+ size_mm = [float(v) if hasattr(v, 'value') else v for v in size_mm]
3021
+
3022
+ metadata["figure"] = {
3023
+ "size_mm": size_mm,
3024
+ "dpi": int(dim_info["dpi"]),
3025
+ "auto_crop": auto_crop,
3026
+ "crop_margin_mm": crop_margin_mm,
3027
+ }
3028
+
3029
+ # Add top-level axes_bbox_px for canvas/web alignment (x0/y0/x1/y1 format)
3030
+ # x0: left edge (Y-axis position), y1: bottom edge (X-axis position)
3031
+ if "axes_bbox_px" in dim_info:
3032
+ bbox = dim_info["axes_bbox_px"]
3033
+ metadata["axes_bbox_px"] = {
3034
+ "x0": int(bbox["x0"]),
3035
+ "y0": int(bbox["y0"]),
3036
+ "x1": int(bbox["x1"]),
3037
+ "y1": int(bbox["y1"]),
3038
+ "width": int(bbox["width"]),
3039
+ "height": int(bbox["height"]),
3040
+ }
3041
+ if "axes_bbox_mm" in dim_info:
3042
+ bbox = dim_info["axes_bbox_mm"]
3043
+ metadata["axes_bbox_mm"] = {
3044
+ "x0": round(float(bbox["x0"]), 2),
3045
+ "y0": round(float(bbox["y0"]), 2),
3046
+ "x1": round(float(bbox["x1"]), 2),
3047
+ "y1": round(float(bbox["y1"]), 2),
3048
+ "width": round(float(bbox["width"]), 2),
3049
+ "height": round(float(bbox["height"]), 2),
3050
+ }
3051
+ except Exception:
3052
+ pass
3053
+
3054
+ # Add mode from scitex metadata
3055
+ scitex_meta = None
3056
+ if ax is not None and hasattr(ax, "_scitex_metadata"):
3057
+ scitex_meta = ax._scitex_metadata
3058
+ elif hasattr(fig, "_scitex_metadata"):
3059
+ scitex_meta = fig._scitex_metadata
3060
+
3061
+ if scitex_meta:
3062
+ if "figure" not in metadata:
3063
+ metadata["figure"] = {}
3064
+ if "mode" in scitex_meta:
3065
+ metadata["figure"]["mode"] = scitex_meta["mode"]
3066
+ # Include style_mm for reproducibility (thickness, fonts, etc.)
3067
+ if "style_mm" in scitex_meta:
3068
+ metadata["style"] = scitex_meta["style_mm"]
3069
+
3070
+ # Collect per-axes metadata with calls
3071
+ if all_axes:
3072
+ metadata["axes"] = {}
3073
+ for current_ax, row, col in all_axes:
3074
+ # Use row-col format: ax_00, ax_01, ax_10, ax_11 for 2x2 grid
3075
+ ax_key = f"ax_{row}{col}"
3076
+
3077
+ # Get underlying matplotlib axis if wrapped
3078
+ mpl_ax = getattr(current_ax, '_axis_mpl', current_ax)
3079
+
3080
+ ax_meta = {
3081
+ "grid_position": {"row": row, "col": col}
3082
+ }
3083
+
3084
+ # Additional position info from scitex_metadata if available
3085
+ if hasattr(current_ax, "_scitex_metadata"):
3086
+ pos = current_ax._scitex_metadata.get("position_in_grid")
3087
+ if pos:
3088
+ ax_meta["grid_position"] = {"row": pos[0], "col": pos[1]}
3089
+
3090
+ # Axis labels and limits (minimal - for axis alignment)
3091
+ try:
3092
+ xlim = mpl_ax.get_xlim()
3093
+ ylim = mpl_ax.get_ylim()
3094
+ ax_meta["xaxis"] = {
3095
+ "label": mpl_ax.get_xlabel() or "",
3096
+ "lim": [round(xlim[0], 4), round(xlim[1], 4)],
3097
+ }
3098
+ ax_meta["yaxis"] = {
3099
+ "label": mpl_ax.get_ylabel() or "",
3100
+ "lim": [round(ylim[0], 4), round(ylim[1], 4)],
3101
+ }
3102
+ except Exception:
3103
+ pass
3104
+
3105
+ # Method calls from history - the core "recipe"
3106
+ # Pass row and col for proper data_ref column naming
3107
+ ax_index = row * grid_shape[1] + col
3108
+ ax_meta["calls"] = _extract_calls_from_history(current_ax, ax_index)
3109
+
3110
+ metadata["axes"][ax_key] = ax_meta
3111
+
3112
+ return metadata
3113
+
3114
+
3115
+ def _extract_calls_from_history(ax, ax_index: int) -> List[dict]:
3116
+ """
3117
+ Extract method call records from axis history.
3118
+
3119
+ Parameters
3120
+ ----------
3121
+ ax : AxisWrapper or matplotlib.axes.Axes
3122
+ Axis to extract history from
3123
+ ax_index : int
3124
+ Index of axis in figure (for CSV column naming)
3125
+
3126
+ Returns
3127
+ -------
3128
+ list
3129
+ List of call records: [{id, method, data_ref, kwargs}, ...]
3130
+ """
3131
+ calls = []
3132
+
3133
+ # Check for scitex wrapper with history
3134
+ if not hasattr(ax, 'history') and not hasattr(ax, '_ax_history'):
3135
+ return calls
3136
+
3137
+ # Get history dict
3138
+ history = getattr(ax, 'history', None)
3139
+ if history is None:
3140
+ history = getattr(ax, '_ax_history', {})
3141
+
3142
+ # Get grid position
3143
+ ax_row = 0
3144
+ ax_col = 0
3145
+ if hasattr(ax, "_scitex_metadata"):
3146
+ pos = ax._scitex_metadata.get("position_in_grid", [0, 0])
3147
+ ax_row, ax_col = pos[0], pos[1]
3148
+
3149
+ for trace_id, record in history.items():
3150
+ # record format: (id, method_name, tracked_dict, kwargs)
3151
+ if not isinstance(record, (list, tuple)) or len(record) < 3:
3152
+ continue
3153
+
3154
+ call_id, method_name, tracked_dict = record[0], record[1], record[2]
3155
+ kwargs = record[3] if len(record) > 3 else {}
3156
+
3157
+ call = {
3158
+ "id": str(call_id),
3159
+ "method": method_name,
3160
+ }
3161
+
3162
+ # Build data_ref from tracked_dict to CSV column names
3163
+ data_ref = _build_data_ref(call_id, method_name, tracked_dict, ax_row, ax_col)
3164
+ if data_ref:
3165
+ call["data_ref"] = data_ref
3166
+
3167
+ # Filter kwargs to only style-relevant ones (not data)
3168
+ style_kwargs = _filter_style_kwargs(kwargs, method_name)
3169
+ if style_kwargs:
3170
+ call["kwargs"] = style_kwargs
3171
+
3172
+ calls.append(call)
3173
+
3174
+ return calls
3175
+
3176
+
3177
+ def _build_data_ref(trace_id, method_name: str, tracked_dict: dict,
3178
+ ax_row: int, ax_col: int) -> dict:
3179
+ """
3180
+ Build data_ref mapping from tracked_dict to CSV column names.
3181
+
3182
+ Parameters
3183
+ ----------
3184
+ trace_id : str
3185
+ Trace identifier
3186
+ method_name : str
3187
+ Name of the method called
3188
+ tracked_dict : dict
3189
+ Data tracked by the method (contains arrays, dataframes)
3190
+ ax_row, ax_col : int
3191
+ Axis position in grid
3192
+
3193
+ Returns
3194
+ -------
3195
+ dict
3196
+ Mapping of variable names to CSV column names
3197
+ """
3198
+ prefix = f"ax-row-{ax_row}-col-{ax_col}_trace-id-{trace_id}_variable-"
3199
+
3200
+ data_ref = {}
3201
+
3202
+ # Method-specific column naming
3203
+ if method_name == 'hist':
3204
+ # Histogram: raw data + computed bins
3205
+ data_ref["raw_data"] = f"{prefix}raw-data"
3206
+ data_ref["bin_centers"] = f"{prefix}bin-centers"
3207
+ data_ref["bin_counts"] = f"{prefix}bin-counts"
3208
+ elif method_name in ('plot', 'scatter', 'step', 'errorbar'):
3209
+ # Standard x, y plots
3210
+ data_ref["x"] = f"{prefix}x"
3211
+ data_ref["y"] = f"{prefix}y"
3212
+ # Check for error bars in tracked_dict
3213
+ if tracked_dict and 'yerr' in tracked_dict:
3214
+ data_ref["yerr"] = f"{prefix}yerr"
3215
+ if tracked_dict and 'xerr' in tracked_dict:
3216
+ data_ref["xerr"] = f"{prefix}xerr"
3217
+ elif method_name in ('bar', 'barh'):
3218
+ data_ref["x"] = f"{prefix}x"
3219
+ data_ref["y"] = f"{prefix}y"
3220
+ elif method_name == 'stem':
3221
+ data_ref["x"] = f"{prefix}x"
3222
+ data_ref["y"] = f"{prefix}y"
3223
+ elif method_name in ('fill_between', 'fill_betweenx'):
3224
+ data_ref["x"] = f"{prefix}x"
3225
+ data_ref["y1"] = f"{prefix}y1"
3226
+ data_ref["y2"] = f"{prefix}y2"
3227
+ elif method_name in ('imshow', 'matshow', 'pcolormesh'):
3228
+ data_ref["data"] = f"{prefix}data"
3229
+ elif method_name in ('contour', 'contourf'):
3230
+ data_ref["x"] = f"{prefix}x"
3231
+ data_ref["y"] = f"{prefix}y"
3232
+ data_ref["z"] = f"{prefix}z"
3233
+ elif method_name in ('boxplot', 'violinplot'):
3234
+ data_ref["data"] = f"{prefix}data"
3235
+ elif method_name == 'pie':
3236
+ data_ref["x"] = f"{prefix}x"
3237
+ elif method_name in ('quiver', 'streamplot'):
3238
+ data_ref["x"] = f"{prefix}x"
3239
+ data_ref["y"] = f"{prefix}y"
3240
+ data_ref["u"] = f"{prefix}u"
3241
+ data_ref["v"] = f"{prefix}v"
3242
+ elif method_name == 'hexbin':
3243
+ data_ref["x"] = f"{prefix}x"
3244
+ data_ref["y"] = f"{prefix}y"
3245
+ elif method_name == 'hist2d':
3246
+ data_ref["x"] = f"{prefix}x"
3247
+ data_ref["y"] = f"{prefix}y"
3248
+ elif method_name == 'kde':
3249
+ data_ref["x"] = f"{prefix}x"
3250
+ data_ref["y"] = f"{prefix}y"
3251
+ # SciTeX custom methods (stx_*) - use same naming as matplotlib wrappers
3252
+ elif method_name == 'stx_line':
3253
+ data_ref["x"] = f"{prefix}x"
3254
+ data_ref["y"] = f"{prefix}y"
3255
+ elif method_name in ('stx_mean_std', 'stx_mean_ci', 'stx_median_iqr', 'stx_shaded_line'):
3256
+ data_ref["x"] = f"{prefix}x"
3257
+ data_ref["y_lower"] = f"{prefix}y-lower"
3258
+ data_ref["y_middle"] = f"{prefix}y-middle"
3259
+ data_ref["y_upper"] = f"{prefix}y-upper"
3260
+ elif method_name in ('stx_box', 'stx_violin'):
3261
+ data_ref["data"] = f"{prefix}data"
3262
+ elif method_name == 'stx_scatter_hist':
3263
+ data_ref["x"] = f"{prefix}x"
3264
+ data_ref["y"] = f"{prefix}y"
3265
+ elif method_name in ('stx_heatmap', 'stx_conf_mat', 'stx_image', 'stx_raster'):
3266
+ data_ref["data"] = f"{prefix}data"
3267
+ elif method_name in ('stx_kde', 'stx_ecdf'):
3268
+ data_ref["x"] = f"{prefix}x"
3269
+ data_ref["y"] = f"{prefix}y"
3270
+ elif method_name.startswith('stx_'):
3271
+ # Generic fallback for other stx_ methods
3272
+ data_ref["x"] = f"{prefix}x"
3273
+ data_ref["y"] = f"{prefix}y"
3274
+ else:
3275
+ # Generic fallback for tracked data
3276
+ if tracked_dict:
3277
+ if 'x' in tracked_dict or 'args' in tracked_dict:
3278
+ data_ref["x"] = f"{prefix}x"
3279
+ data_ref["y"] = f"{prefix}y"
3280
+
3281
+ return data_ref
3282
+
3283
+
3284
+ def _filter_style_kwargs(kwargs: dict, method_name: str) -> dict:
3285
+ """
3286
+ Filter kwargs to only include style-relevant parameters.
3287
+
3288
+ Removes data arrays and internal parameters, keeps style settings
3289
+ that affect appearance (color, linewidth, etc.).
3290
+
3291
+ Parameters
3292
+ ----------
3293
+ kwargs : dict
3294
+ Original keyword arguments
3295
+ method_name : str
3296
+ Name of the method
3297
+
3298
+ Returns
3299
+ -------
3300
+ dict
3301
+ Filtered kwargs with only style parameters
3302
+ """
3303
+ if not kwargs:
3304
+ return {}
3305
+
3306
+ # Style-relevant kwargs to keep
3307
+ style_keys = {
3308
+ 'color', 'c', 'facecolor', 'edgecolor', 'linecolor',
3309
+ 'linewidth', 'lw', 'linestyle', 'ls',
3310
+ 'marker', 'markersize', 'ms', 'markerfacecolor', 'markeredgecolor',
3311
+ 'alpha', 'zorder',
3312
+ 'label',
3313
+ 'bins', 'density', 'histtype', 'orientation',
3314
+ 'width', 'height', 'align',
3315
+ 'cmap', 'vmin', 'vmax', 'norm',
3316
+ 'levels', 'extend',
3317
+ 'scale', 'units',
3318
+ 'autopct', 'explode', 'shadow', 'startangle',
3319
+ }
3320
+
3321
+ filtered = {}
3322
+ for key, value in kwargs.items():
3323
+ if key in style_keys:
3324
+ # Skip if value is a large array (data, not style)
3325
+ if hasattr(value, '__len__') and not isinstance(value, str):
3326
+ if len(value) > 10:
3327
+ continue
3328
+ # Round float values to 4 decimal places for cleaner JSON
3329
+ if isinstance(value, float):
3330
+ value = round(value, 4)
3331
+ filtered[key] = value
3332
+
3333
+ return filtered
3334
+
3335
+
568
3336
  if __name__ == "__main__":
569
3337
  import numpy as np
570
3338
 
@@ -610,13 +3378,11 @@ if __name__ == "__main__":
610
3378
  print(f" • Software version: {metadata['scitex']['version']}")
611
3379
  print(f" • Timestamp: {metadata['scitex']['created_at']}")
612
3380
  if "dimensions" in metadata:
613
- print(
614
- f" • Axes size: {metadata['dimensions']['axes_size_mm']} mm"
615
- )
3381
+ print(f" • Axes size: {metadata['dimensions']['axes_size_mm']} mm")
616
3382
  print(f" • DPI: {metadata['dimensions']['dpi']}")
617
- if "scitex" in metadata and "mode" in metadata["scitex"]:
3383
+ if "runtime" in metadata and "mode" in metadata["runtime"]:
618
3384
  print(f" • Mode: {metadata['scitex']['mode']}")
619
- if "scitex" in metadata and "style_mm" in metadata["scitex"]:
3385
+ if "runtime" in metadata and "style_mm" in metadata["runtime"]:
620
3386
  print(" • Style: Embedded ✓")
621
3387
 
622
3388
  # EOF