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,691 @@
1
+ """Dispatch area plots: render and save stacked-area dispatch plots for nodeGroups and nodes."""
2
+
3
+ import pandas as pd
4
+ from pathlib import Path
5
+ import matplotlib
6
+ import matplotlib.pyplot as plt
7
+ from matplotlib.figure import Figure
8
+ import numpy as np
9
+
10
+ from flextool.scenario_comparison.constants import DEFAULT_SPECIAL_COLORS
11
+ from flextool.scenario_comparison.data_models import TimeSeriesResults, DispatchMappings
12
+ from flextool.scenario_comparison.dispatch_data import (
13
+ prepare_dispatch_data,
14
+ prepare_node_dispatch_data,
15
+ )
16
+
17
+
18
+ def _auto_assign_node_colors_with_existing(
19
+ columns, existing: dict[str, str | None],
20
+ ) -> dict[str, str]:
21
+ """Auto-assign tab20 colors for columns not already in *existing*."""
22
+ cmap = plt.cm.tab20(np.linspace(0, 1, 20))
23
+ colors = dict(existing)
24
+ # Also include special colors as defaults
25
+ for col, color in DEFAULT_SPECIAL_COLORS.items():
26
+ if col not in colors:
27
+ colors[col] = color
28
+ color_idx = 0
29
+ for col in columns:
30
+ col_str = str(col)
31
+ if col_str in colors and colors[col_str] is not None:
32
+ continue
33
+ # Check base name (_in/_out suffix)
34
+ base = col_str
35
+ if col_str.endswith('_in'):
36
+ base = col_str[:-3]
37
+ elif col_str.endswith('_out'):
38
+ base = col_str[:-4]
39
+ if base in colors and colors[base] is not None:
40
+ colors[col_str] = colors[base]
41
+ continue
42
+ colors[col_str] = matplotlib.colors.rgb2hex(cmap[color_idx % 20])
43
+ color_idx += 1
44
+ return colors
45
+
46
+
47
+ def get_color_for_column(col: str, colors_dict: dict[str, str | None]) -> str:
48
+ """Get color for a column, handling _in/_out suffixes."""
49
+ # Direct lookup
50
+ if col in colors_dict and colors_dict[col] is not None:
51
+ return colors_dict[col]
52
+ # Try base name without _in/_out suffix
53
+ if col.endswith('_in') or col.endswith('_out'):
54
+ base_name = col[:-3] if col.endswith('_in') else col[:-4]
55
+ if base_name in colors_dict and colors_dict[base_name] is not None:
56
+ return colors_dict[base_name]
57
+ # Default color
58
+ return 'lightgray'
59
+
60
+
61
+ def _compute_ylim(
62
+ df: pd.DataFrame,
63
+ timeline: tuple[int, int],
64
+ inflow: pd.Series | None = None,
65
+ ) -> tuple[float, float]:
66
+ """Compute y-axis range from a dispatch DataFrame, including line overlays.
67
+
68
+ Accounts for the stacked area, the Curtailed dashed line, and the
69
+ Demand (inflow) solid line so that nothing is clipped.
70
+ """
71
+ df_slice = df.iloc[timeline[0]:timeline[1]]
72
+ plot_cols = [col for col in df.columns if col != 'Curtailed']
73
+ area_slice = df_slice[plot_cols]
74
+ pos_max = area_slice.clip(lower=0).sum(axis=1).max()
75
+ neg_min = area_slice.clip(upper=0).sum(axis=1).min()
76
+
77
+ # Include Curtailed line
78
+ if 'Curtailed' in df.columns:
79
+ curtailed_max = df_slice['Curtailed'].max()
80
+ curtailed_min = df_slice['Curtailed'].min()
81
+ pos_max = max(pos_max, curtailed_max)
82
+ neg_min = min(neg_min, curtailed_min)
83
+
84
+ # Include Demand (inflow) line
85
+ if inflow is not None and not inflow.empty:
86
+ inflow_slice = inflow.iloc[timeline[0]:timeline[1]]
87
+ pos_max = max(pos_max, inflow_slice.max())
88
+ neg_min = min(neg_min, inflow_slice.min())
89
+
90
+ # Guard against a degenerate range. An empty/all-zero/flat dispatch
91
+ # window yields neg_min == pos_max (or NaN from a max()/min() over no
92
+ # rows), which makes ax.set_ylim() emit the "identical low and high
93
+ # ylims" warning and collapse the axis. Fall back to a small
94
+ # non-degenerate band centred on the value so the (empty) plot still
95
+ # renders cleanly.
96
+ if not (np.isfinite(neg_min) and np.isfinite(pos_max)):
97
+ neg_min, pos_max = 0.0, 0.0
98
+ if pos_max - neg_min < 1e-9:
99
+ center = pos_max
100
+ pad = abs(center) * 0.05 if center != 0.0 else 1.0
101
+ neg_min, pos_max = center - pad, center + pad
102
+
103
+ return (float(neg_min), float(pos_max))
104
+
105
+
106
+ def _build_dispatch_figure(
107
+ df_dispatch: pd.DataFrame,
108
+ inflow_series: pd.Series | None,
109
+ title: str,
110
+ ylabel: str = "MWh/h",
111
+ colors: dict | None = None,
112
+ timeline: tuple[int, int] = (0, 168),
113
+ ylim: tuple[float, float] | None = None,
114
+ break_times: set[str] | None = None,
115
+ ) -> plt.Figure | None:
116
+ """Build a dispatch stacked-area Figure and return it (without saving or closing).
117
+
118
+ Returns None if there's nothing to plot.
119
+ """
120
+ from flextool.plot_outputs.format_helpers import insert_timeline_breaks
121
+
122
+ if colors is None:
123
+ colors = {}
124
+
125
+ # Auto-assign colors for columns not already in the colors dict
126
+ colors = _auto_assign_node_colors_with_existing(df_dispatch.columns, colors)
127
+
128
+ # Get plot colors for columns (excluding 'Curtailed' which is plotted as line)
129
+ plot_cols = [col for col in df_dispatch.columns if col != 'Curtailed']
130
+ plot_colors = [get_color_for_column(col, colors) for col in plot_cols]
131
+
132
+ # Slice to timeline
133
+ df_plot = df_dispatch.iloc[timeline[0]:timeline[1]]
134
+
135
+ # Insert NaN rows at timeline breaks for visual gaps
136
+ if break_times:
137
+ df_plot = insert_timeline_breaks(df_plot, break_times)
138
+ if inflow_series is not None:
139
+ inflow_series = insert_timeline_breaks(
140
+ inflow_series.iloc[timeline[0]:timeline[1]].to_frame(), break_times
141
+ ).iloc[:, 0]
142
+
143
+ # Check if there's anything to plot (area, curtailed, or demand)
144
+ has_area = bool(plot_cols) and not (df_plot[plot_cols].select_dtypes(include='number').abs() < 1e-6).all().all()
145
+ has_curtailed = 'Curtailed' in df_dispatch.columns and (df_plot['Curtailed'].abs() > 1e-6).any()
146
+ has_demand = inflow_series is not None and not inflow_series.empty
147
+ if not has_area and not has_curtailed and not has_demand:
148
+ return None
149
+
150
+ # Estimate legend width to size figure appropriately
151
+ all_labels = list(plot_cols)
152
+ if has_curtailed:
153
+ all_labels.append('Curtailed')
154
+ if has_demand:
155
+ all_labels.append('Demand')
156
+ max_label_len = max((len(str(label)) for label in all_labels), default=0)
157
+ legend_width_in = max(1.5, max_label_len * 0.08 + 0.6)
158
+
159
+ plot_width = 10
160
+ fig_width = plot_width + legend_width_in + 0.3
161
+ fig = Figure(figsize=(fig_width, 4))
162
+
163
+ # Position axes to leave room for legend on the right
164
+ left_margin = 0.08
165
+ right_margin = (legend_width_in + 0.2) / fig_width
166
+ ax = fig.add_axes([left_margin, 0.15, 1.0 - left_margin - right_margin, 0.75])
167
+
168
+ # Plot area chart (NaN rows create visual gaps at timeline breaks)
169
+ if has_area:
170
+ df_plot[plot_cols].plot.area(
171
+ ax=ax,
172
+ stacked=True,
173
+ linewidth=0,
174
+ color=plot_colors,
175
+ legend=False
176
+ )
177
+
178
+ # Use integer x-positions for line overlays so NaN gap rows create visible gaps
179
+ # (matching the area chart's internal integer positioning)
180
+ x_positions = np.arange(len(df_plot))
181
+
182
+ # Plot curtailed as dashed line
183
+ if has_curtailed:
184
+ curtailed = df_plot['Curtailed']
185
+ ax.plot(x_positions, curtailed.values, linestyle='--', color='red', linewidth=1, label='Curtailed')
186
+
187
+ # Plot demand line
188
+ if has_demand:
189
+ if break_times:
190
+ inflow_plot = inflow_series
191
+ else:
192
+ inflow_plot = inflow_series.iloc[timeline[0]:timeline[1]]
193
+ ax.plot(np.arange(len(inflow_plot)), inflow_plot.values, linestyle='solid', color='black', linewidth=1.5, label='Demand')
194
+
195
+ ax.axhline(y=0, color='black', linestyle=':', linewidth=0.5)
196
+
197
+ ax.set_ylabel(ylabel)
198
+ ax.set_title(title)
199
+
200
+ if ylim:
201
+ ax.set_ylim(ylim)
202
+
203
+ # Legend — read top-to-bottom to match the VISUAL stack. The area draw
204
+ # order is neg-block (stacks down, first = nearest axis = top of the
205
+ # negative area) then pos-block (stacks up, first = nearest axis = bottom
206
+ # of the positive area). A blanket reversal makes positives read
207
+ # top-to-bottom but leaves negatives upside-down, so build the order per
208
+ # sign: positives reversed (top-most positive first), negatives as-is
209
+ # (top-most negative first), with the line overlays (Demand / Curtailed) on
210
+ # top.
211
+ handles, labels = ax.get_legend_handles_labels()
212
+ line_labels = {'Curtailed', 'Demand'}
213
+ pos_h, neg_h, line_h = [], [], []
214
+ for h, lbl in zip(handles, labels):
215
+ if lbl in line_labels or lbl not in df_plot.columns:
216
+ line_h.append((h, lbl))
217
+ elif (df_plot[lbl].fillna(0) < 0).any():
218
+ neg_h.append((h, lbl))
219
+ else:
220
+ pos_h.append((h, lbl))
221
+ ordered = line_h + pos_h[::-1] + neg_h
222
+ ax.legend(
223
+ [h for h, _ in ordered], [lbl for _, lbl in ordered],
224
+ bbox_to_anchor=(1.05, 1), loc='upper left',
225
+ )
226
+
227
+ return fig
228
+
229
+
230
+ def plot_dispatch_area(
231
+ df_dispatch: pd.DataFrame,
232
+ inflow_series: pd.Series | None,
233
+ output_path: str | Path,
234
+ title: str,
235
+ ylabel: str = "MWh/h",
236
+ colors: dict | None = None,
237
+ timeline: tuple[int, int] = (0, 168),
238
+ show_plot: bool = False,
239
+ ylim: tuple[float, float] | None = None,
240
+ break_times: set[str] | None = None,
241
+ ) -> None:
242
+ """Create a stacked area dispatch plot with demand line."""
243
+ fig = _build_dispatch_figure(
244
+ df_dispatch, inflow_series, title,
245
+ ylabel=ylabel, colors=colors, timeline=timeline,
246
+ ylim=ylim, break_times=break_times,
247
+ )
248
+ if fig is None:
249
+ return
250
+
251
+ # Save (bbox_inches='tight' in savefig handles layout)
252
+ output_path = Path(output_path)
253
+ output_path.parent.mkdir(parents=True, exist_ok=True)
254
+ fig.savefig(output_path, format='png', bbox_inches='tight', dpi=150)
255
+
256
+ if show_plot:
257
+ plt.show()
258
+ plt.close(fig)
259
+
260
+
261
+ def compute_dispatch_metadata_for_scenario(
262
+ results: TimeSeriesResults,
263
+ mappings: DispatchMappings,
264
+ scenario: str,
265
+ timeline: tuple[int, int] = (0, 168),
266
+ ) -> dict:
267
+ """Compute dispatch ylims + column order for ONE scenario.
268
+
269
+ Returns a dict suitable for JSON serialization::
270
+
271
+ {
272
+ "nodeGroups": {
273
+ "GroupName": {
274
+ "ylim": [ymin, ymax],
275
+ "columns": ["col1", "col2", ...]
276
+ },
277
+ ...
278
+ }
279
+ }
280
+
281
+ No margin is applied here; downstream union code adds it. Call sites
282
+ persist this to ``<project>/output_parquet/<scenario>/_dispatch_metadata.json``.
283
+ """
284
+ dispatch_groups_df = mappings.dispatch_groups
285
+ node_groups: list[str] = []
286
+ if dispatch_groups_df is not None and not dispatch_groups_df.empty:
287
+ node_groups = list(dispatch_groups_df['group'].unique())
288
+
289
+ meta: dict = {"nodeGroups": {}}
290
+ for ng in node_groups:
291
+ df_dispatch, inflow = prepare_dispatch_data(
292
+ results, mappings, scenario, ng,
293
+ )
294
+ if df_dispatch is not None and not df_dispatch.empty:
295
+ ymin, ymax = _compute_ylim(df_dispatch, timeline, inflow)
296
+ meta["nodeGroups"][ng] = {
297
+ "ylim": [float(ymin), float(ymax)],
298
+ "columns": [str(c) for c in df_dispatch.columns],
299
+ }
300
+ return meta
301
+
302
+
303
+ def _fold_per_scenario_metadata(
304
+ per_scen_meta: dict,
305
+ ng_ylims: dict[str, tuple[float, float]],
306
+ ng_columns: dict[str, list[str]],
307
+ ) -> None:
308
+ """Fold a per-scenario metadata dict into running ylims/columns dicts.
309
+
310
+ Mutates *ng_ylims* and *ng_columns* in place with the union (min/max
311
+ ylim, columns extended in first-seen order). No margin is applied
312
+ here — callers add the margin once after folding all scenarios.
313
+ """
314
+ for ng, entry in per_scen_meta.get("nodeGroups", {}).items():
315
+ ylim = entry.get("ylim")
316
+ columns = entry.get("columns") or []
317
+ if not ylim or len(ylim) < 2:
318
+ continue
319
+ ymin, ymax = float(ylim[0]), float(ylim[1])
320
+ if ng in ng_ylims:
321
+ ng_ylims[ng] = (
322
+ min(ng_ylims[ng][0], ymin),
323
+ max(ng_ylims[ng][1], ymax),
324
+ )
325
+ for col in columns:
326
+ if col not in ng_columns[ng]:
327
+ ng_columns[ng].append(col)
328
+ else:
329
+ ng_ylims[ng] = (ymin, ymax)
330
+ ng_columns[ng] = list(columns)
331
+
332
+
333
+ def compute_dispatch_metadata(
334
+ results: TimeSeriesResults,
335
+ mappings: DispatchMappings,
336
+ scenarios: list[str],
337
+ timeline: tuple[int, int] = (0, 168),
338
+ ) -> dict:
339
+ """Compute cross-scenario ylims and column order per nodeGroup.
340
+
341
+ Returns a dict suitable for JSON serialization::
342
+
343
+ {
344
+ "nodeGroups": {
345
+ "GroupName": {
346
+ "ylim": [ymin, ymax],
347
+ "columns": ["col1", "col2", ...]
348
+ },
349
+ ...
350
+ }
351
+ }
352
+ """
353
+ ng_ylims: dict[str, tuple[float, float]] = {}
354
+ ng_columns: dict[str, list[str]] = {}
355
+
356
+ for scenario in scenarios:
357
+ per_scen = compute_dispatch_metadata_for_scenario(
358
+ results, mappings, scenario, timeline,
359
+ )
360
+ _fold_per_scenario_metadata(per_scen, ng_ylims, ng_columns)
361
+
362
+ # Add margin
363
+ for key, (ymin, ymax) in ng_ylims.items():
364
+ margin = (ymax - ymin) * 0.05
365
+ ng_ylims[key] = (ymin - margin, ymax + margin)
366
+
367
+ meta: dict = {"nodeGroups": {}}
368
+ for ng, ylim in ng_ylims.items():
369
+ meta["nodeGroups"][ng] = {
370
+ "ylim": list(ylim),
371
+ "columns": ng_columns.get(ng, []),
372
+ }
373
+ return meta
374
+
375
+
376
+ def union_dispatch_metadata(
377
+ project_path: Path,
378
+ scenarios: list[str],
379
+ margin_fraction: float = 0.05,
380
+ ) -> dict:
381
+ """Union per-scenario _dispatch_metadata.json files into a single
382
+ cross-scenario manifest.
383
+
384
+ Reads ``<project>/output_parquet/<scenario>/_dispatch_metadata.json``
385
+ for each scenario in *scenarios*; missing files contribute nothing
386
+ (silently skipped, like Phase C's availability union).
387
+
388
+ Returns a dict matching the same schema as ``compute_dispatch_metadata``
389
+ (with the 5% ylim margin applied at the union level), or
390
+ ``{"nodeGroups": {}}`` if no per-scenario files exist.
391
+ """
392
+ import json as _json
393
+
394
+ project_path = Path(project_path)
395
+ ng_ylims: dict[str, tuple[float, float]] = {}
396
+ ng_columns: dict[str, list[str]] = {}
397
+
398
+ for scenario in scenarios:
399
+ meta_path = (
400
+ project_path / "output_parquet" / scenario / "_dispatch_metadata.json"
401
+ )
402
+ if not meta_path.is_file():
403
+ continue
404
+ try:
405
+ with open(meta_path, "r", encoding="utf-8") as f:
406
+ per_scen = _json.load(f)
407
+ except (OSError, _json.JSONDecodeError):
408
+ continue
409
+ if not isinstance(per_scen, dict):
410
+ continue
411
+ _fold_per_scenario_metadata(per_scen, ng_ylims, ng_columns)
412
+
413
+ # Add margin at the union level
414
+ for key, (ymin, ymax) in ng_ylims.items():
415
+ margin = (ymax - ymin) * margin_fraction
416
+ ng_ylims[key] = (ymin - margin, ymax + margin)
417
+
418
+ meta: dict = {"nodeGroups": {}}
419
+ for ng, ylim in ng_ylims.items():
420
+ meta["nodeGroups"][ng] = {
421
+ "ylim": list(ylim),
422
+ "columns": ng_columns.get(ng, []),
423
+ }
424
+ return meta
425
+
426
+
427
+ def create_dispatch_plots(
428
+ results: TimeSeriesResults,
429
+ mappings: DispatchMappings,
430
+ plot_dir: str | Path,
431
+ scenarios: list[str],
432
+ show_plot: bool = False,
433
+ write_xlsx: bool = False,
434
+ plot_rows: list[int] | tuple[int, int] | None = None,
435
+ break_times: set[str] | None = None,
436
+ debug: bool = False,
437
+ settings_path: str | Path | None = None,
438
+ ) -> None:
439
+ """Create dispatch plots for all nodeGroups (and, in debug, all nodes).
440
+
441
+ nodeGroup dispatch plots are always generated. Per-node dispatch plots
442
+ are generated for ALL nodes that have dispatch data (discovered from the
443
+ results, never curated) ONLY when *debug* is true; otherwise no node
444
+ plots are produced. There is no persisted node selection.
445
+
446
+ *scenarios* is the run's scenario set (the data-derived dispatch tags),
447
+ passed in by the caller; there is no longer a ``config.yaml`` fallback.
448
+
449
+ *settings_path* is the already-resolved ``plot_settings.yaml`` path (the
450
+ orchestrator resolves it against the derived project root). When omitted
451
+ it is resolved from ``plot_dir.parent`` — the legacy assumption that the
452
+ comparison output dir lives directly under the project root.
453
+ """
454
+ plot_dir = Path(plot_dir)
455
+
456
+ # Dispatch colors + stacking order now come from the project's
457
+ # ``plot_settings.yaml`` (``entities`` for per-entity flows, new
458
+ # ``categories.dispatch`` block for the special tokens), resolved by
459
+ # ``resolve_dispatch_colors_and_order``. This replaces the legacy
460
+ # ``config['positive'|'negative']`` parsing. The template path is the
461
+ # caller-supplied *settings_path* (resolved against the derived project
462
+ # root) or, as a fallback, resolved from the parent of *plot_dir*.
463
+ #
464
+ # ``colors`` / ``config_order`` keep the same shape as before so the
465
+ # downstream ``prepare_dispatch_data`` → ``_order_dispatch_columns`` →
466
+ # render interface is unchanged. ``colors`` is resolved once the union
467
+ # of actual dispatch columns is known (after the first / ylim pass);
468
+ # special-token colors and the entity ordering do not depend on the
469
+ # data, only the per-column color resolution does.
470
+ from flextool.plot_outputs.color_template import (
471
+ load_color_template,
472
+ resolve_dispatch_colors_and_order,
473
+ resolve_plot_settings_path,
474
+ template_entity_names,
475
+ )
476
+
477
+ if settings_path is None:
478
+ settings_path = resolve_plot_settings_path(plot_dir.parent)
479
+ template = load_color_template(settings_path)
480
+
481
+ # ``config_order`` (entity stacking order) depends only on the template's
482
+ # entity file order, not on the data, so resolve it once up front and use
483
+ # it identically in the ylim pass and the plot pass. ``colors`` needs the
484
+ # actual column names (composite / node-level columns resolve to an entity
485
+ # name), so it is resolved after the union of columns is known (below).
486
+ # Resolving config_order from the template entity names keeps the two
487
+ # passes byte-consistent; per-column colors are filled in afterwards.
488
+ _, config_order = resolve_dispatch_colors_and_order(
489
+ template, template_entity_names(template),
490
+ )
491
+ colors: dict[str, str] = {}
492
+
493
+ # Time window comes from ``plot_rows`` (GUI/CLI, derived from
494
+ # ``PlotSettings.start_time``/``duration``); a sane default covers the
495
+ # rare path where the caller omits it (the old ``config.yaml``
496
+ # ``time_to_plot`` source is gone).
497
+ if plot_rows and len(plot_rows) >= 2:
498
+ timeline = (int(plot_rows[0]), int(plot_rows[1]) + 1) # plot_rows is inclusive
499
+ else:
500
+ timeline = (0, 168)
501
+
502
+ # Get nodeGroups from data (dispatch_groups mapping), not config
503
+ dispatch_groups_df = mappings.dispatch_groups
504
+ node_groups: list[str] = []
505
+ if dispatch_groups_df is not None and not dispatch_groups_df.empty:
506
+ node_groups = list(dispatch_groups_df['group'].unique())
507
+
508
+ excel_data: dict[str, pd.DataFrame] = {}
509
+
510
+ # First pass: collect y-axis ranges + column order. ylims are keyed
511
+ # PER (scenario, group/node) so each plot scales to its own scenario's
512
+ # dispatch — unioning ylims across scenarios of very different magnitude
513
+ # (e.g. a represented-period solve vs an 8760 h trade-only snapshot
514
+ # ~1000x larger) would force the smaller scenario onto the larger one's
515
+ # axis and flatten it. Columns stay keyed by group/node (unioned across
516
+ # scenarios) so the legend / stacking order is consistent between plots.
517
+ ng_ylims: dict[tuple[str, str], tuple[float, float]] = {}
518
+ node_ylims: dict[tuple[str, str], tuple[float, float]] = {}
519
+ ng_columns: dict[str, list[str]] = {}
520
+ node_columns: dict[str, list[str]] = {}
521
+ # No node curation: ``config['nodes']`` is no longer consumed. Per-node
522
+ # dispatch is a debug-only convenience covering ALL nodes that have
523
+ # dispatch data (discovered from the results), and is skipped entirely
524
+ # when ``debug`` is false.
525
+ from flextool.scenario_comparison.dispatch_data import (
526
+ available_dispatch_nodes,
527
+ )
528
+ nodes = available_dispatch_nodes(results) if debug else []
529
+
530
+ for scenario in scenarios:
531
+ for ng in node_groups:
532
+ df_dispatch, inflow = prepare_dispatch_data(
533
+ results, mappings, scenario, ng,
534
+ colors=colors, config_order=config_order,
535
+ )
536
+ if df_dispatch is not None and not df_dispatch.empty:
537
+ ymin, ymax = _compute_ylim(df_dispatch, timeline, inflow)
538
+ # ylim: per (scenario, group) — each plot scales to itself.
539
+ ng_ylims[(scenario, ng)] = (ymin, ymax)
540
+ # columns: unioned per group for consistent legend/stacking.
541
+ if ng in ng_columns:
542
+ for col in df_dispatch.columns:
543
+ if col not in ng_columns[ng]:
544
+ ng_columns[ng].append(col)
545
+ else:
546
+ ng_columns[ng] = list(df_dispatch.columns)
547
+
548
+ for node in nodes:
549
+ df_node, inflow_node = prepare_node_dispatch_data(
550
+ results, scenario, node
551
+ )
552
+ if df_node is not None and not df_node.empty:
553
+ ymin, ymax = _compute_ylim(df_node, timeline, inflow_node)
554
+ node_ylims[(scenario, node)] = (ymin, ymax)
555
+ if node in node_columns:
556
+ for col in df_node.columns:
557
+ if col not in node_columns[node]:
558
+ node_columns[node].append(col)
559
+ else:
560
+ node_columns[node] = list(df_node.columns)
561
+
562
+ # Add small margin to y-axis limits
563
+ for key, (ymin, ymax) in ng_ylims.items():
564
+ margin = (ymax - ymin) * 0.05
565
+ ng_ylims[key] = (ymin - margin, ymax + margin)
566
+ for key, (ymin, ymax) in node_ylims.items():
567
+ margin = (ymax - ymin) * 0.05
568
+ node_ylims[key] = (ymin - margin, ymax + margin)
569
+
570
+ # Resolve per-column colors now that the union of dispatch columns
571
+ # (nodeGroup + node, base + split parts) is known. Special tokens map to
572
+ # ``categories.dispatch`` (== the old DEFAULT_SPECIAL_COLORS); entity /
573
+ # composite / node-level columns map to ``entities``; anything unresolved
574
+ # is left to ``_auto_assign_node_colors_with_existing``'s palette below.
575
+ union_columns: list[str] = []
576
+ seen_cols: set[str] = set()
577
+ for col_list in list(ng_columns.values()) + list(node_columns.values()):
578
+ for col in col_list:
579
+ scol = str(col)
580
+ if scol not in seen_cols:
581
+ seen_cols.add(scol)
582
+ union_columns.append(scol)
583
+ resolved_colors, _ = resolve_dispatch_colors_and_order(template, union_columns)
584
+ colors = resolved_colors
585
+
586
+ for scenario in scenarios:
587
+ print(f"Creating dispatch plots for scenario: {scenario}")
588
+
589
+ # Plot nodeGroup dispatches
590
+ for ng in node_groups:
591
+ df_dispatch, inflow = prepare_dispatch_data(
592
+ results, mappings, scenario, ng,
593
+ colors=colors, config_order=config_order,
594
+ )
595
+
596
+ has_dispatch = df_dispatch is not None and not df_dispatch.empty
597
+ has_demand = inflow is not None and not inflow.empty
598
+ if has_dispatch or has_demand:
599
+ if df_dispatch is None or df_dispatch.empty:
600
+ # Create a minimal DataFrame so plot_dispatch_area can
601
+ # draw the demand line even when there are no supply flows.
602
+ idx = inflow.index if has_demand else pd.RangeIndex(1)
603
+ df_dispatch = pd.DataFrame(index=idx)
604
+ # Ensure consistent columns across scenarios for same nodeGroup.
605
+ # Build the missing columns in one DataFrame and concat once;
606
+ # the per-column ``df_dispatch[col] = 0.0`` loop triggered a
607
+ # pandas PerformanceWarning about frame fragmentation when
608
+ # ng_columns[ng] had many entries.
609
+ if ng in ng_columns:
610
+ missing = [
611
+ c for c in ng_columns[ng]
612
+ if c not in df_dispatch.columns
613
+ ]
614
+ if missing:
615
+ zeros = pd.DataFrame(
616
+ 0.0, index=df_dispatch.index, columns=missing,
617
+ )
618
+ df_dispatch = pd.concat(
619
+ [df_dispatch, zeros], axis=1,
620
+ )
621
+ df_dispatch = df_dispatch[ng_columns[ng]]
622
+ output_path = plot_dir / f"dispatch_nodeGroup_{ng}_{scenario}.png"
623
+ plot_dispatch_area(
624
+ df_dispatch, inflow, output_path,
625
+ title=f"{ng} - {scenario}",
626
+ colors=colors,
627
+ timeline=timeline,
628
+ show_plot=show_plot,
629
+ ylim=ng_ylims.get((scenario, ng)),
630
+ break_times=break_times,
631
+ )
632
+
633
+ if write_xlsx:
634
+ excel_data[f"{ng}_{scenario}"] = df_dispatch
635
+ else:
636
+ print(f" No dispatch data for nodeGroup {ng}")
637
+
638
+ # Plot individual node dispatches
639
+ for node in nodes:
640
+ df_node, inflow_node = prepare_node_dispatch_data(
641
+ results, scenario, node
642
+ )
643
+ has_node_dispatch = df_node is not None and not df_node.empty
644
+ has_node_demand = inflow_node is not None and not inflow_node.empty
645
+ if has_node_dispatch or has_node_demand:
646
+ if df_node is None or df_node.empty:
647
+ idx = inflow_node.index if has_node_demand else pd.RangeIndex(1)
648
+ df_node = pd.DataFrame(index=idx)
649
+ # Ensure consistent columns across scenarios for same node.
650
+ # Same fragmentation fix as the nodeGroup branch above —
651
+ # build missing zero-columns in one DataFrame + concat once
652
+ # instead of per-column ``df_node[col] = 0.0`` assignment.
653
+ if node in node_columns:
654
+ missing = [
655
+ c for c in node_columns[node]
656
+ if c not in df_node.columns
657
+ ]
658
+ if missing:
659
+ zeros = pd.DataFrame(
660
+ 0.0, index=df_node.index, columns=missing,
661
+ )
662
+ df_node = pd.concat([df_node, zeros], axis=1)
663
+ df_node = df_node[node_columns[node]]
664
+ # Seed with template-resolved colors (entity / special
665
+ # tokens), then auto-assign palette colors for the rest.
666
+ node_colors = _auto_assign_node_colors_with_existing(
667
+ df_node.columns, colors,
668
+ )
669
+ output_path = plot_dir / f"dispatch_node_{node}_{scenario}.png"
670
+ plot_dispatch_area(
671
+ df_node, inflow_node, output_path,
672
+ title=f"{node} - {scenario}",
673
+ colors=node_colors,
674
+ timeline=timeline,
675
+ show_plot=show_plot,
676
+ ylim=node_ylims.get((scenario, node)),
677
+ break_times=break_times,
678
+ )
679
+ if write_xlsx:
680
+ excel_data[f"node_{node}_{scenario}"] = df_node
681
+ else:
682
+ print(f" No dispatch data for node {node}")
683
+
684
+ # Write Excel file
685
+ if write_xlsx and excel_data:
686
+ excel_path = plot_dir / "dispatch_data.xlsx"
687
+ with pd.ExcelWriter(excel_path, engine='xlsxwriter') as writer:
688
+ for name, df in excel_data.items():
689
+ sheet_name = name[:31] # Excel sheet name limit
690
+ df.to_excel(writer, sheet_name=sheet_name)
691
+ print(f"Wrote dispatch data to {excel_path}")