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,171 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ # Dimension rule characters come from templates/default_plots.yaml and must remain unchanged.
4
+ # This dict documents their meaning for readability in code (e.g. DIMENSION_RULES['t'] → 'time axis (x)').
5
+ DIMENSION_RULES: dict[str, str] = {
6
+ 't': 'time axis (x, datetime)',
7
+ 'i': 'time-like axis (x, chunk-average)',
8
+ 'b': 'bar/line column stack',
9
+ 'e': 'expand (separate y-axes)',
10
+ 'g': 'group (grouped bars)',
11
+ 's': 'stack (stacked segments)',
12
+ 'u': 'subplot (separate plot panel)',
13
+ 'l': 'line series',
14
+ 'f': 'file (separate file per member)',
15
+ 'm': 'merge (sum and collapse)',
16
+ 'a': 'average and collapse',
17
+ 'y': 'weight by years_represented, then sum and collapse (period row only)',
18
+ 'z': 'years_represented weighted average and collapse (period row only)',
19
+ }
20
+
21
+ # Field names for plot settings — used by _is_single_config() to detect config dicts.
22
+ PLOT_FIELD_NAMES = {
23
+ 'plot_name', 'map_dimensions_for_plots', 'subplots_per_row', 'legend',
24
+ 'bar_orientation', 'base_length', 'max_subplots_per_file', 'max_items_per_plot',
25
+ 'max_items_per_subplot_column',
26
+ 'time_average_duration', 'xlabel', 'ylabel', 'value_label', 'axis_bounds',
27
+ 'axis_scale_min_max', # backward compat alias for axis_bounds
28
+ 'axis_tick_format', 'always_include_zero_in_axis', 'skip_data_with_only_zeroes',
29
+ 'multiply_by', 'full_timeline', 'subplots_by_magnitudes',
30
+ 'variant', # used by plot_config_reader to override variant letter derivation
31
+ 'color_category', 'color_entity_class', # color-template hints (chunk D)
32
+ 'scenario_rule', 'comparison_overrides', # comparison-mode add-ons
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class PlotConfig:
38
+ """Typed, default-carrying wrapper for a single plot's settings dict.
39
+
40
+ Callers that pass raw dicts (e.g. scenario_results.py) need no change:
41
+ ``PlotConfig(**raw_dict)`` works as long as the dict only contains known fields.
42
+ """
43
+ plot_name: str = ''
44
+ map_dimensions_for_plots: dict = field(default_factory=dict)
45
+ subplots_per_row: int = 2
46
+ legend: str = 'right' # 'right', 'all', or 'shared'
47
+ bar_orientation: str = 'horizontal'
48
+ base_length: float = 4.0
49
+ max_subplots_per_file: int = 6
50
+ max_items_per_plot: int = 10
51
+ max_items_per_subplot_column: int = 40
52
+ time_average_duration: str | None = None
53
+ xlabel: str | None = None
54
+ ylabel: str | None = None
55
+ value_label: str | None = None
56
+ axis_bounds: dict | list | str | None = None
57
+ axis_tick_format: str = 'dynamic'
58
+ always_include_zero_in_axis: bool = True
59
+ skip_data_with_only_zeroes: bool = False
60
+ multiply_by: float | None = None
61
+ full_timeline: bool = False
62
+ subplots_by_magnitudes: bool = False
63
+ # Hints for the color-template resolver (see flextool.plot_outputs.color_template).
64
+ # Default None means "no hint" — fall back to the tab10/tab20 palette as today.
65
+ color_category: str | None = None
66
+ color_entity_class: str | None = None
67
+
68
+ # --- Comparison-mode add-ons ---
69
+ # The unioned plan parquet at comparison-view time has the post-single-rules
70
+ # shape with `scenario` prepended as the outermost column-MultiIndex level.
71
+ # `scenario_rule` is the dim-rule character applied to that scenario level
72
+ # when building the comparison PlotPlan (see
73
+ # flextool.scenario_comparison.plan_union.compute_comparison_plan_from_single).
74
+ # Common choices: 'g' (grouped/coloured bars), 'l' (line series), 'u'
75
+ # (subplot per scenario), 'f' (file per scenario), 's' (stack — rare).
76
+ # When None, the config has no comparison rendering — comparison view
77
+ # surfaces "no comparison rule defined" for this plot.
78
+ scenario_rule: str | None = None
79
+ # Optional per-mode overrides applied on top of the single-mode values when
80
+ # rendering the comparison view (e.g. fewer ``max_subplots_per_file``
81
+ # because each subplot now has scenario-many more bars). Any field listed
82
+ # in PLOT_FIELD_NAMES is allowed. Plain dict so the YAML stays terse.
83
+ comparison_overrides: dict | None = None
84
+
85
+
86
+ def default_ylabel_for(
87
+ result_key: str, variant_letter: str | None = None
88
+ ) -> str | None:
89
+ """Derive a default y-axis label (the displayed unit) for a plot.
90
+
91
+ Single source: the output-metadata catalog
92
+ (:func:`flextool.process_outputs._output_meta.result_key_summary`).
93
+ Returns the unit string when one is declared and non-trivial, else
94
+ ``None`` so callers leave an explicit ylabel untouched. Imported lazily
95
+ to keep ``plot_outputs`` free of a ``process_outputs`` import at module
96
+ load.
97
+
98
+ When *variant_letter* is given, the variant-adjusted unit is used
99
+ (:func:`~flextool.process_outputs._output_meta.result_variant_summary`)
100
+ so the ``a`` (total → ``/a`` stripped) and ``w`` (weekly, unchanged)
101
+ plot variants get the correct value-axis unit; otherwise the base
102
+ output unit is used unchanged.
103
+ """
104
+ try:
105
+ if variant_letter is None:
106
+ from flextool.process_outputs._output_meta import result_key_summary
107
+ summary = result_key_summary(result_key)
108
+ else:
109
+ from flextool.process_outputs._output_meta import (
110
+ result_variant_summary,
111
+ )
112
+ summary = result_variant_summary(result_key, variant_letter)
113
+ except Exception:
114
+ return None
115
+ if not summary:
116
+ return None
117
+ unit = summary[0]
118
+ if not unit or unit == 'ratio':
119
+ return None
120
+ return unit
121
+
122
+
123
+ def _is_single_config(d: dict) -> bool:
124
+ """Return True if *d* is a single-plot config (has known field names), not a named-config dict."""
125
+ return any(k in PLOT_FIELD_NAMES for k in d)
126
+
127
+
128
+ def _is_new_format_entry(d: dict) -> bool:
129
+ """Return True if *d* is an entry-name-based config (has 'group' and 'order' keys)."""
130
+ return 'group' in d and 'order' in d
131
+
132
+
133
+ def flatten_new_format(plots: dict) -> dict:
134
+ """Convert entry-name-based format to flat result_key-based format.
135
+
136
+ Entries have ``group`` and ``order`` keys; result_keys are nested
137
+ underneath. The flattened output maps each result_key directly to
138
+ its sub-config dict (same shape the orchestrator/plan code expects).
139
+
140
+ When the same result_key appears under multiple entry names (e.g.
141
+ ``costs_dt_p`` under both "Costs" and "Costs lines"), the sub-configs
142
+ from each entry are merged into a single dict for that result_key.
143
+
144
+ Sub-configs that lack an explicit ``plot_name`` get the entry name
145
+ (e.g. "Node flows") injected as ``_entry_name`` so the orchestrator
146
+ can use it as a filename fallback instead of the raw result_key.
147
+
148
+ Already-flat entries (result_key → config dict without group/order)
149
+ pass through unchanged for programmatic callers.
150
+ """
151
+ flat: dict = {}
152
+ for key, value in plots.items():
153
+ if not isinstance(value, dict):
154
+ continue
155
+ if _is_new_format_entry(value):
156
+ for rk, rk_val in value.items():
157
+ if rk in ('group', 'order') or not isinstance(rk_val, dict):
158
+ continue
159
+ # Inject entry name into sub-configs that lack plot_name
160
+ enriched = dict(rk_val)
161
+ for setting_name, setting in enriched.items():
162
+ if isinstance(setting, dict) and 'plot_name' not in setting:
163
+ setting['_entry_name'] = key
164
+ if rk in flat:
165
+ flat[rk].update(enriched)
166
+ else:
167
+ flat[rk] = enriched
168
+ else:
169
+ # Already flat (result_key → config dict) — pass through
170
+ flat[key] = value
171
+ return flat
@@ -0,0 +1,345 @@
1
+ """Value formatting and filename utilities for plot_outputs."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import math
6
+ import os
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from decimal import Decimal, InvalidOperation
11
+ from matplotlib.ticker import Formatter, FuncFormatter
12
+
13
+ from flextool.lean_parquet import read_lean_parquet
14
+
15
+
16
+ # ── superscript digits for nice exponent display (single source) ──
17
+ _SUPERSCRIPTS = str.maketrans('0123456789-', '⁰¹²³⁴⁵⁶⁷⁸⁹⁻')
18
+
19
+
20
+ def _sig_figs_fmt(x, pos, n: int = 5) -> str:
21
+ """Format x with n significant figures, plain notation, no trailing zeros."""
22
+ if x == 0:
23
+ return '0'
24
+ try:
25
+ d = Decimal(str(x))
26
+ rounded = d.quantize(Decimal(10) ** (d.adjusted() - n + 1))
27
+ result = f'{rounded:f}'
28
+ if '.' in result:
29
+ result = result.rstrip('0').rstrip('.')
30
+ return result
31
+ except (InvalidOperation, ValueError):
32
+ return str(x)
33
+
34
+
35
+ class DynamicFormatter(Formatter):
36
+ """Value-axis formatter that adapts to the data range.
37
+
38
+ Determines precision from the maximum tick value (2 significant digits):
39
+ - max >= 1,000: commas, no decimals (e.g. "1,200,000")
40
+ - max >= 10: no decimals (e.g. "45")
41
+ - max >= 1: 1 decimal (e.g. "4.7")
42
+ - max >= 0.1: 2 decimals (e.g. "0.46")
43
+ - smaller: more decimals as needed
44
+
45
+ Switches to engineering notation (exponent multiple of 3) when
46
+ |max value| > 990,000,000 (e.g. "1.5e9").
47
+ """
48
+
49
+ # ── superscript digits for nice exponent display ──
50
+ # Alias to the module-level single source so tick formatting and the
51
+ # value-label formatter share one table.
52
+ _SUPERSCRIPTS = _SUPERSCRIPTS
53
+
54
+ def format_ticks(self, values):
55
+ if len(values) == 0:
56
+ return []
57
+
58
+ finite = [v for v in values if math.isfinite(v)]
59
+ if not finite:
60
+ return [str(v) for v in values]
61
+
62
+ max_abs = max(abs(v) for v in finite)
63
+ if max_abs == 0:
64
+ return ['0' for _ in values]
65
+
66
+ # Engineering notation for very large values
67
+ if max_abs > 990_000_000:
68
+ return self._eng_format(values, max_abs)
69
+
70
+ # Number of decimals: show ~2 significant digits
71
+ # decimals = max(0, 1 - floor(log10(max_abs)))
72
+ log_max = math.floor(math.log10(max_abs))
73
+ decimals = max(0, 1 - log_max)
74
+
75
+ # Commas for thousands and above
76
+ if max_abs >= 1_000:
77
+ fmt_spec = f',.{decimals}f'
78
+ else:
79
+ fmt_spec = f'.{decimals}f'
80
+
81
+ return [format(v, fmt_spec) for v in values]
82
+
83
+ def _eng_format(self, values, max_abs):
84
+ """Format with engineering notation (exponent as multiple of 3)."""
85
+ exp = 3 * (math.floor(math.log10(max_abs)) // 3)
86
+ divisor = 10 ** exp
87
+
88
+ # Determine decimals for the divided values (2 sig digits)
89
+ max_divided = max_abs / divisor
90
+ log_div = math.floor(math.log10(max_divided)) if max_divided > 0 else 0
91
+ decimals = max(0, 1 - log_div)
92
+
93
+ fmt_spec = f',.{decimals}f' if max_divided >= 1_000 else f'.{decimals}f'
94
+ exp_str = str(exp).translate(self._SUPERSCRIPTS)
95
+
96
+ result = []
97
+ for v in values:
98
+ if not math.isfinite(v):
99
+ result.append(str(v))
100
+ elif v == 0:
101
+ result.append('0')
102
+ else:
103
+ divided = v / divisor
104
+ formatted = format(divided, fmt_spec)
105
+ result.append(f'{formatted}×10{exp_str}')
106
+ return result
107
+
108
+ def __call__(self, x, pos=None):
109
+ """Fallback for individual tick formatting (cursor display, width estimates)."""
110
+ if not math.isfinite(x):
111
+ return str(x)
112
+ if x == 0:
113
+ return '0'
114
+ max_abs = abs(x)
115
+ if max_abs > 990_000_000:
116
+ exp = 3 * (math.floor(math.log10(max_abs)) // 3)
117
+ divisor = 10 ** exp
118
+ divided = x / divisor
119
+ log_div = math.floor(math.log10(abs(divided))) if divided != 0 else 0
120
+ dec = max(0, 1 - log_div)
121
+ exp_str = str(exp).translate(self._SUPERSCRIPTS)
122
+ return f'{format(divided, f".{dec}f")}×10{exp_str}'
123
+ log_max = math.floor(math.log10(max_abs))
124
+ decimals = max(0, 1 - log_max)
125
+ if max_abs >= 1_000:
126
+ return format(x, f',.{decimals}f')
127
+ return format(x, f'.{decimals}f')
128
+
129
+
130
+ # Module-level instance reused only to delegate the small-value (< 1e6)
131
+ # comma-formatting path of format_value_label, so small value labels render
132
+ # exactly as the dynamic tick path would. This does NOT affect tick labels.
133
+ _VALUE_LABEL_DELEGATE = DynamicFormatter()
134
+
135
+
136
+ def format_value_label(x) -> str:
137
+ """Format a bar value label (the ``'dynamic'`` value-label path only).
138
+
139
+ For ``abs(x) >= 1_000_000`` produce compact engineering notation in the
140
+ ×10⁶ / ×10⁹ / ×10¹² style with 5 significant figures on the mantissa
141
+ (trailing zeros and a trailing '.' stripped), e.g. ``16_701_871`` →
142
+ ``"16.701×10⁶"``. Below 1e6 (and for non-finite / zero values) delegate
143
+ to the existing dynamic comma formatting so small labels are unchanged.
144
+
145
+ Note: this is for *value labels on bars only*. Axis tick labels keep going
146
+ through DynamicFormatter / _get_value_formatter and must not call this.
147
+ """
148
+ if not math.isfinite(x) or x == 0:
149
+ return _VALUE_LABEL_DELEGATE(x, None)
150
+ abs_x = abs(x)
151
+ if abs_x < 1_000_000:
152
+ return _VALUE_LABEL_DELEGATE(x, None)
153
+
154
+ # Engineering exponent (multiple of 3); for abs_x >= 1e6 this is >= 6.
155
+ exp = 3 * (math.floor(math.log10(abs_x)) // 3)
156
+ mantissa = x / 10 ** exp
157
+ # 5 significant figures: |mantissa| lies in [1, 1000).
158
+ decimals = max(0, 4 - math.floor(math.log10(abs(mantissa))))
159
+ mantissa_str = format(mantissa, f'.{decimals}f')
160
+ if '.' in mantissa_str:
161
+ mantissa_str = mantissa_str.rstrip('0').rstrip('.')
162
+ exp_str = str(exp).translate(_SUPERSCRIPTS)
163
+ return f'{mantissa_str}×10{exp_str}'
164
+
165
+
166
+ def _get_value_formatter(axis_tick_format, idx: int):
167
+ """Return a tick formatter for subplot idx.
168
+
169
+ axis_tick_format can be:
170
+ None → sig-figs FuncFormatter (default, 5 sig figs, plain notation)
171
+ 'dynamic' → DynamicFormatter (adapts to data range)
172
+ ',.0f' → StrMethodFormatter applied to all subplots
173
+ [',.0f', '.2%'] → per-subplot StrMethodFormatter; sig-figs default beyond list length
174
+ The format spec is a standard Python format spec (without braces), e.g. ',.0f', '.2%'.
175
+ """
176
+ if axis_tick_format is None:
177
+ return FuncFormatter(_sig_figs_fmt)
178
+ if isinstance(axis_tick_format, str):
179
+ spec = axis_tick_format
180
+ elif isinstance(axis_tick_format, list):
181
+ entry = axis_tick_format[idx] if idx < len(axis_tick_format) else None
182
+ if entry is None:
183
+ return FuncFormatter(_sig_figs_fmt)
184
+ spec = entry
185
+ else:
186
+ return FuncFormatter(_sig_figs_fmt)
187
+ if spec == 'dynamic':
188
+ return DynamicFormatter()
189
+ def _fmt_with_spec(x, pos, _spec=str(spec)):
190
+ try:
191
+ return format(x, _spec)
192
+ except (ValueError, TypeError) as e:
193
+ logging.error(f"axis_tick_format: cannot format value {x!r} with spec {_spec!r}: {e}")
194
+ return str(x)
195
+ return FuncFormatter(_fmt_with_spec)
196
+
197
+
198
+ def generate_split_filename(
199
+ base_name: str,
200
+ plot_dir: str,
201
+ extension: str,
202
+ file_idx: int | None = None,
203
+ needs_split: bool = False,
204
+ file_member: str | None = None,
205
+ ) -> str:
206
+ """Generate filename with appropriate suffix based on splitting needs.
207
+
208
+ - No splitting: base_name.extension
209
+ - With file_member only: base_name_member.extension
210
+ - With splitting: base_name_01.extension, base_name_02.extension, ...
211
+ - With both: base_name_member_01.extension, base_name_member_02.extension, ...
212
+
213
+ File index uses leading zeros for numbers < 10 (e.g., _01, _02, ..., _09, _10).
214
+ """
215
+ name = base_name
216
+ if file_member is not None:
217
+ name = f'{name}_{file_member}'
218
+ if not needs_split:
219
+ return f'{plot_dir}/{name}.{extension}'
220
+ else:
221
+ idx_str = f'{file_idx:02d}'
222
+ return f'{plot_dir}/{name}_{idx_str}.{extension}'
223
+
224
+
225
+ def split_into_chunks(items, chunk_size):
226
+ """Split a list into chunks of specified size."""
227
+ for i in range(0, len(items), chunk_size):
228
+ yield items[i:i + chunk_size]
229
+
230
+
231
+ def _chunk_average_df(df: pd.DataFrame, chunk_size: int) -> pd.DataFrame:
232
+ """Chunk-average a DataFrame along its (simple) index.
233
+
234
+ Divides the index into consecutive blocks of `chunk_size` rows,
235
+ averages each block, and labels the result with the first original
236
+ index label of each chunk.
237
+ """
238
+ chunk_ids = np.arange(len(df)) // chunk_size
239
+ first_labels = df.index[::chunk_size]
240
+ averaged = df.groupby(chunk_ids).mean()
241
+ averaged.index = first_labels[:len(averaged)]
242
+ averaged.index.name = df.index.name
243
+ return averaged
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Timeline breaks
248
+ # ---------------------------------------------------------------------------
249
+
250
+ def load_timeline_breaks(*parquet_dirs: str | os.PathLike) -> set[str]:
251
+ """Load timeline break timesteps from one or more parquet directories.
252
+
253
+ Reads ``timeline_breaks.parquet`` from each directory and returns the
254
+ union of all break timestep identifiers (e.g. ``{'t1336', 't2848'}``).
255
+ Returns an empty set if no files are found.
256
+ """
257
+ break_times: set[str] = set()
258
+ for d in parquet_dirs:
259
+ path = os.path.join(str(d), 'timeline_breaks.parquet')
260
+ if os.path.exists(path):
261
+ try:
262
+ df = read_lean_parquet(path)
263
+ if 'time' in df.columns:
264
+ break_times.update(df['time'].astype(str))
265
+ except Exception:
266
+ pass
267
+ return break_times
268
+
269
+
270
+ def split_at_timeline_breaks(
271
+ df: pd.DataFrame,
272
+ break_times: set[str],
273
+ ) -> list[pd.DataFrame]:
274
+ """Split a DataFrame into contiguous segments at timeline discontinuities.
275
+
276
+ Returns a list of DataFrames, one per contiguous block. Plotting each
277
+ segment separately produces clean gaps (lines/areas stop and restart)
278
+ rather than plunging to zero at NaN values.
279
+
280
+ Works with both simple and MultiIndex row indices.
281
+ """
282
+ if not break_times or df.empty:
283
+ return [df]
284
+
285
+ # Find the time level in the index
286
+ if isinstance(df.index, pd.MultiIndex):
287
+ time_level = df.index.nlevels - 1 # default: last level
288
+ for i, name in enumerate(df.index.names):
289
+ if name and str(name).lower() in ('time', 't'):
290
+ time_level = i
291
+ break
292
+ time_vals = df.index.get_level_values(time_level).astype(str)
293
+ else:
294
+ time_vals = df.index.astype(str)
295
+
296
+ # Find integer positions where breaks occur
297
+ break_positions = [i for i, t in enumerate(time_vals) if t in break_times]
298
+ if not break_positions:
299
+ return [df]
300
+
301
+ # Split into contiguous segments
302
+ segments: list[pd.DataFrame] = []
303
+ prev = 0
304
+ for pos in break_positions:
305
+ if prev < pos:
306
+ segments.append(df.iloc[prev:pos])
307
+ prev = pos
308
+ if prev < len(df):
309
+ segments.append(df.iloc[prev:])
310
+
311
+ return segments
312
+
313
+
314
+ def insert_timeline_breaks(
315
+ df: pd.DataFrame,
316
+ break_times: set[str],
317
+ gap_rows: int = 3,
318
+ ) -> pd.DataFrame:
319
+ """Insert NaN rows before timeline discontinuities for visual gaps.
320
+
321
+ Inserts *gap_rows* NaN rows (default 3) between contiguous blocks
322
+ so the gap is clearly visible in plots.
323
+ """
324
+ if not break_times or df.empty:
325
+ return df
326
+
327
+ segments = split_at_timeline_breaks(df, break_times)
328
+ if len(segments) <= 1:
329
+ return df
330
+
331
+ # Insert NaN rows between segments
332
+ parts: list[pd.DataFrame] = []
333
+ for i, seg in enumerate(segments):
334
+ if i > 0:
335
+ prev_seg = parts[-1]
336
+ gap_index = prev_seg.index[-1:].repeat(gap_rows)
337
+ nan_block = pd.DataFrame(
338
+ np.nan,
339
+ index=gap_index,
340
+ columns=df.columns,
341
+ )
342
+ parts.append(nan_block)
343
+ parts.append(seg)
344
+
345
+ return pd.concat(parts)
@@ -0,0 +1,143 @@
1
+ """Legend sizing, positioning, and label formatting utilities."""
2
+ from __future__ import annotations
3
+
4
+ import matplotlib.pyplot as plt
5
+
6
+
7
+ def estimate_legend_width(labels, title: str = '', base_width: float = 1.5) -> float:
8
+ """Estimate the width in inches needed for a legend based on label content.
9
+
10
+ Args:
11
+ labels: List of label strings
12
+ title: Legend title string
13
+ base_width: Minimum width in inches (default: 1.5)
14
+
15
+ Returns:
16
+ Estimated width in inches
17
+ """
18
+ if not labels:
19
+ return base_width
20
+
21
+ max_label_len = max(len(str(label)) for label in labels)
22
+ title_len = len(str(title)) if title else 0
23
+
24
+ # ~0.09 inches per character + base padding (accounts for typical 8-10pt font)
25
+ char_width = 0.07
26
+ label_width = max_label_len * char_width
27
+ title_width = title_len * char_width
28
+
29
+ estimated_width = max(label_width, title_width, base_width) + 0.8
30
+ return estimated_width
31
+
32
+
33
+ def estimate_legend_height(n_entries: int, has_title: bool = True) -> float:
34
+ """Estimate the height in inches needed for a legend.
35
+
36
+ Args:
37
+ n_entries: Number of legend entries
38
+ has_title: Whether the legend has a title
39
+
40
+ Returns:
41
+ Estimated height in inches
42
+ """
43
+ entry_height = 0.22 # approximate height per entry at 10pt font
44
+ title_height = 0.25 if has_title else 0.0
45
+ padding = 0.15 # top + bottom padding
46
+ return n_entries * entry_height + title_height + padding
47
+
48
+
49
+ def _format_legend_labels(items: list) -> list[str]:
50
+ """Convert legend items (possibly tuples/lists) to display strings.
51
+
52
+ Tuples and lists are joined with ' | '; all other items are str()-converted.
53
+ """
54
+ result = []
55
+ for item in items:
56
+ if isinstance(item, (tuple, list)):
57
+ result.append(' | '.join(str(v) for v in item))
58
+ else:
59
+ result.append(str(item))
60
+ return result
61
+
62
+
63
+ def _should_show_legend(
64
+ legend_position: str,
65
+ sub_levels: list,
66
+ idx: int,
67
+ n_cols: int,
68
+ n_subs: int,
69
+ ) -> bool:
70
+ """Return True if the legend should be shown on subplot idx.
71
+
72
+ 'all' → show on every subplot.
73
+ 'right' → show only on the rightmost column or the last subplot.
74
+ 'shared' → never (the caller renders one figure-level legend separately).
75
+ """
76
+ if legend_position == 'shared':
77
+ return False
78
+ if legend_position == 'all':
79
+ return True
80
+ if legend_position == 'right':
81
+ col = idx % n_cols
82
+ if col == n_cols - 1 or idx == n_subs - 1:
83
+ return True
84
+ return False
85
+
86
+
87
+ def build_shared_color_map(
88
+ labels: list[str],
89
+ *,
90
+ color_template: dict | None = None,
91
+ category: str | None = None,
92
+ entity_class: str | None = None,
93
+ scenario: bool = False,
94
+ ) -> dict[str, tuple]:
95
+ """Assign consistent colors to a list of labels.
96
+
97
+ Template-matched labels (via ``color_template`` + ``category`` /
98
+ ``entity_class``) get their explicit color and do **not** consume a
99
+ palette slot. Remaining labels get tab10/tab20 colors assigned in
100
+ input order, so e.g. the first un-templated label always gets
101
+ ``tab10[0]`` regardless of how many template matches preceded it.
102
+
103
+ With no template (the default), this is byte-identical to the
104
+ previous palette-only behaviour: the palette is picked based on the
105
+ total label count — tab10 when ≤10, tab20 otherwise — and labels are
106
+ assigned in input order, cycling if the palette is exhausted.
107
+ """
108
+ from flextool.plot_outputs.color_template import resolve_label_color
109
+
110
+ n = len(labels)
111
+
112
+ # First pass: resolve template colors for each label. None means
113
+ # "fall back to palette".
114
+ template_colors: list[tuple | None] = [None] * n
115
+ palette_needed = n
116
+ if color_template and (category or entity_class or scenario):
117
+ for i, label in enumerate(labels):
118
+ c = resolve_label_color(
119
+ label, color_template, category=category,
120
+ entity_class=entity_class, scenario=scenario,
121
+ )
122
+ if c is not None:
123
+ template_colors[i] = c
124
+ palette_needed -= 1
125
+
126
+ # Pick the palette based on how many palette slots we actually need.
127
+ # For the zero-template case, palette_needed == n, so the behaviour
128
+ # matches the pre-refactor code exactly.
129
+ if palette_needed <= 10:
130
+ cmap_colors = plt.colormaps['tab10'].colors
131
+ else:
132
+ cmap_colors = plt.colormaps['tab20'].colors
133
+
134
+ result: dict[str, tuple] = {}
135
+ palette_idx = 0
136
+ for i, label in enumerate(labels):
137
+ tc = template_colors[i]
138
+ if tc is not None:
139
+ result[label] = tc
140
+ continue
141
+ result[label] = cmap_colors[palette_idx % len(cmap_colors)]
142
+ palette_idx += 1
143
+ return result