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,888 @@
1
+ """Loader and resolver for the per-project plot settings (colors).
2
+
3
+ The settings file maps legend labels to explicit RGB colors. It has
4
+ these label sections:
5
+
6
+ * ``categories`` — keyed by a category name declared by a plot entry via
7
+ ``color_category``. Label lookup is *exact* (these are parameter or
8
+ result names whose casing is controlled by the pipeline).
9
+ * ``entities`` — keyed by an entity-class name (``nodeGroup`` /
10
+ ``flowGroup`` / ``unit`` / ``connection`` / ``node``; legacy ``group``
11
+ still read for dispatch) declared via ``color_entity_class``. Label
12
+ lookup is *case-insensitive* (entity names come from the data and can be
13
+ mixed-case). An entity entry may be a bare color or a mapping
14
+ ``{color: ..., neg_color: ...}`` whose ``neg_color`` colors the
15
+ entity's negative-side part of a mixed (both-signs) stacked column.
16
+
17
+ For backward compatibility the resolver also reads the legacy top-level
18
+ keys ``category`` / ``entity_class`` written by older projects' seeded
19
+ ``plot_settings.yaml`` (migration window).
20
+
21
+ Any label not found falls back to the tab10/tab20 palette used by
22
+ :func:`build_shared_color_map`.
23
+
24
+ The bundled starting-point file lives at
25
+ ``schemas/default_plot_settings.yaml`` and is copied into each project as
26
+ ``plot_settings.yaml``. The loaded result is cached at the module level
27
+ keyed by ``(path, mtime)`` so repeated plot builds within the same process
28
+ don't re-read it.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ from pathlib import Path
35
+
36
+ import yaml
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ # (resolved_path, mtime_ns) -> parsed dict
42
+ _TEMPLATE_CACHE: dict[tuple[str, int], dict] = {}
43
+
44
+
45
+ def _default_path() -> Path:
46
+ from flextool._resources import package_data_path
47
+ return package_data_path("schemas/default_plot_settings.yaml")
48
+
49
+
50
+ def resolve_plot_settings_path(project_path: Path | None) -> Path:
51
+ """Resolve the plot color template path for *project_path*.
52
+
53
+ Returns ``<project_path>/plot_settings.yaml`` when that file exists,
54
+ otherwise the bundled starting point
55
+ (``schemas/default_plot_settings.yaml``). This
56
+ keeps behavior byte-identical to before for any project that has no
57
+ ``plot_settings.yaml`` while letting a per-project file override the
58
+ template for both PNG export and the result viewer.
59
+ """
60
+ if project_path is not None:
61
+ candidate = Path(project_path) / "plot_settings.yaml"
62
+ if candidate.is_file():
63
+ return candidate
64
+ return _default_path()
65
+
66
+
67
+ def load_color_template(path: Path | None = None) -> dict:
68
+ """Load the color template YAML from *path* (or the default location).
69
+
70
+ Returns an empty dict if the file is missing or unreadable, so
71
+ callers can treat "no template" uniformly. The result is cached
72
+ keyed by ``(resolved path, mtime_ns)`` — a subsequent call with an
73
+ unchanged file hits the cache.
74
+ """
75
+ resolved = Path(path) if path is not None else _default_path()
76
+ try:
77
+ st = resolved.stat()
78
+ except OSError:
79
+ return {}
80
+
81
+ cache_key = (str(resolved), st.st_mtime_ns)
82
+ cached = _TEMPLATE_CACHE.get(cache_key)
83
+ if cached is not None:
84
+ return cached
85
+
86
+ try:
87
+ with open(resolved, "r", encoding="utf-8") as f:
88
+ data = yaml.safe_load(f)
89
+ except (yaml.YAMLError, OSError) as exc:
90
+ logger.warning("Failed to load color template %s: %s", resolved, exc)
91
+ _TEMPLATE_CACHE[cache_key] = {}
92
+ return {}
93
+
94
+ if not isinstance(data, dict):
95
+ data = {}
96
+
97
+ # Also prune any stale entries for the same path with a different
98
+ # mtime — keeps the cache from growing unbounded over long sessions.
99
+ for key in list(_TEMPLATE_CACHE):
100
+ if key[0] == str(resolved) and key != cache_key:
101
+ del _TEMPLATE_CACHE[key]
102
+
103
+ _TEMPLATE_CACHE[cache_key] = data
104
+ return data
105
+
106
+
107
+ def _parse_color_value(value) -> tuple[float, float, float] | None:
108
+ """Normalize a YAML color value to an ``(r, g, b)`` float tuple in 0..1.
109
+
110
+ Accepts:
111
+ * ``#RRGGBB`` hex strings (with or without the leading ``#``).
112
+ * ``[r, g, b]`` lists/tuples of ints (0..255) or floats (0..1).
113
+
114
+ Returns ``None`` for anything malformed; callers log the context.
115
+ """
116
+ if value is None:
117
+ return None
118
+ if isinstance(value, str):
119
+ s = value.strip().lstrip("#")
120
+ if len(s) != 6:
121
+ return None
122
+ try:
123
+ r = int(s[0:2], 16)
124
+ g = int(s[2:4], 16)
125
+ b = int(s[4:6], 16)
126
+ except ValueError:
127
+ return None
128
+ return (r / 255.0, g / 255.0, b / 255.0)
129
+ if isinstance(value, (list, tuple)) and len(value) == 3:
130
+ try:
131
+ parts = [float(x) for x in value]
132
+ except (TypeError, ValueError):
133
+ return None
134
+ # Decide 0..1 vs 0..255 by the max component.
135
+ max_part = max(parts)
136
+ min_part = min(parts)
137
+ if min_part < 0:
138
+ return None
139
+ if max_part <= 1.0:
140
+ # floats in 0..1
141
+ if any(p > 1.0 for p in parts):
142
+ return None
143
+ return (parts[0], parts[1], parts[2])
144
+ if max_part <= 255.0:
145
+ return (parts[0] / 255.0, parts[1] / 255.0, parts[2] / 255.0)
146
+ return None
147
+ return None
148
+
149
+
150
+ # Categories whose labels are composite ``(type, item)`` tuples rendered
151
+ # into legend strings in one of two shapes:
152
+ # * ``"<type> | <item>"`` — bar plans (via _format_legend_labels)
153
+ # * ``"('<type>', '<item>')"`` — time-series stacks (str(tuple))
154
+ # For these categories we split the label, then try
155
+ # 1. ``<type>_<item>`` (e.g. ``slack_upward``)
156
+ # 2. ``<type>`` alone (e.g. ``unit``)
157
+ # before falling through. Plain (non-composite) labels still fall through
158
+ # to the exact-key lookup below.
159
+ _COMPOSITE_CATEGORIES: frozenset[str] = frozenset({"nodegroup_flows"})
160
+
161
+
162
+ def _split_composite_label(label: str) -> tuple[str, str] | None:
163
+ """Split a composite legend label into ``(type, item)`` if possible.
164
+
165
+ Recognizes the two formats emitted by the plot pipeline for two-level
166
+ column stacks:
167
+
168
+ * ``"<type> | <item>"`` — produced by ``_format_legend_labels`` in the
169
+ bar-plan path.
170
+ * ``"('<type>', '<item>')"`` — produced by ``str(item)`` in the
171
+ time-series stack path when ``item`` is a 2-tuple.
172
+
173
+ Returns ``None`` for labels that don't match either shape (callers
174
+ should treat the label as non-composite and try a plain key lookup).
175
+ """
176
+ if not isinstance(label, str):
177
+ return None
178
+
179
+ # " | " separator (bar plans).
180
+ if " | " in label:
181
+ parts = label.split(" | ", 1)
182
+ if len(parts) == 2 and parts[0] and parts[1]:
183
+ return parts[0], parts[1]
184
+
185
+ # Python tuple repr (time-series stacks). Use ast.literal_eval for
186
+ # safety — the string came from str(tuple(...)) of hashable scalars so
187
+ # it should parse cleanly; anything else falls through.
188
+ s = label.strip()
189
+ if s.startswith("(") and s.endswith(")"):
190
+ try:
191
+ import ast
192
+ parsed = ast.literal_eval(s)
193
+ except (ValueError, SyntaxError):
194
+ return None
195
+ if isinstance(parsed, tuple) and len(parsed) == 2:
196
+ a, b = parsed
197
+ if isinstance(a, str) and isinstance(b, str) and a and b:
198
+ return a, b
199
+ return None
200
+
201
+
202
+ def _resolve_entity_value(raw):
203
+ """Normalize an entity entry into ``(color_value, neg_color_value)``.
204
+
205
+ An entity entry may be either a bare color scalar (``"#RRGGBB"`` or
206
+ ``[r, g, b]``) or a mapping ``{color: ..., neg_color: ...}``. Returns
207
+ the positive-side value and the optional negative-side value (the
208
+ latter ``None`` when the entry is a scalar or has no ``neg_color``).
209
+ """
210
+ if isinstance(raw, dict):
211
+ return raw.get("color"), raw.get("neg_color")
212
+ return raw, None
213
+
214
+
215
+ # For the ``nodegroup_flows`` composite category each flow ``type`` is
216
+ # colored by its ``item`` (the participating entity), resolved in exactly
217
+ # one ``entities`` class — the ``type`` fixes the class (``from_unitGroup``
218
+ # items are ``group`` entities, ``from_unit`` items are ``unit`` entities,
219
+ # etc.). ``to_*`` types are the draw (negative) side and use the entity's
220
+ # ``neg_color`` when one is defined. Types absent from this table
221
+ # (``inflow`` / ``internal_losses`` / ``slack``) are group-property
222
+ # aggregates and stay category-colored.
223
+ _NODEGROUP_TYPE_TO_ENTITY_CLASS: dict[str, str] = {
224
+ "from_unitGroup": "flowGroup",
225
+ "to_unitGroup": "flowGroup",
226
+ "from_connectionGroup": "flowGroup",
227
+ "to_connectionGroup": "flowGroup",
228
+ "from_unit": "unit",
229
+ "to_unit": "unit",
230
+ "from_connection": "connection",
231
+ "to_connection": "connection",
232
+ }
233
+
234
+ # Stacking band order for ``nodegroup_flows`` types (supply types first,
235
+ # draw types last; the series sign decides which side of zero each lands
236
+ # on, this only fixes the order *within* a side). Items inside an
237
+ # entity-routed band are ordered by their ``entities`` class file order
238
+ # (so the picker's entity reordering carries through); items inside a
239
+ # special band (slack upward/downward, internal_losses
240
+ # storages/units/connections) by ``categories.nodegroup_flows`` file order.
241
+ _NODEGROUP_TYPE_ORDER: tuple[str, ...] = (
242
+ "from_unitGroup",
243
+ "from_unit",
244
+ "from_connectionGroup",
245
+ "from_connection",
246
+ "inflow",
247
+ "slack",
248
+ "internal_losses",
249
+ "to_connection",
250
+ "to_connectionGroup",
251
+ "to_unit",
252
+ "to_unitGroup",
253
+ )
254
+
255
+
256
+ def _lookup_entity_color_in_class(
257
+ template: dict, entity_class: str, name: str, negative: bool,
258
+ ):
259
+ """Look up *name* in a single ``entities`` class (case-insensitive).
260
+
261
+ Returns the raw color value (``neg_color`` when *negative* and the
262
+ entry defines one, else ``color``) or ``None`` when that class lists no
263
+ *name*. Unlike :func:`_lookup_entity_color` this does **not** search
264
+ the other classes — the nodegroup ``type`` already fixes the class.
265
+ """
266
+ section = _entities_section(template)
267
+ class_map = section.get(entity_class)
268
+ if not isinstance(class_map, dict):
269
+ return None
270
+ name_lc = str(name).lower()
271
+ for key, val in class_map.items():
272
+ if str(key).lower() == name_lc:
273
+ color_val, neg_val = _resolve_entity_value(val)
274
+ if negative and neg_val is not None:
275
+ return neg_val
276
+ return color_val
277
+ return None
278
+
279
+
280
+ def resolve_label_color(
281
+ label: str,
282
+ template: dict,
283
+ category: str | None = None,
284
+ entity_class: str | None = None,
285
+ negative: bool = False,
286
+ scenario: bool = False,
287
+ ) -> tuple[float, float, float] | None:
288
+ """Resolve *label* to a color via the plot-settings *template*.
289
+
290
+ Lookup precedence:
291
+ * If *scenario* is True, check the top-level ``scenarios`` section with
292
+ a case-insensitive key match (single color; ``negative`` has no
293
+ effect). Used to color the scenario series of comparison plots.
294
+ * Else if *entity_class* is given, check the ``entities`` section
295
+ (legacy ``entity_class``) under ``[entity_class]`` with a
296
+ case-insensitive key match. An entity entry may be a bare color or
297
+ a mapping ``{color: ..., neg_color: ...}``. When *negative* is True
298
+ and the entry has a ``neg_color``, that color is returned; otherwise
299
+ the positive ``color`` is used.
300
+ * Else if *category* is given, check the ``categories`` section
301
+ (legacy ``category``) under ``[category]`` with an exact key match.
302
+ For composite-label categories (see ``_COMPOSITE_CATEGORIES``) the
303
+ label is first split on ``" | "`` or parsed as a 2-tuple repr and
304
+ looked up as ``<type>_<item>`` then ``<type>`` before falling back
305
+ to the exact-key lookup. Categories are always scalar; *negative*
306
+ has no effect.
307
+ * Otherwise return ``None`` and let the caller fall back to the palette.
308
+
309
+ Both the new top-level keys (``categories`` / ``entities``) and the
310
+ legacy keys (``category`` / ``entity_class``) are accepted so that
311
+ projects whose seeded ``plot_settings.yaml`` still uses the old schema
312
+ keep working (migration window).
313
+
314
+ Returns a normalized ``(r, g, b)`` float tuple in ``0..1`` or
315
+ ``None`` if the template has no entry (or the entry is malformed).
316
+ Malformed values are logged but do not raise.
317
+ """
318
+ if not isinstance(template, dict) or label is None:
319
+ return None
320
+
321
+ raw = None
322
+ if scenario:
323
+ section = template.get("scenarios")
324
+ if isinstance(section, dict):
325
+ label_lc = str(label).lower()
326
+ for key, val in section.items():
327
+ if str(key).lower() == label_lc:
328
+ color_val, _neg = _resolve_entity_value(val)
329
+ raw = color_val
330
+ break
331
+ elif entity_class:
332
+ section = template.get("entities")
333
+ if not isinstance(section, dict):
334
+ section = template.get("entity_class")
335
+ if isinstance(section, dict):
336
+ class_map = section.get(entity_class)
337
+ if isinstance(class_map, dict):
338
+ # Case-insensitive lookup. Build a lowercase view on the
339
+ # fly — these dicts are small (tens of entries at most).
340
+ label_lc = str(label).lower()
341
+ for key, val in class_map.items():
342
+ if str(key).lower() == label_lc:
343
+ color_val, neg_val = _resolve_entity_value(val)
344
+ raw = neg_val if (negative and neg_val is not None) else color_val
345
+ break
346
+ elif category and category in _COMPOSITE_CATEGORIES:
347
+ # Composite categories (nodegroup_flows): entity-routed types color
348
+ # by their ``item`` via the ``entities`` section — which works even
349
+ # when ``categories.<category>`` is absent — while group-property
350
+ # types (inflow / internal_losses / slack) stay category-keyed.
351
+ section = template.get("categories")
352
+ if not isinstance(section, dict):
353
+ section = template.get("category")
354
+ cat_map = section.get(category) if isinstance(section, dict) else None
355
+ if not isinstance(cat_map, dict):
356
+ cat_map = {}
357
+ entity_routed = False
358
+ parts = _split_composite_label(label)
359
+ if parts is not None:
360
+ type_key, item_key = parts
361
+ entity_cls = _NODEGROUP_TYPE_TO_ENTITY_CLASS.get(type_key)
362
+ if entity_cls is not None:
363
+ # Entity-routed type: color by the item via its single
364
+ # entities class; ``to_*`` is the draw (negative) side. No
365
+ # type-color fallback — un-listed items go to the palette,
366
+ # otherwise a per-type color would re-collapse every item to
367
+ # one hue (the bug this routing fixes).
368
+ entity_routed = True
369
+ raw = _lookup_entity_color_in_class(
370
+ template, entity_cls, item_key,
371
+ negative=type_key.startswith("to_"),
372
+ )
373
+ else:
374
+ # Group-property aggregate: type_item first then type.
375
+ raw = cat_map.get(f"{type_key}_{item_key}")
376
+ if raw is None:
377
+ raw = cat_map.get(type_key)
378
+ # Exact-key fallback for non-composite labels / verbatim entries;
379
+ # entity-routed misses fall through to the palette instead.
380
+ if raw is None and not entity_routed:
381
+ raw = cat_map.get(label)
382
+ elif category:
383
+ section = template.get("categories")
384
+ if not isinstance(section, dict):
385
+ section = template.get("category")
386
+ if isinstance(section, dict):
387
+ cat_map = section.get(category)
388
+ if isinstance(cat_map, dict):
389
+ raw = cat_map.get(label)
390
+
391
+ if raw is None:
392
+ return None
393
+
394
+ color = _parse_color_value(raw)
395
+ if color is None:
396
+ logger.warning(
397
+ "Color template: malformed value %r for label %r "
398
+ "(category=%r, entity_class=%r)",
399
+ raw, label, category, entity_class,
400
+ )
401
+ return color
402
+
403
+
404
+ def template_label_order(
405
+ template: dict,
406
+ category: str | None = None,
407
+ entity_class: str | None = None,
408
+ scenario: bool = False,
409
+ ) -> list[str]:
410
+ """Return the label keys of a template section in **file order**.
411
+
412
+ This is the single source of file-defined stacking/legend order. Given
413
+ a loaded *template* and an optional *category* or *entity_class*, it
414
+ returns the keys of ``categories[category]`` (exact) or
415
+ ``entities[entity_class]`` (case-insensitive, but the file's canonical
416
+ key spelling is returned) in YAML insertion order — which is dict
417
+ insertion order, preserved by the loader.
418
+
419
+ Both the new top-level keys (``categories`` / ``entities``) and the
420
+ legacy keys (``category`` / ``entity_class``) are honored (migration
421
+ window), matching :func:`resolve_label_color`.
422
+
423
+ Returns an empty list when the template has no matching section. When
424
+ both *category* and *entity_class* are given, *entity_class* wins
425
+ (matching the lookup precedence in :func:`resolve_label_color`).
426
+ """
427
+ if not isinstance(template, dict):
428
+ return []
429
+
430
+ if scenario:
431
+ section = template.get("scenarios")
432
+ if isinstance(section, dict):
433
+ return [str(k) for k in section.keys()]
434
+ return []
435
+
436
+ if entity_class:
437
+ section = template.get("entities")
438
+ if not isinstance(section, dict):
439
+ section = template.get("entity_class")
440
+ if isinstance(section, dict):
441
+ class_map = section.get(entity_class)
442
+ if isinstance(class_map, dict):
443
+ return [str(k) for k in class_map.keys()]
444
+ return []
445
+
446
+ if category:
447
+ section = template.get("categories")
448
+ if not isinstance(section, dict):
449
+ section = template.get("category")
450
+ if isinstance(section, dict):
451
+ cat_map = section.get(category)
452
+ if isinstance(cat_map, dict):
453
+ return [str(k) for k in cat_map.keys()]
454
+ return []
455
+
456
+
457
+ def _order_composite_labels(
458
+ labels: list[str], template: dict, category: str,
459
+ ) -> list[str]:
460
+ """Order ``nodegroup_flows`` composite ``(type, item)`` labels.
461
+
462
+ Bands follow :data:`_NODEGROUP_TYPE_ORDER`. Within an entity-routed
463
+ band items follow their ``entities`` class file order (honoring the
464
+ picker's reordering); within a special band they follow
465
+ ``categories.<category>`` file order. Labels that don't parse as a
466
+ composite, and items not listed anywhere, sort to the tail by name.
467
+ The result is a permutation of *labels* (stable on ties via the
468
+ original index).
469
+ """
470
+ band_index = {t: i for i, t in enumerate(_NODEGROUP_TYPE_ORDER)}
471
+ n_band = len(_NODEGROUP_TYPE_ORDER)
472
+
473
+ entity_orders: dict[str, dict[str, int]] = {}
474
+ for cls in set(_NODEGROUP_TYPE_TO_ENTITY_CLASS.values()):
475
+ order = template_label_order(template, entity_class=cls)
476
+ entity_orders[cls] = {str(k).lower(): i for i, k in enumerate(order)}
477
+
478
+ cat_order = template_label_order(template, category=category)
479
+ special_index = {str(k): i for i, k in enumerate(cat_order)}
480
+ n_special = len(special_index)
481
+
482
+ def sort_key(item):
483
+ idx, label = item
484
+ parts = _split_composite_label(label)
485
+ if parts is None:
486
+ return (n_band, 0, str(label), idx)
487
+ type_key, item_key = parts
488
+ band = band_index.get(type_key, n_band)
489
+ entity_cls = _NODEGROUP_TYPE_TO_ENTITY_CLASS.get(type_key)
490
+ if entity_cls is not None:
491
+ class_order = entity_orders[entity_cls]
492
+ within = class_order.get(str(item_key).lower(), len(class_order))
493
+ else:
494
+ within = special_index.get(
495
+ f"{type_key}_{item_key}",
496
+ special_index.get(type_key, n_special),
497
+ )
498
+ return (band, within, str(item_key), idx)
499
+
500
+ return [lbl for _, lbl in sorted(enumerate(labels), key=sort_key)]
501
+
502
+
503
+ def order_labels_by_template(
504
+ labels: list[str],
505
+ template: dict,
506
+ category: str | None = None,
507
+ entity_class: str | None = None,
508
+ scenario: bool = False,
509
+ ) -> list[str]:
510
+ """Order *labels* by template file order, appending unlisted tail sorted.
511
+
512
+ Labels present in the template section (per
513
+ :func:`template_label_order`) come first in **file order**; labels not
514
+ in the section are appended **sorted alphabetically** (the historical
515
+ fallback for the tail).
516
+
517
+ Matching mirrors :func:`resolve_label_color`:
518
+
519
+ * ``entity_class`` lookups are **case-insensitive**.
520
+ * ``category`` lookups are **exact**, except composite categories (see
521
+ ``_COMPOSITE_CATEGORIES``) whose labels are matched on
522
+ ``<type>_<item>`` then ``<type>`` after splitting.
523
+
524
+ The returned list is a permutation of *labels* (no labels are dropped
525
+ or duplicated even if a file key has no matching label).
526
+ """
527
+ # Composite categories (nodegroup_flows) route items to entity classes
528
+ # for both color and order, so they use a dedicated band+entity sorter
529
+ # rather than the single category-section file order below.
530
+ if category and category in _COMPOSITE_CATEGORIES and not entity_class:
531
+ return _order_composite_labels(labels, template, category)
532
+
533
+ file_order = template_label_order(
534
+ template, category=category, entity_class=entity_class, scenario=scenario)
535
+ if not file_order:
536
+ return sorted(labels, key=str)
537
+
538
+ remaining = list(labels)
539
+ ordered: list[str] = []
540
+
541
+ if entity_class or scenario:
542
+ # Case-insensitive: bucket remaining labels by lowercase.
543
+ by_lc: dict[str, list[str]] = {}
544
+ for lbl in remaining:
545
+ by_lc.setdefault(str(lbl).lower(), []).append(lbl)
546
+ consumed: set[int] = set()
547
+ for key in file_order:
548
+ bucket = by_lc.get(str(key).lower())
549
+ if not bucket:
550
+ continue
551
+ for lbl in bucket:
552
+ lid = id(lbl)
553
+ if lid in consumed:
554
+ continue
555
+ consumed.add(lid)
556
+ ordered.append(lbl)
557
+ tail = [lbl for lbl in remaining if id(lbl) not in consumed]
558
+ return ordered + sorted(tail, key=str)
559
+
560
+ # Category path (exact + composite split).
561
+ composite = bool(category) and category in _COMPOSITE_CATEGORIES
562
+
563
+ def _match_key(label) -> str | None:
564
+ """Return the file_order key this label matches, or None."""
565
+ if composite:
566
+ parts = _split_composite_label(label)
567
+ if parts is not None:
568
+ type_key, item_key = parts
569
+ joined = f"{type_key}_{item_key}"
570
+ if joined in file_order_set:
571
+ return joined
572
+ if type_key in file_order_set:
573
+ return type_key
574
+ if label in file_order_set:
575
+ return label
576
+ return None
577
+
578
+ file_order_set = set(file_order)
579
+ # Group labels by the file key they match (in file order).
580
+ matched: dict[str, list[str]] = {}
581
+ consumed_idx: set[int] = set()
582
+ for i, lbl in enumerate(remaining):
583
+ key = _match_key(lbl)
584
+ if key is not None:
585
+ matched.setdefault(key, []).append(lbl)
586
+ consumed_idx.add(i)
587
+ for key in file_order:
588
+ for lbl in matched.get(key, []):
589
+ ordered.append(lbl)
590
+ tail = [lbl for i, lbl in enumerate(remaining) if i not in consumed_idx]
591
+ return ordered + sorted(tail, key=str)
592
+
593
+
594
+ # ---------------------------------------------------------------------------
595
+ # Dispatch color / order resolution
596
+ # ---------------------------------------------------------------------------
597
+
598
+ # Entity classes the dispatch resolver searches, in precedence order. A
599
+ # dispatch column that is an entity name (a flowGroup aggregate, a unit, a
600
+ # connection, …) may live in any of these ``entities`` subsections; the
601
+ # first one with a matching (case-insensitive) key wins. ``group`` is kept
602
+ # last as a LEGACY fallback so projects seeded before the
603
+ # nodeGroup/flowGroup split still resolve their old ``entities.group``
604
+ # colors; new seeds write ``nodeGroup`` / ``flowGroup``.
605
+ # Class resolution order for a dispatch column → entity color/order. A
606
+ # dispatch band is a FLOW: a flowGroup aggregate, or an individual unit /
607
+ # connection / node. A ``nodeGroup`` is the plot's CONTAINER, never a band,
608
+ # so it must have the LOWEST priority — otherwise a nodeGroup that happens to
609
+ # share a name with a flowGroup aggregate (e.g. a "PV_flows" nodeGroup and a
610
+ # "PV_flows" flowGroup) would shadow it, and editing the flowGroup entry would
611
+ # silently do nothing. First case-insensitive match wins (see
612
+ # ``_lookup_entity_color`` / ``template_entity_names``).
613
+ _DISPATCH_ENTITY_CLASSES: tuple[str, ...] = (
614
+ "flowGroup",
615
+ "unit",
616
+ "connection",
617
+ "node",
618
+ "nodeGroup",
619
+ "group",
620
+ )
621
+
622
+
623
+ def _entities_section(template: dict) -> dict:
624
+ """Return the ``entities`` (or legacy ``entity_class``) mapping or {}."""
625
+ if not isinstance(template, dict):
626
+ return {}
627
+ section = template.get("entities")
628
+ if not isinstance(section, dict):
629
+ section = template.get("entity_class")
630
+ return section if isinstance(section, dict) else {}
631
+
632
+
633
+ def _dispatch_special_section(template: dict) -> dict:
634
+ """Return the ``categories.dispatch`` mapping or {}."""
635
+ if not isinstance(template, dict):
636
+ return {}
637
+ cats = template.get("categories")
638
+ if not isinstance(cats, dict):
639
+ cats = template.get("category")
640
+ if not isinstance(cats, dict):
641
+ return {}
642
+ block = cats.get("dispatch")
643
+ return block if isinstance(block, dict) else {}
644
+
645
+
646
+ def _extract_dispatch_entity_name(column: str) -> str | None:
647
+ """Extract the entity name from a dispatch column name.
648
+
649
+ Dispatch column names come in several shapes (see
650
+ ``scenario_comparison/dispatch_data.py``):
651
+
652
+ * bare entity name — a processGroup aggregate, e.g. ``"coal"`` ;
653
+ * ``"(process, node)"`` or ``"(connection)"`` repr composites for
654
+ not-in-aggregate flows — the *process*/*connection* is the entity ;
655
+ * node-level columns ``"<unit>_out"`` / ``"<unit>_in"`` /
656
+ ``"<conn>_left"`` / ``"<conn>_right"`` from the single-node path ;
657
+ * any of the above with a trailing ``_pos`` / ``_neg`` added by the
658
+ mixed-sign split in ``_order_dispatch_columns``.
659
+
660
+ Returns the bare entity name, or ``None`` if *column* is not a string.
661
+ """
662
+ if not isinstance(column, str):
663
+ return None
664
+ name = column
665
+
666
+ # Strip the mixed-sign split suffix first (it is appended last).
667
+ if name.endswith("_pos"):
668
+ name = name[:-4]
669
+ elif name.endswith("_neg"):
670
+ name = name[:-4]
671
+
672
+ # ``(process, node)`` / ``(connection)`` repr composites: take the
673
+ # first element (the process / connection entity).
674
+ s = name.strip()
675
+ if s.startswith("(") and s.endswith(")"):
676
+ inner = s[1:-1]
677
+ # First comma-separated field, stripped of surrounding quotes.
678
+ first = inner.split(",", 1)[0].strip().strip("'\"")
679
+ return first or None
680
+
681
+ # Node-level flow suffixes from the single-node dispatch path.
682
+ for suffix in ("_left_right", "_left", "_right", "_out", "_in"):
683
+ if name.endswith(suffix):
684
+ base = name[: -len(suffix)]
685
+ if base:
686
+ return base
687
+
688
+ return name or None
689
+
690
+
691
+ def _lookup_entity_color(entities: dict, name: str, negative: bool):
692
+ """Look up *name* across the dispatch entity classes (case-insensitive).
693
+
694
+ Returns the raw color value (positive side, or ``neg_color`` when
695
+ *negative* and present) or ``None`` if no class lists *name*.
696
+ """
697
+ name_str = str(name)
698
+ name_lc = name_str.lower()
699
+ # Two passes: EXACT case first (across all classes, flowGroup first), then
700
+ # a case-insensitive fallback. The exact pass keeps a unit ``wind`` and a
701
+ # flowGroup ``Wind`` distinct — a node's ``wind_out`` band then takes the
702
+ # unit colour and a nodeGroup's ``Wind`` aggregate the flowGroup colour,
703
+ # instead of both collapsing to whichever class comes first case-folded.
704
+ # This mirrors the exact-then-lower order index in
705
+ # ``resolve_dispatch_colors_and_order`` so colour and order agree.
706
+ for exact in (True, False):
707
+ for cls in _DISPATCH_ENTITY_CLASSES:
708
+ class_map = entities.get(cls)
709
+ if not isinstance(class_map, dict):
710
+ continue
711
+ for key, val in class_map.items():
712
+ hit = (str(key) == name_str) if exact else (
713
+ str(key).lower() == name_lc
714
+ )
715
+ if hit:
716
+ color_val, neg_val = _resolve_entity_value(val)
717
+ if negative and neg_val is not None:
718
+ return neg_val
719
+ return color_val
720
+ return None
721
+
722
+
723
+ def template_entity_names(template: dict) -> list[str]:
724
+ """Return all entity names listed in the template, in file order.
725
+
726
+ Concatenates the keys of every dispatch entity class
727
+ (``group`` / ``unit`` / ``connection`` / ``node``) in file order. This
728
+ is the data-independent stacking order seed for the dispatch resolver:
729
+ bare-entity dispatch columns match these names, so passing them to
730
+ :func:`resolve_dispatch_colors_and_order` yields a stable
731
+ ``config_order`` without first enumerating the data columns.
732
+ """
733
+ names: list[str] = []
734
+ for cls in _DISPATCH_ENTITY_CLASSES:
735
+ names.extend(template_label_order(template, entity_class=cls))
736
+ return names
737
+
738
+
739
+ def resolve_dispatch_colors_and_order(
740
+ template: dict,
741
+ columns,
742
+ ) -> tuple[dict[str, str], list[str]]:
743
+ """Build dispatch ``colors`` + ``config_order`` from a plot-settings template.
744
+
745
+ *columns* is the set of dispatch column names that may appear (the
746
+ union across scenarios, base names — i.e. before the mixed-sign
747
+ ``_pos`` / ``_neg`` split). The result reproduces the shape that the
748
+ legacy ``config['positive'|'negative']`` parsing produced:
749
+
750
+ * ``colors`` maps each column name to its color value (a ``#RRGGBB``
751
+ hex string, an ``[r, g, b]`` list, or a matplotlib color name). A
752
+ mixed-sign entity whose ``entities`` entry has ``neg_color`` also gets
753
+ a ``"<column>_neg"`` entry carrying that distinct negative-side color;
754
+ the ``"<column>_pos"`` part inherits the base color via
755
+ ``get_color_for_column``.
756
+ * ``config_order`` lists the **entity** column base names in matplotlib
757
+ stacking order (bottom-to-top), following ``entities`` file order. As
758
+ in the legacy path the list is built top→bottom then reversed for
759
+ matplotlib. Special tokens are deliberately **omitted** from
760
+ ``config_order`` so that ``_order_dispatch_columns`` keeps placing them
761
+ at their fixed ``POSITIVE_SPECIAL`` / ``NEGATIVE_SPECIAL`` positions
762
+ (its ``else`` branch is byte-for-byte the historical behavior when no
763
+ entity ordering is supplied). Special-token *colors* still flow
764
+ through ``colors``.
765
+
766
+ Resolution per column:
767
+
768
+ 1. **Special token** (exact key in ``categories.dispatch``) — e.g.
769
+ ``LossOfLoad``, ``Charge``, ``internal_losses``. Contributes a color
770
+ only; ordering stays pipeline-fixed.
771
+ 2. **Entity** — extract the entity name (bare / composite /
772
+ node-level, see :func:`_extract_dispatch_entity_name`) and look it
773
+ up case-insensitively across ``entities`` ``nodeGroup`` /
774
+ ``flowGroup`` / ``unit`` / ``connection`` / ``node`` (and legacy
775
+ ``group``). Contributes both a color and a ``config_order`` position.
776
+ 3. **Fallback** — left out of ``colors`` and ``config_order`` so the
777
+ downstream ``_auto_assign_node_colors_with_existing`` palette (seeded
778
+ from the historical ``DEFAULT_SPECIAL_COLORS``) assigns it and
779
+ std-dev ordering places it, exactly as before.
780
+
781
+ For a project whose template has no dispatch entities (the common
782
+ case — the bundled default ships ``entities`` empty), only the special
783
+ tokens resolve (to the ``categories.dispatch`` values, which equal the
784
+ old ``DEFAULT_SPECIAL_COLORS``); ``config_order`` comes back empty, so
785
+ ``_order_dispatch_columns`` takes its ``else`` branch and every column —
786
+ specials included — is ordered exactly as before.
787
+ """
788
+ special = _dispatch_special_section(template)
789
+ entities = _entities_section(template)
790
+ cols = [str(c) for c in (columns or [])]
791
+
792
+ colors: dict[str, str] = {}
793
+
794
+ # Entity columns in template file order, then the unlisted tail. We
795
+ # reuse the case-insensitive ordering machinery so file order is the
796
+ # single source of truth. Build the union of entity-class file orders
797
+ # (group, unit, connection, node) as the order key.
798
+ entity_file_order: list[str] = []
799
+ for cls in _DISPATCH_ENTITY_CLASSES:
800
+ entity_file_order.extend(template_label_order(template, entity_class=cls))
801
+ # Order index, first-occurrence-wins (``setdefault``) so it agrees with
802
+ # ``_lookup_entity_color``'s first-match-wins class precedence
803
+ # (``_DISPATCH_ENTITY_CLASSES`` has flowGroup first). Keep BOTH an
804
+ # exact-case and a lowercase map: the exact map is consulted first so a
805
+ # flowGroup ``Wind`` and a unit ``wind`` keep DISTINCT positions — a
806
+ # nodeGroup's ``Wind`` aggregate band then tracks the flowGroup order and a
807
+ # node's ``wind_out`` band tracks the unit order, instead of one silently
808
+ # overwriting the other in a flat case-folded, last-wins index (which
809
+ # pinned flowGroup bands to an unrelated unit/node slot). The lowercase
810
+ # map preserves case-insensitive tolerance as a fallback.
811
+ entity_order_index: dict[str, int] = {}
812
+ entity_order_index_lc: dict[str, int] = {}
813
+ for i, k in enumerate(entity_file_order):
814
+ entity_order_index.setdefault(str(k), i)
815
+ entity_order_index_lc.setdefault(str(k).lower(), i)
816
+
817
+ def _entity_order_pos(name: str) -> int:
818
+ if name in entity_order_index:
819
+ return entity_order_index[name]
820
+ return entity_order_index_lc.get(
821
+ name.lower(), len(entity_file_order),
822
+ )
823
+
824
+ entity_cols: list[str] = []
825
+ for col in cols:
826
+ if col in special:
827
+ color = special.get(col)
828
+ if color is not None:
829
+ colors[col] = color
830
+ continue
831
+ name = _extract_dispatch_entity_name(col)
832
+ if name is None:
833
+ continue
834
+ pos_color = _lookup_entity_color(entities, name, negative=False)
835
+ if pos_color is None:
836
+ continue
837
+ neg_color = _lookup_entity_color(entities, name, negative=True)
838
+ is_neg_part = isinstance(col, str) and col.endswith("_neg")
839
+ # The column may itself already be the negative-side split part
840
+ # (``<col>_neg``) — color it with ``neg_color``; otherwise it is the
841
+ # positive / whole column → ``color``. ``get_color_for_column`` does
842
+ # an exact-key lookup, so we register the precise rendered name.
843
+ if is_neg_part and neg_color is not None and neg_color != pos_color:
844
+ colors[col] = neg_color
845
+ else:
846
+ colors[col] = pos_color
847
+ # A whole (not-yet-split) mixed column is split downstream into
848
+ # ``<col>_pos`` / ``<col>_neg``; register the ``<col>_neg`` key so
849
+ # its negative part picks up a distinct ``neg_color`` when set.
850
+ if (
851
+ not is_neg_part
852
+ and not col.endswith("_pos")
853
+ and neg_color is not None
854
+ and neg_color != pos_color
855
+ ):
856
+ colors[f"{col}_neg"] = neg_color
857
+ entity_cols.append(col)
858
+
859
+ # --- Order (top → bottom; reversed at the end for matplotlib) ---
860
+ # Only entity columns drive config_order, in template file order.
861
+ # Special tokens are left out so their fixed pos/neg placement (and the
862
+ # historical std-dev ordering of unlisted columns) is preserved.
863
+ ordered_entities = sorted(
864
+ entity_cols,
865
+ key=lambda c: _entity_order_pos(str(_extract_dispatch_entity_name(c))),
866
+ )
867
+
868
+ config_order = list(ordered_entities)
869
+ config_order.reverse()
870
+
871
+ return colors, config_order
872
+
873
+
874
+ def _clear_cache() -> None:
875
+ """Clear the module-level cache (test hook)."""
876
+ _TEMPLATE_CACHE.clear()
877
+
878
+
879
+ __all__ = [
880
+ "load_color_template",
881
+ "resolve_plot_settings_path",
882
+ "resolve_label_color",
883
+ "template_label_order",
884
+ "order_labels_by_template",
885
+ "resolve_dispatch_colors_and_order",
886
+ "template_entity_names",
887
+ "_clear_cache",
888
+ ]