flextool 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. flextool-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,564 @@
1
+ """Cross-scenario axis-bounds manifest for the result viewer.
2
+
3
+ Background
4
+ ----------
5
+ :class:`~flextool.plot_outputs.plan.PlotPlan` records a per-scenario
6
+ ``subplot_y_ranges`` list so the y-axis stays stable while the user
7
+ scrolls the time slider **within** a scenario. Switching scenarios in
8
+ single-viewer mode loads a different plan whose y-ranges are computed
9
+ from that scenario alone, so the axis can still jump.
10
+
11
+ This module produces a side-car manifest, written once per batch run at
12
+ ``<project_path>/output_parquet/_axis_bounds.json``, that stores
13
+ each scenario's per-subplot y-ranges. The viewer takes the union at
14
+ read time, filtered to the scenarios the user has currently selected —
15
+ so checking/unchecking scenarios in the executed list naturally adjusts
16
+ the shared axis without a manifest rewrite.
17
+
18
+ Schema
19
+ ------
20
+
21
+ .. code-block:: json
22
+
23
+ {
24
+ "<result_key>": {
25
+ "<sub_config>": {
26
+ "<subplot_key>": {
27
+ "<scenario_name>": [min, max],
28
+ ...
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ ``<subplot_key>`` is chosen as the subplot *title*. See the comment on
35
+ :meth:`ManifestAccumulator.add_plan` for the rationale and the fallback
36
+ used for untitled (single-subplot) plans.
37
+
38
+ Bar-chart plans are intentionally skipped — bars don't use time-scrolling
39
+ so cross-scenario y-axis stability isn't a concern for them.
40
+
41
+ Atomicity
42
+ ---------
43
+ :meth:`ManifestAccumulator.write` serialises via a temp-file + rename so
44
+ concurrent readers (e.g. the viewer polling the file) never observe a
45
+ half-written JSON document.
46
+
47
+ Backward compatibility
48
+ ----------------------
49
+ An older version of this module stored bounds as 2-element lists directly
50
+ under the subplot title (``{"subplot": [min, max]}``). Such entries are
51
+ silently ignored on load — the next batch run rewrites the file in the
52
+ new per-scenario schema.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ import logging
59
+ import tempfile
60
+ from pathlib import Path
61
+ from typing import TYPE_CHECKING
62
+
63
+ if TYPE_CHECKING:
64
+ from flextool.plot_outputs.plan import PlotPlan
65
+
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ # Sentinel for subplots with no title (single-subplot plans where no
71
+ # subplot levels are present). These plans always have exactly one
72
+ # subplot, so a single sentinel per (result_key, sub_config) is safe.
73
+ _UNTITLED_KEY = "<untitled>"
74
+
75
+
76
+ class ManifestAccumulator:
77
+ """Collects per-subplot y-axis bounds across scenarios.
78
+
79
+ One accumulator instance lives for the entire batch run. The caller
80
+ invokes :meth:`add_plan` with a ``scenario_name`` so the per-scenario
81
+ slice is recorded distinctly. Subsequent runs of the same scenario
82
+ replace that slice (rather than union it), making the newest
83
+ computation authoritative for that scenario.
84
+
85
+ The accumulator seeds from any existing on-disk manifest so that
86
+ re-running a single scenario's batch doesn't wipe out other
87
+ scenarios' entries.
88
+
89
+ The target path is
90
+ ``<project_path>/output_parquet/_axis_bounds.json``. The
91
+ ``output_parquet`` directory is created on :meth:`write` if missing.
92
+ """
93
+
94
+ def __init__(self, project_path: Path):
95
+ self.project_path = Path(project_path)
96
+ self._manifest_path = (
97
+ self.project_path / "output_parquet" / "_axis_bounds.json"
98
+ )
99
+ # Four-level nested dict:
100
+ # result_key -> sub_config -> subplot_key -> scenario_name -> [lo, hi]
101
+ self._data: dict[str, dict[str, dict[str, dict[str, list[float]]]]] = {}
102
+ # Scenarios this accumulator has seen during the current batch.
103
+ # Any previously-stored entries for these scenarios are cleared on
104
+ # first add so the new plans fully replace the old slice.
105
+ self._replaced_scenarios: set[str] = set()
106
+ # Seed accumulator with any bounds already on disk so re-running a
107
+ # single scenario doesn't wipe the others' slices.
108
+ self._load_existing()
109
+
110
+ # ------------------------------------------------------------------
111
+ # Public API
112
+ # ------------------------------------------------------------------
113
+
114
+ def add_plan(
115
+ self,
116
+ result_key: str,
117
+ sub_config: str,
118
+ plan: "PlotPlan",
119
+ scenario_name: str,
120
+ ) -> None:
121
+ """Record one plan's subplot_y_ranges under *scenario_name*.
122
+
123
+ Subplot keying: we use the subplot *title* (first element of each
124
+ ``effective_plot_specs`` tuple). Rationale:
125
+
126
+ - ``title`` is a plain string or ``None`` — directly JSON-safe.
127
+ - When the same entity is present in two scenarios, the title is
128
+ identical, so the reader's union picks up both ranges.
129
+ - When one scenario drops an entity (e.g. zero-filtered), its
130
+ subplot simply doesn't contribute — the other scenario's
131
+ bounds still govern, which is the desired behaviour.
132
+ - Subplot *indices* would misalign in exactly that dropped-entity
133
+ case, so we avoid index-based keying.
134
+ - ``None`` titles (single-subplot plans without subplot levels)
135
+ are mapped to a single ``<untitled>`` sentinel, which is safe
136
+ because such plans always have exactly one subplot.
137
+
138
+ Bar-chart plans are skipped — see module docstring.
139
+
140
+ Replacement semantics: the first call for a given
141
+ ``scenario_name`` within this accumulator's lifetime clears any
142
+ previously-stored entries for that scenario across *all*
143
+ (result_key, sub_config, subplot) triples. Subsequent calls for
144
+ the same scenario simply add/overwrite entries for the specific
145
+ subplots touched. This way a re-run authoritative picture of the
146
+ scenario doesn't leave stale subplot entries lingering from a
147
+ previous run.
148
+ """
149
+ if plan.chart_type == 'bar':
150
+ return
151
+ if not plan.subplot_y_ranges:
152
+ return
153
+ if not scenario_name:
154
+ logger.warning(
155
+ "ManifestAccumulator.add_plan called with empty scenario_name; "
156
+ "dropping entries for %s/%s",
157
+ result_key, sub_config,
158
+ )
159
+ return
160
+
161
+ # effective_plot_specs and subplot_y_ranges are parallel lists of
162
+ # the same length (see _compute_time_plan). Guard anyway to avoid
163
+ # silent misalignment if that invariant ever breaks.
164
+ specs = plan.effective_plot_specs
165
+ ranges = plan.subplot_y_ranges
166
+ if len(specs) != len(ranges):
167
+ logger.warning(
168
+ "PlotPlan %s/%s: effective_plot_specs (%d) and "
169
+ "subplot_y_ranges (%d) length mismatch — skipping",
170
+ result_key, sub_config, len(specs), len(ranges),
171
+ )
172
+ return
173
+
174
+ # On first touch of this scenario in this accumulator lifetime,
175
+ # drop any disk-seeded entries that belong to it — the current
176
+ # batch is now the authoritative picture of the scenario.
177
+ if scenario_name not in self._replaced_scenarios:
178
+ self._purge_scenario_in_memory(scenario_name)
179
+ self._replaced_scenarios.add(scenario_name)
180
+
181
+ result_entry = self._data.setdefault(result_key, {})
182
+ sub_entry = result_entry.setdefault(sub_config, {})
183
+
184
+ for (title, _selector), (lo, hi) in zip(specs, ranges):
185
+ key = _UNTITLED_KEY if title is None else str(title)
186
+ try:
187
+ lo_f = float(lo)
188
+ hi_f = float(hi)
189
+ except (TypeError, ValueError):
190
+ continue
191
+ subplot_entry = sub_entry.setdefault(key, {})
192
+ subplot_entry[scenario_name] = [lo_f, hi_f]
193
+
194
+ def write(self) -> None:
195
+ """Atomically write the accumulated manifest to disk.
196
+
197
+ Creates the ``output_parquet`` directory if missing. Writes to a
198
+ temp file in the same directory, then ``os.replace``-renames it
199
+ into place so concurrent readers never see a partial document.
200
+ """
201
+ if not self._data:
202
+ # Nothing to write — don't create an empty file.
203
+ return
204
+ _atomic_write_manifest(self._manifest_path, self._data)
205
+
206
+ # ------------------------------------------------------------------
207
+ # Introspection helpers (primarily for tests)
208
+ # ------------------------------------------------------------------
209
+
210
+ @property
211
+ def manifest_path(self) -> Path:
212
+ return self._manifest_path
213
+
214
+ @property
215
+ def data(self) -> dict[str, dict[str, dict[str, dict[str, list[float]]]]]:
216
+ """Read-only view of the accumulated data. Don't mutate."""
217
+ return self._data
218
+
219
+ # ------------------------------------------------------------------
220
+ # Internal
221
+ # ------------------------------------------------------------------
222
+
223
+ def _purge_scenario_in_memory(self, scenario_name: str) -> None:
224
+ """Drop *scenario_name*'s entries from ``self._data`` in place.
225
+
226
+ Removes empty subplot / sub_config / result_key entries as it
227
+ prunes. Used when a scenario's first ``add_plan`` call arrives
228
+ and the disk-seeded data for that scenario needs to be cleared
229
+ before the new batch's values take its place.
230
+ """
231
+ for result_key in list(self._data.keys()):
232
+ sub_map = self._data[result_key]
233
+ for sub_config in list(sub_map.keys()):
234
+ subplot_map = sub_map[sub_config]
235
+ for subplot_key in list(subplot_map.keys()):
236
+ scen_map = subplot_map[subplot_key]
237
+ if scenario_name in scen_map:
238
+ del scen_map[scenario_name]
239
+ if not scen_map:
240
+ del subplot_map[subplot_key]
241
+ if not subplot_map:
242
+ del sub_map[sub_config]
243
+ if not sub_map:
244
+ del self._data[result_key]
245
+
246
+ def _load_existing(self) -> None:
247
+ """Seed the accumulator from an on-disk manifest if present.
248
+
249
+ Tolerant of malformed files and the legacy flat schema — anything
250
+ that doesn't fit the four-level per-scenario layout is silently
251
+ dropped. A corrupt previous write can't permanently break the
252
+ next batch run; a legacy manifest is simply ignored and will be
253
+ rewritten in the new schema on the next :meth:`write`.
254
+ """
255
+ loaded = load_axis_bounds_manifest(self.project_path)
256
+ if loaded is None:
257
+ return
258
+ saw_legacy = False
259
+ # Shape-validate as we copy in so downstream code can trust the structure.
260
+ for rk, rk_val in loaded.items():
261
+ if not isinstance(rk_val, dict):
262
+ continue
263
+ rk_entry: dict[str, dict[str, dict[str, list[float]]]] = {}
264
+ for sc, sc_val in rk_val.items():
265
+ if not isinstance(sc_val, dict):
266
+ continue
267
+ sc_entry: dict[str, dict[str, list[float]]] = {}
268
+ for sp, sp_val in sc_val.items():
269
+ # Legacy schema: sp_val is a 2-element list [lo, hi].
270
+ # Ignore (and flag) — the per-scenario attribution is
271
+ # missing, so there's no safe way to retain it.
272
+ if isinstance(sp_val, (list, tuple)):
273
+ saw_legacy = True
274
+ continue
275
+ if not isinstance(sp_val, dict):
276
+ continue
277
+ scen_entry: dict[str, list[float]] = {}
278
+ for scenario_name, bounds in sp_val.items():
279
+ if not isinstance(scenario_name, str) or not scenario_name:
280
+ continue
281
+ if (
282
+ isinstance(bounds, (list, tuple))
283
+ and len(bounds) == 2
284
+ ):
285
+ try:
286
+ scen_entry[scenario_name] = [
287
+ float(bounds[0]), float(bounds[1]),
288
+ ]
289
+ except (TypeError, ValueError):
290
+ continue
291
+ if scen_entry:
292
+ sc_entry[str(sp)] = scen_entry
293
+ if sc_entry:
294
+ rk_entry[str(sc)] = sc_entry
295
+ if rk_entry:
296
+ self._data[str(rk)] = rk_entry
297
+ if saw_legacy:
298
+ logger.warning(
299
+ "Shared axis manifest %s uses the legacy (pre-per-scenario) "
300
+ "schema; those entries are being dropped and will be "
301
+ "rewritten on the next batch run.",
302
+ self._manifest_path,
303
+ )
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Reader API (used by the result viewer — Chunk C)
308
+ # ---------------------------------------------------------------------------
309
+
310
+ def load_axis_bounds_manifest(project_path: Path) -> dict | None:
311
+ """Load the shared axis-bounds manifest for *project_path*.
312
+
313
+ Returns the parsed JSON object (a nested ``dict``) or ``None`` when the
314
+ manifest does not exist, cannot be read, or is malformed. The function
315
+ never raises — a corrupt or missing manifest simply means "no
316
+ cross-scenario axis data available", and the viewer falls back to the
317
+ per-plan y-ranges.
318
+
319
+ The expected manifest path is
320
+ ``<project_path>/output_parquet/_axis_bounds.json``.
321
+ """
322
+ manifest_path = (
323
+ Path(project_path) / "output_parquet" / "_axis_bounds.json"
324
+ )
325
+ if not manifest_path.is_file():
326
+ return None
327
+ try:
328
+ with open(manifest_path, "r", encoding="utf-8") as f:
329
+ loaded = json.load(f)
330
+ except (OSError, json.JSONDecodeError) as exc:
331
+ logger.warning(
332
+ "Ignoring unreadable shared manifest %s: %s",
333
+ manifest_path, exc,
334
+ )
335
+ return None
336
+ if not isinstance(loaded, dict):
337
+ logger.warning(
338
+ "Shared manifest %s is not a JSON object — ignoring",
339
+ manifest_path,
340
+ )
341
+ return None
342
+ return loaded
343
+
344
+
345
+ def apply_manifest_to_plan(
346
+ plan: "PlotPlan",
347
+ manifest: dict | None,
348
+ result_key: str,
349
+ sub_config: str,
350
+ scenarios: set[str] | list[str] | None = None,
351
+ ) -> bool:
352
+ """Override ``plan.subplot_y_ranges`` with values from *manifest*.
353
+
354
+ Mutates *plan* in-place. Returns ``True`` when at least one subplot
355
+ range was replaced, ``False`` when nothing changed (missing manifest,
356
+ bar plan, no matching entry, filtered-out scenarios, etc.).
357
+
358
+ *scenarios* controls which scenarios contribute to the union:
359
+
360
+ - ``None`` — union over *all* scenarios present in the manifest (the
361
+ full cross-scenario fallback). Use this only when the caller has
362
+ no explicit scope; in the result viewer's comparison mode the
363
+ caller should always pass the **viewer scenarios** set so the y-axis
364
+ stays frozen while the user toggles individual scenarios.
365
+ - A non-empty ``set``/``list`` — union only over the named scenarios.
366
+ - An empty ``set``/``list`` — no scenarios to union from; the call
367
+ becomes a no-op (the plan's per-scenario y-ranges remain in effect).
368
+
369
+ Subplot keying: for each ``(title, _)`` in
370
+ ``plan.effective_plot_specs``, if the manifest has a per-scenario map
371
+ keyed by that title (with ``None`` mapped to ``_UNTITLED_KEY`` exactly
372
+ as the writer does), the entries for the active scenarios are unioned
373
+ and written to ``plan.subplot_y_ranges`` at the matching index.
374
+ Subplots without a manifest entry keep their per-scenario range.
375
+
376
+ Bar-chart plans are skipped (matching the writer). Legacy flat-schema
377
+ entries (``list``/``tuple`` instead of per-scenario ``dict``) are
378
+ ignored — the next batch run rewrites them. Extra entries in the
379
+ manifest that don't correspond to any subplot in *plan* are ignored.
380
+ """
381
+ if manifest is None or plan is None:
382
+ return False
383
+ if plan.chart_type == "bar":
384
+ return False
385
+ if not plan.effective_plot_specs:
386
+ return False
387
+ # Normalise scenarios into a set (or None) for fast membership checks.
388
+ scenarios_set: set[str] | None
389
+ if scenarios is None:
390
+ scenarios_set = None
391
+ else:
392
+ scenarios_set = set(scenarios)
393
+ if not scenarios_set:
394
+ return False
395
+ sub_entry = manifest.get(result_key)
396
+ if not isinstance(sub_entry, dict):
397
+ return False
398
+ title_map = sub_entry.get(sub_config)
399
+ if not isinstance(title_map, dict):
400
+ return False
401
+
402
+ # Pad subplot_y_ranges if shorter than effective_plot_specs so we can
403
+ # overwrite any index. (In practice they are parallel lists, but the
404
+ # plan file format is JSON — defend against asymmetric loads.)
405
+ ranges: list[tuple[float, float]] = [
406
+ tuple(r) for r in plan.subplot_y_ranges
407
+ ]
408
+ while len(ranges) < len(plan.effective_plot_specs):
409
+ ranges.append((0.0, 0.0))
410
+
411
+ changed = False
412
+ for i, (title, _selector) in enumerate(plan.effective_plot_specs):
413
+ key = _UNTITLED_KEY if title is None else str(title)
414
+ scen_map = title_map.get(key)
415
+ if not isinstance(scen_map, dict) or not scen_map:
416
+ # Missing, legacy (list/tuple), or empty — no override.
417
+ continue
418
+ new_range = _union_scenarios(scen_map, scenarios_set)
419
+ if new_range is None:
420
+ continue
421
+ if ranges[i] != new_range:
422
+ ranges[i] = new_range
423
+ changed = True
424
+
425
+ if changed:
426
+ plan.subplot_y_ranges = ranges
427
+ return changed
428
+
429
+
430
+ def _union_scenarios(
431
+ scen_map: dict, scenarios: set[str] | None,
432
+ ) -> tuple[float, float] | None:
433
+ """Union per-scenario bounds, filtering to *scenarios*.
434
+
435
+ Returns ``None`` when no valid bounds remain after filtering.
436
+ """
437
+ los: list[float] = []
438
+ his: list[float] = []
439
+ for scenario_name, bounds in scen_map.items():
440
+ if scenarios is not None and scenario_name not in scenarios:
441
+ continue
442
+ if not isinstance(bounds, (list, tuple)) or len(bounds) != 2:
443
+ continue
444
+ try:
445
+ lo = float(bounds[0])
446
+ hi = float(bounds[1])
447
+ except (TypeError, ValueError):
448
+ continue
449
+ los.append(lo)
450
+ his.append(hi)
451
+ if not los:
452
+ return None
453
+ return (min(los), max(his))
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # Scenario removal helper
458
+ # ---------------------------------------------------------------------------
459
+
460
+ def remove_scenario_from_manifest(
461
+ project_path: Path, scenario_name: str,
462
+ ) -> bool:
463
+ """Strip all entries for *scenario_name* from the on-disk manifest.
464
+
465
+ Atomically rewrites ``<project_path>/output_parquet/_axis_bounds.json``
466
+ with the scenario's slice removed. Empty subplot / sub_config /
467
+ result_key entries are pruned. Returns ``True`` if the file was
468
+ modified (including being deleted when empty), ``False`` when the
469
+ manifest is missing, unreadable, or already free of the scenario.
470
+
471
+ Safe to call when the manifest doesn't exist — it just returns
472
+ ``False``. Intended for callers that delete a scenario's output so
473
+ its slice doesn't continue to pull y-range data in the viewer.
474
+ """
475
+ if not scenario_name:
476
+ return False
477
+ manifest_path = (
478
+ Path(project_path) / "output_parquet" / "_axis_bounds.json"
479
+ )
480
+ if not manifest_path.is_file():
481
+ return False
482
+
483
+ loaded = load_axis_bounds_manifest(project_path)
484
+ if not isinstance(loaded, dict):
485
+ return False
486
+
487
+ changed = False
488
+ for result_key in list(loaded.keys()):
489
+ sub_map = loaded.get(result_key)
490
+ if not isinstance(sub_map, dict):
491
+ continue
492
+ for sub_config in list(sub_map.keys()):
493
+ subplot_map = sub_map.get(sub_config)
494
+ if not isinstance(subplot_map, dict):
495
+ continue
496
+ for subplot_key in list(subplot_map.keys()):
497
+ scen_map = subplot_map.get(subplot_key)
498
+ # Legacy flat entries have no scenario dimension; skip.
499
+ if not isinstance(scen_map, dict):
500
+ continue
501
+ if scenario_name in scen_map:
502
+ del scen_map[scenario_name]
503
+ changed = True
504
+ if not scen_map:
505
+ del subplot_map[subplot_key]
506
+ if not subplot_map:
507
+ del sub_map[sub_config]
508
+ if not sub_map:
509
+ del loaded[result_key]
510
+
511
+ if not changed:
512
+ return False
513
+
514
+ if not loaded:
515
+ # Nothing left — drop the file entirely.
516
+ try:
517
+ manifest_path.unlink()
518
+ except OSError as exc:
519
+ logger.warning(
520
+ "Failed to delete now-empty manifest %s: %s",
521
+ manifest_path, exc,
522
+ )
523
+ return False
524
+ return True
525
+
526
+ _atomic_write_manifest(manifest_path, loaded)
527
+ return True
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # Shared atomic writer
532
+ # ---------------------------------------------------------------------------
533
+
534
+ def _atomic_write_manifest(manifest_path: Path, data: dict) -> None:
535
+ """Temp-file + rename write shared by the accumulator and remove helper."""
536
+ parent_dir = manifest_path.parent
537
+ try:
538
+ parent_dir.mkdir(parents=True, exist_ok=True)
539
+ except OSError as exc:
540
+ logger.warning(
541
+ "Failed to create manifest parent directory %s: %s",
542
+ parent_dir, exc,
543
+ )
544
+ return
545
+
546
+ try:
547
+ fd, tmp_path = tempfile.mkstemp(
548
+ dir=str(parent_dir),
549
+ prefix="axis_bounds_",
550
+ suffix=".tmp",
551
+ )
552
+ tmp_path = Path(tmp_path)
553
+ try:
554
+ with open(fd, "w", encoding="utf-8") as f:
555
+ json.dump(data, f, indent=2, sort_keys=True)
556
+ tmp_path.replace(manifest_path)
557
+ except BaseException:
558
+ tmp_path.unlink(missing_ok=True)
559
+ raise
560
+ except Exception as exc:
561
+ logger.warning(
562
+ "Failed to write shared axis-bounds manifest %s: %s",
563
+ manifest_path, exc,
564
+ )
@@ -0,0 +1,137 @@
1
+ """
2
+ Shared utilities for subplot grid layout and data extraction.
3
+
4
+ Functions
5
+ ---------
6
+ _calculate_grid_layout Grid dimensions (n_rows, n_cols) from n_subs and subplots_per_row.
7
+ _get_unique_levels Unique subplot values from DataFrame column index (integer indices).
8
+ _extract_subplot_data Unified .xs() extraction returning always a DataFrame, never a Series.
9
+ """
10
+
11
+ import pandas as pd
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass
16
+ class BarLayoutParams:
17
+ """Pre-computed horizontal layout measurements for bar charts (inches).
18
+
19
+ Attributes:
20
+ bar_label_width: Width for the longest bar tick label (the
21
+ expand-axis group is folded into this label).
22
+ total_label_width: Total horizontal width reserved for category labels.
23
+ legend_width: Width reserved for the legend column (0 if not needed).
24
+ legend_height: Height needed for the legend (0 if not needed).
25
+ base_bar_length: The drawing-area width (value axis extent).
26
+ value_axis_width: Width for value-axis tick labels (vertical bar plots).
27
+ """
28
+
29
+ bar_label_width: float
30
+ total_label_width: float
31
+ legend_width: float
32
+ legend_height: float
33
+ base_bar_length: float
34
+ value_axis_width: float
35
+
36
+
37
+ @dataclass
38
+ class LineLayoutParams:
39
+ """Pre-computed layout measurements for line/area charts (inches).
40
+
41
+ Attributes:
42
+ value_label_width: Width for the longest y-axis value tick label.
43
+ legend_width: Width reserved for the legend column (0 if not needed).
44
+ base_width: The drawing-area width.
45
+ subplot_height: The drawing-area height.
46
+ """
47
+ value_label_width: float
48
+ legend_width: float
49
+ base_width: float
50
+ subplot_height: float
51
+
52
+
53
+ def _calculate_grid_layout(n_subs: int, subplots_per_row: int) -> tuple[int, int]:
54
+ """Return (n_rows, n_cols) for a grid of n_subs subplots.
55
+
56
+ Args:
57
+ n_subs: Total number of subplots.
58
+ subplots_per_row: Maximum columns per row.
59
+
60
+ Returns:
61
+ (n_rows, n_cols) as integers.
62
+ """
63
+ n_cols = min(subplots_per_row, n_subs)
64
+ n_rows = (n_subs + n_cols - 1) // n_cols # Ceiling division
65
+ return n_rows, n_cols
66
+
67
+
68
+ def _sort_subs(subs: list) -> list:
69
+ """Return subplot identifiers sorted alphabetically (case-insensitive).
70
+
71
+ Handles the three shapes :func:`_get_unique_levels` may return:
72
+
73
+ * ``[None]`` — a single un-split subplot; returned as-is.
74
+ * list of scalars — sorted by lowercased string form.
75
+ * list of tuples — sorted element-wise by lowercased string form so
76
+ multi-level subplot titles order like "a | x", "a | y", "b | x".
77
+
78
+ Used wherever subplot identifiers drive the final display order (both
79
+ the viewer's PlotPlan and the PNG builders) so the two render paths
80
+ agree.
81
+ """
82
+ def key(sub):
83
+ if sub is None:
84
+ return ("",)
85
+ if isinstance(sub, tuple):
86
+ return tuple(str(v).lower() for v in sub)
87
+ return (str(sub).lower(),)
88
+ return sorted(subs, key=key)
89
+
90
+
91
+ def _get_unique_levels(df_columns: pd.Index, level_indices: list) -> list:
92
+ """Return unique subplot values from DataFrame column index.
93
+
94
+ Args:
95
+ df_columns: The .columns attribute of a DataFrame.
96
+ level_indices: Integer position(s) of the level(s) to extract.
97
+ Empty list → returns [None] (single plot, no subplotting).
98
+ Single element → returns list of scalars.
99
+ Multiple elements → returns list of tuples.
100
+
101
+ Returns:
102
+ List of subplot values (scalars, tuples, or [None]).
103
+ """
104
+ if not level_indices:
105
+ return [None]
106
+ if len(level_indices) == 1:
107
+ return df_columns.get_level_values(level_indices[0]).unique().tolist()
108
+ sub_df = df_columns.to_frame().iloc[:, level_indices].drop_duplicates()
109
+ return [tuple(row) for row in sub_df.values]
110
+
111
+
112
+ def _extract_subplot_data(df: pd.DataFrame, sub, sub_levels: list) -> pd.DataFrame:
113
+ """Extract data for one subplot value, always returning a DataFrame.
114
+
115
+ Handles MultiIndex and single-level columns uniformly. Converts any
116
+ Series result of .xs() back to a single-column DataFrame.
117
+
118
+ Args:
119
+ df: Source DataFrame with MultiIndex or single-level columns.
120
+ sub: Subplot value (scalar or tuple) to select, or None for the
121
+ whole frame (when sub_levels is empty / subs == [None]).
122
+ sub_levels: Integer level indices that identify this subplot level.
123
+
124
+ Returns:
125
+ DataFrame slice for the requested subplot value.
126
+ """
127
+ if sub is None:
128
+ return df
129
+ if len(sub_levels) == 1 and not isinstance(df.columns, pd.MultiIndex):
130
+ result = df[sub]
131
+ elif len(sub_levels) == 1:
132
+ result = df.xs(sub, level=sub_levels[0], axis=1)
133
+ else:
134
+ result = df.xs(sub, level=sub_levels, axis=1)
135
+ if isinstance(result, pd.Series):
136
+ result = result.to_frame()
137
+ return result