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,475 @@
1
+ """Output functions for node group results."""
2
+ import pandas as pd
3
+
4
+ from flextool.process_outputs._annualize import annualize_dt_to_d
5
+
6
+
7
+ def _get_group_nodes_with_inflow(s, g: str) -> list[str]:
8
+ """Return nodes in group g that have an inflow method (not 'no_inflow')."""
9
+ group_nodes = (
10
+ s.group_node[s.group_node.get_level_values('group') == g]
11
+ .get_level_values('node')
12
+ .tolist()
13
+ )
14
+ if hasattr(s, 'node__inflow_method'):
15
+ no_inflow_nodes = {n for (n, method) in s.node__inflow_method if method == 'no_inflow'}
16
+ return [n for n in group_nodes if n not in no_inflow_nodes]
17
+ return group_nodes
18
+
19
+
20
+ def nodeGroup_indicators(par, s, v, r, debug):
21
+ """Node group indicator results by period and time"""
22
+
23
+ results = []
24
+
25
+ if not list(s.nodeGroupIndicators):
26
+ return results
27
+
28
+ # Get time steps
29
+ dt_index = s.dt_realize_dispatch # Should be MultiIndex with (period, time)
30
+
31
+ # Calculate timestep-level results first
32
+ results_dt = []
33
+
34
+ for g in s.nodeGroupIndicators:
35
+ group_nodes = (
36
+ s.group_node[s.group_node.get_level_values('group').isin([g])]
37
+ .get_level_values('node')
38
+ .tolist()
39
+ )
40
+ group_nodes_with_inflow = _get_group_nodes_with_inflow(s, g)
41
+
42
+ # 1. pdtNodeInflow (negative sum)
43
+ if group_nodes_with_inflow:
44
+ group_inflow = -par.node_inflow[group_nodes_with_inflow].sum(axis=1)
45
+ else:
46
+ group_inflow = pd.Series(0, index=dt_index)
47
+
48
+ # 2. Sum of annualized inflows [MWh]. Weight each (d, t) by
49
+ # par.timestep_weight (=1.0 with no/uniform timeset_weights →
50
+ # byte-identical) so the annualized inflow matches the cost-weighted
51
+ # objective. group_inflow itself stays unweighted: it feeds the
52
+ # intensive VRE/slack *shares* below where the weight would cancel
53
+ # anyway, and the raw '5. Timestep inflow' output.
54
+ annualized_inflow = group_inflow.mul(par.timestep_weight, axis=0).div(par.complete_period_share_of_year)
55
+
56
+ # Flow-derived quantities (r.flow_dt, potentialVREgen_dt) and q_state
57
+ # slacks are MW at the timestep. Multiply by step_duration so the
58
+ # stored timestep value is MWh/step — matching '5. Timestep inflow'
59
+ # (par.node_inflow is already MWh/step). This keeps all columns in
60
+ # consistent energy units so shares and period aggregations work for
61
+ # any step_duration.
62
+ step_dur = par.step_duration
63
+
64
+ # 3. VRE share (actual flow)
65
+ vre_processes = s.process_VRE.get_level_values('process')
66
+ vre_cols = r.flow_dt.columns[
67
+ r.flow_dt.columns.get_level_values('sink').isin(group_nodes) &
68
+ r.flow_dt.columns.get_level_values('process').isin(vre_processes) &
69
+ r.flow_dt.columns.isin(s.process_source_sink_alwaysProcess)
70
+ ]
71
+ vre_flow_sum = r.flow_dt[vre_cols].mul(step_dur, axis=0).sum(axis=1)
72
+
73
+ # VRE share calculation (avoid division by zero)
74
+ vre_share = vre_flow_sum / group_inflow.where(group_inflow != 0, pd.NA)
75
+
76
+ # 4. Curtailed VRE share
77
+ potential_cols = r.potentialVREgen_dt.columns[
78
+ r.potentialVREgen_dt.columns.get_level_values(1).isin(group_nodes) &
79
+ r.potentialVREgen_dt.columns.get_level_values(0).isin(vre_processes)
80
+ ]
81
+ potential_sum = r.potentialVREgen_dt[potential_cols].mul(step_dur, axis=0).sum(axis=1)
82
+ curtailed_vre = (potential_sum - vre_flow_sum).clip(lower=0)
83
+ curtailed_vre_share = curtailed_vre / group_inflow.where(group_inflow != 0, pd.NA)
84
+ curtailed_vre / potential_sum
85
+
86
+ # Filter balance nodes directly from the sets
87
+ balance_set = set(s.node_balance) | set(s.node_balance_period)
88
+ balance_nodes = [n for n in group_nodes if n in balance_set]
89
+
90
+ # 5. Upward slack (MWh/step)
91
+ if balance_nodes and not v.q_state_up.empty:
92
+ # node_capacity_for_scaling is complete over nodeBalance ∪
93
+ # nodeBalancePeriod (⊇ q_state nodes), so direct column indexing
94
+ # is safe.
95
+ upward_slack = v.q_state_up.mul(par.node_capacity_for_scaling[v.q_state_up.columns]).sum(axis=1).clip(lower=0)
96
+ upward_slack = upward_slack.mul(step_dur)
97
+ else:
98
+ upward_slack = pd.Series(0, index=dt_index)
99
+
100
+ # 6. Downward slack (MWh/step)
101
+ if balance_nodes and not v.q_state_down.empty:
102
+ downward_slack = v.q_state_down.mul(par.node_capacity_for_scaling[v.q_state_down.columns]).sum(axis=1).clip(lower=0)
103
+ downward_slack = downward_slack.mul(step_dur)
104
+ else:
105
+ downward_slack = pd.Series(0, index=dt_index)
106
+
107
+ # Multi-solve handoff: v.* / r.flow_dt span the full union of
108
+ # sub-solve dt axes, while ``dt_realize_dispatch`` is the
109
+ # post-roll "realized" subset. Reindex every component series
110
+ # to ``dt_index`` so the per-(period, time) DataFrame below
111
+ # never raises "All arrays must be of the same length".
112
+ upward_slack = upward_slack.reindex(dt_index, fill_value=0)
113
+ downward_slack = downward_slack.reindex(dt_index, fill_value=0)
114
+ vre_flow_sum = vre_flow_sum.reindex(dt_index, fill_value=0)
115
+ curtailed_vre = curtailed_vre.reindex(dt_index, fill_value=0)
116
+ curtailed_vre_share = curtailed_vre_share.reindex(dt_index, fill_value=0)
117
+ vre_share = vre_share.reindex(dt_index, fill_value=0)
118
+ group_inflow = group_inflow.reindex(dt_index, fill_value=0)
119
+ annualized_inflow = annualized_inflow.reindex(dt_index, fill_value=0)
120
+
121
+ # Combine timestep results for this group
122
+ group_result_dt = pd.DataFrame({
123
+ 'group': g,
124
+ 'period': dt_index.get_level_values('period'),
125
+ 'time': dt_index.get_level_values('time'),
126
+ '1. Loss of load': upward_slack.fillna(0).values,
127
+ '2. VRE generation': vre_flow_sum.values,
128
+ '3. Excess load': downward_slack.fillna(0).values,
129
+ '4. Curtailed VRE': curtailed_vre.values,
130
+ '5. Timestep inflow': group_inflow.values,
131
+ '6. Curtailed VRE of potential VRE': curtailed_vre_share.fillna(0).values,
132
+ '7. Annualized inflow': annualized_inflow.values,
133
+ '8. VRE share of demand': vre_share.fillna(0).values,
134
+ })
135
+ results_dt.append(group_result_dt)
136
+
137
+ # Combine all groups for timestep level
138
+ result_flat_dt = pd.concat(results_dt, ignore_index=True)
139
+
140
+ # Create multi-index version for timestep level
141
+ results_dt_indexed = result_flat_dt.set_index(['group', 'period', 'time'])[
142
+ ['1. Loss of load', '2. VRE generation', '3. Excess load', '4. Curtailed VRE',
143
+ '5. Timestep inflow', '6. Curtailed VRE of potential VRE', '7. Annualized inflow', '8. VRE share of demand']
144
+ ]
145
+ results_dt_indexed.columns.name = "parameter"
146
+
147
+ results.append((results_dt_indexed, 'nodeGroup_gdt_p'))
148
+
149
+ # Aggregate to period level
150
+ # For shares, we need to recalculate properly:
151
+ # Sum the numerators and denominators separately, then divide
152
+ inflow_d = results_dt_indexed['5. Timestep inflow'].groupby(level=['group', 'period']).sum()
153
+ annualized_inflow_d = results_dt_indexed['7. Annualized inflow'].groupby(level=['group', 'period']).sum()
154
+
155
+ # For VRE share: need to sum absolute flows and recalculate
156
+ # Since we have vre_share * inflow = vre_flow, we can recover vre_flow
157
+ vre_flow_dt = results_dt_indexed['8. VRE share of demand'] * results_dt_indexed['5. Timestep inflow']
158
+ vre_flow_d = vre_flow_dt.groupby(level=['group', 'period']).sum()
159
+ vre_share_d = vre_flow_d / inflow_d.where(inflow_d != 0, pd.NA)
160
+
161
+ # For curtailed VRE share: similar recovery
162
+ curtailed_vre_share_dt = results_dt_indexed['4. Curtailed VRE'] / results_dt_indexed['5. Timestep inflow']
163
+ curtailed_vre_share_d = curtailed_vre_share_dt.groupby(level=['group', 'period']).mean()
164
+ curtailed_vre_of_potential_vre_d = results_dt_indexed['6. Curtailed VRE of potential VRE'].groupby(level=['group', 'period']).sum()
165
+
166
+ # Slack shares: sum absolute values and recalculate
167
+ upward_slack_d = results_dt_indexed['1. Loss of load'].groupby(['group', 'period']).sum()
168
+ upward_slack_share_d = upward_slack_d / inflow_d
169
+
170
+ downward_slack_d = results_dt_indexed['3. Excess load'].groupby(['group', 'period']).sum()
171
+ downward_slack_share_d = downward_slack_d / inflow_d
172
+
173
+ # Combine period-level results
174
+ results_d_indexed = pd.DataFrame({
175
+ '1. Loss of load share': upward_slack_share_d.fillna(0),
176
+ '2. VRE share of demand': vre_share_d.fillna(0),
177
+ '3. Excess load share': downward_slack_share_d.fillna(0),
178
+ '4. Curtailed VRE of demand': curtailed_vre_share_d.fillna(0),
179
+ '5. Annualized inflow': annualized_inflow_d,
180
+ '6. Curtailed VRE of potential VRE': curtailed_vre_of_potential_vre_d.fillna(0)
181
+ })
182
+ results_d_indexed.columns.name = "parameter"
183
+
184
+ results.append((results_d_indexed, 'nodeGroup_gd_p'))
185
+
186
+ return results
187
+
188
+
189
+ def nodeGroup_VRE_share(par, s, v, r, debug):
190
+ """VRE share for node groups by period and time"""
191
+
192
+ results = []
193
+
194
+ # Get timesteps and groups
195
+ timesteps = list(s.dt_realize_dispatch)
196
+
197
+ # Filter groups that have nodes with inflow
198
+ groups_with_inflow = [
199
+ g for g in s.nodeGroupIndicators
200
+ if _get_group_nodes_with_inflow(s, g)
201
+ ]
202
+
203
+ if not groups_with_inflow or not timesteps:
204
+ index = pd.MultiIndex.from_tuples([], names=['period', 'time'])
205
+ results_dt = pd.DataFrame(
206
+ index=index,
207
+ columns=pd.Index(groups_with_inflow, name='group'),
208
+ )
209
+ return [(results_dt, 'nodeGroup_VRE_share_dt_g')]
210
+
211
+ # Create index
212
+ index = pd.MultiIndex.from_tuples(timesteps, names=['period', 'time'])
213
+ results_dt = pd.DataFrame(
214
+ index=index,
215
+ columns=pd.Index(groups_with_inflow, name='group'),
216
+ dtype=float,
217
+ )
218
+
219
+ # Get VRE processes
220
+ vre_processes = s.process_VRE.unique()
221
+
222
+ # Calculate for each group
223
+ for g in groups_with_inflow:
224
+ group_nodes = _get_group_nodes_with_inflow(s, g)
225
+ node_cols = [col for col in par.node_inflow.columns.get_level_values(0) if col in group_nodes]
226
+
227
+ # Total inflow to group nodes (vectorized)
228
+ total_inflow = par.node_inflow[node_cols].sum(axis=1)
229
+
230
+ # VRE flow: sum flows from VRE processes to group nodes (vectorized)
231
+ vre_cols = [(p, src, snk) for (p, src, snk) in r.flow_dt.columns
232
+ if p in vre_processes and (p, snk) in s.process_sink and snk in group_nodes]
233
+
234
+ if vre_cols:
235
+ # r.flow_dt is MW — multiply by step_duration to get MWh/step so
236
+ # the share against total_inflow (MWh/step) is a proper ratio.
237
+ vre_flow = r.flow_dt[vre_cols].mul(par.step_duration, axis=0).sum(axis=1)
238
+ else:
239
+ vre_flow = pd.Series(0.0, index=r.flow_dt.index)
240
+
241
+ # Calculate share (avoid division by zero)
242
+ results_dt[g] = (vre_flow / (-total_inflow)).fillna(0.0)
243
+
244
+ results.append((results_dt, 'nodeGroup_VRE_share_dt_g'))
245
+
246
+ # Return period results
247
+ results_d = results_dt.groupby(level='period').mean()
248
+ results_d.columns.name = 'group'
249
+ results.append((results_d, 'nodeGroup_VRE_share_d_g'))
250
+
251
+ return results
252
+
253
+
254
+ def nodeGroup_total_inflow(par, s, v, r, debug):
255
+ """Total inflow (par.node_inflow) summed over nodes in each group."""
256
+
257
+ results = []
258
+
259
+ # Use node-based group membership (same as nodeGroup_VRE_share)
260
+ groups = [
261
+ g for g in s.nodeGroupIndicators
262
+ if _get_group_nodes_with_inflow(s, g)
263
+ ]
264
+
265
+ if not groups:
266
+ return results
267
+
268
+ timesteps = list(s.dt_realize_dispatch)
269
+ if not timesteps:
270
+ return results
271
+
272
+ # Calculate timestep-level results
273
+ index = pd.MultiIndex.from_tuples(timesteps, names=['period', 'time'])
274
+ result_multi_dt = pd.DataFrame(index=index, columns=groups, dtype=float)
275
+
276
+ for g in groups:
277
+ group_nodes = _get_group_nodes_with_inflow(s, g)
278
+ node_cols = [col for col in par.node_inflow.columns.get_level_values(0)
279
+ if col in group_nodes]
280
+ if node_cols:
281
+ result_multi_dt[g] = par.node_inflow[node_cols].sum(axis=1)
282
+ else:
283
+ result_multi_dt[g] = 0.0
284
+
285
+ result_multi_dt.columns.name = 'group'
286
+ results.append((result_multi_dt, 'nodeGroup_flows_dt_g'))
287
+
288
+ # Aggregate to period level. Weight each (d, t) by par.timestep_weight
289
+ # (=1.0 with no/uniform timeset_weights → byte-identical) before the
290
+ # period sum. result_multi_dt is group inflow (already MWh/step), so no
291
+ # step_duration.
292
+ result_multi_d = annualize_dt_to_d(result_multi_dt, par.timestep_weight, par.complete_period_share_of_year)
293
+ result_multi_d.columns.name = 'group'
294
+ results.append((result_multi_d, 'nodeGroup_flows_d_g'))
295
+
296
+ return results
297
+
298
+
299
+ def nodeGroup_flows(par, s, v, r, debug):
300
+ """Group output flows for periods and time.
301
+
302
+ result_multi_dt combines sources that arrive in different units: slacks
303
+ and inflow are already MWh/step, but the v_flow-derived columns
304
+ (unit/connection flows, internal losses) are MW. We multiply the
305
+ latter by step_duration at assignment so every column is MWh/step at
306
+ the timestep level. The period-level output (nodeGroup_flows_d_gpe)
307
+ is then divided by complete_period_share_of_year so it reports
308
+ annualized MWh, matching node_d_ep and nodeGroup_flows_d_g.
309
+ """
310
+
311
+ results = []
312
+
313
+ if s.nodeGroupDispatch.empty or s.dt_realize_dispatch.empty:
314
+ return results
315
+
316
+ step_dur = par.step_duration
317
+
318
+ def _to_energy(df: pd.DataFrame) -> pd.DataFrame:
319
+ """Multiply a (period, time)-indexed MW frame by step_duration → MWh/step.
320
+
321
+ Guards against empty placeholder frames whose index lacks 'period'/'time'
322
+ level names (pandas' align-on-index would raise).
323
+ """
324
+ if df.empty:
325
+ return df
326
+ return df.mul(step_dur, axis=0)
327
+
328
+ # Calculate timestep-level results first
329
+ result_multi_dt = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_tuples([], names=['group', 'type', 'item']))
330
+
331
+ # Assign simple mappings for all groups at once (before group loop)
332
+ # Slack upward
333
+ r.group_node_up_slack__dt.columns = pd.MultiIndex.from_arrays([
334
+ r.group_node_up_slack__dt.columns,
335
+ ['slack'] * len(r.group_node_up_slack__dt.columns),
336
+ ['upward'] * len(r.group_node_up_slack__dt.columns)
337
+ ], names=['group', 'type', 'item'])
338
+ result_multi_dt[r.group_node_up_slack__dt.columns] = r.group_node_up_slack__dt
339
+
340
+ # Unit aggregates (aggregateUnits to group)
341
+ r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns = pd.MultiIndex.from_arrays([
342
+ r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns.get_level_values('group'),
343
+ ['from_unitGroup'] * len(r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns),
344
+ r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns.get_level_values(1)
345
+ ], names=['group', 'type', 'item'])
346
+ result_multi_dt[r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt.columns] = _to_energy(r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt)
347
+
348
+ # Units not in aggregate (unit to group) - sum across nodes
349
+ r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns = pd.MultiIndex.from_arrays([
350
+ r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns.get_level_values('group'),
351
+ ['from_unit'] * len(r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns),
352
+ r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns.get_level_values('process')
353
+ ], names=['group', 'type', 'item'])
354
+ result_multi_dt[r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt.columns] = _to_energy(r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt)
355
+
356
+ # Connection aggregates (from connections to group) - sum across nodes
357
+ r.nodeGroupDispatch__from_connection_aggregate__dt.columns = pd.MultiIndex.from_arrays([
358
+ r.nodeGroupDispatch__from_connection_aggregate__dt.columns.get_level_values('group'),
359
+ ['from_connectionGroup'] * len(r.nodeGroupDispatch__from_connection_aggregate__dt.columns),
360
+ r.nodeGroupDispatch__from_connection_aggregate__dt.columns.get_level_values(1)
361
+ ], names=['group', 'type', 'item'])
362
+ result_multi_dt[r.nodeGroupDispatch__from_connection_aggregate__dt.columns] = _to_energy(r.nodeGroupDispatch__from_connection_aggregate__dt)
363
+
364
+ # Connections not in aggregate (from connections)
365
+ r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns = pd.MultiIndex.from_arrays([
366
+ r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns.get_level_values('group'),
367
+ ['from_connection'] * len(r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns),
368
+ r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns.get_level_values('process')
369
+ ], names=['group', 'type', 'item'])
370
+ result_multi_dt[r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.columns] = _to_energy(r.nodeGroupDispatch__from_connection_not_in_aggregate__dt)
371
+
372
+ # Connections not in aggregate (to connections)
373
+ r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns = pd.MultiIndex.from_arrays([
374
+ r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns.get_level_values('group'),
375
+ ['to_connection'] * len(r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns),
376
+ r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns.get_level_values('process')
377
+ ], names=['group', 'type', 'item'])
378
+ result_multi_dt[r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.columns] = -_to_energy(r.nodeGroupDispatch__to_connection_not_in_aggregate__dt)
379
+
380
+ # Connection aggregates (to connections) - sum across nodes
381
+ r.nodeGroupDispatch__to_connection_aggregate__dt.columns = pd.MultiIndex.from_arrays([
382
+ r.nodeGroupDispatch__to_connection_aggregate__dt.columns.get_level_values('group'),
383
+ ['to_connectionGroup'] * len(r.nodeGroupDispatch__to_connection_aggregate__dt.columns),
384
+ r.nodeGroupDispatch__to_connection_aggregate__dt.columns.get_level_values(1)
385
+ ], names=['group', 'type', 'item'])
386
+ result_multi_dt[r.nodeGroupDispatch__to_connection_aggregate__dt.columns] = -_to_energy(r.nodeGroupDispatch__to_connection_aggregate__dt)
387
+
388
+ # Group to aggregate units (negative)
389
+ r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns = pd.MultiIndex.from_arrays([
390
+ r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns.get_level_values('group'),
391
+ ['to_unitGroup'] * len(r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns),
392
+ r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns.get_level_values(1)
393
+ ], names=['group', 'type', 'item'])
394
+ result_multi_dt[r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt.columns] = -_to_energy(r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt)
395
+
396
+ # Node to unit not in aggregate (negative)
397
+ r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns = pd.MultiIndex.from_arrays([
398
+ r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns.get_level_values('group'),
399
+ ['to_unit'] * len(r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns),
400
+ r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns.get_level_values('process')
401
+ ], names=['group', 'type', 'item'])
402
+ result_multi_dt[r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt.columns] = -_to_energy(r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt)
403
+
404
+ # Inflow
405
+ r.group_node_inflow_dt.columns = pd.MultiIndex.from_arrays([
406
+ r.group_node_inflow_dt.columns,
407
+ ['inflow'] * len(r.group_node_inflow_dt.columns),
408
+ r.group_node_inflow_dt.columns
409
+ ], names=['group', 'type', 'item'])
410
+ result_multi_dt[r.group_node_inflow_dt.columns] = r.group_node_inflow_dt
411
+
412
+ # Internal losses - connections (sum across processes, negate)
413
+ if not r.nodeGroupDispatch_Internal_connection_losses__dt.empty:
414
+ r.nodeGroupDispatch_Internal_connection_losses__dt = r.nodeGroupDispatch_Internal_connection_losses__dt.T.groupby('group').sum().T
415
+ r.nodeGroupDispatch_Internal_connection_losses__dt.columns = pd.MultiIndex.from_arrays([
416
+ r.nodeGroupDispatch_Internal_connection_losses__dt.columns,
417
+ ['internal_losses'] * len(r.nodeGroupDispatch_Internal_connection_losses__dt.columns),
418
+ ['connections'] * len(r.nodeGroupDispatch_Internal_connection_losses__dt.columns)
419
+ ], names=['group', 'type', 'item'])
420
+ result_multi_dt[r.nodeGroupDispatch_Internal_connection_losses__dt.columns] = _to_energy(r.nodeGroupDispatch_Internal_connection_losses__dt)
421
+
422
+ # Internal losses - units (sum across processes, negate)
423
+ if not r.nodeGroupDispatch_Internal_unit_losses__dt.empty:
424
+ r.nodeGroupDispatch_Internal_unit_losses__dt = r.nodeGroupDispatch_Internal_unit_losses__dt.T.groupby('group').sum().T
425
+ r.nodeGroupDispatch_Internal_unit_losses__dt.columns = pd.MultiIndex.from_arrays([
426
+ r.nodeGroupDispatch_Internal_unit_losses__dt.columns,
427
+ ['internal_losses'] * len(r.nodeGroupDispatch_Internal_unit_losses__dt.columns),
428
+ ['units'] * len(r.nodeGroupDispatch_Internal_unit_losses__dt.columns)
429
+ ], names=['group', 'type', 'item'])
430
+ result_multi_dt[r.nodeGroupDispatch_Internal_unit_losses__dt.columns] = _to_energy(r.nodeGroupDispatch_Internal_unit_losses__dt)
431
+
432
+ # Internal losses - storages (negate)
433
+ r.group_node_state_losses__dt.columns = pd.MultiIndex.from_arrays([
434
+ r.group_node_state_losses__dt.columns,
435
+ ['internal_losses'] * len(r.group_node_state_losses__dt.columns),
436
+ ['storages'] * len(r.group_node_state_losses__dt.columns)
437
+ ], names=['group', 'type', 'item'])
438
+ result_multi_dt[r.group_node_state_losses__dt.columns] = _to_energy(r.group_node_state_losses__dt)
439
+
440
+ # Slack downward
441
+ r.group_node_down_slack__dt.columns = pd.MultiIndex.from_arrays([
442
+ r.group_node_down_slack__dt.columns,
443
+ ['slack'] * len(r.group_node_down_slack__dt.columns),
444
+ ['downward'] * len(r.group_node_down_slack__dt.columns)
445
+ ], names=['group', 'type', 'item'])
446
+ result_multi_dt[r.group_node_down_slack__dt.columns] = r.group_node_down_slack__dt
447
+
448
+ # Tidy: drop all-zero (group, type, item) columns. The inflow,
449
+ # internal_losses storages and slack families are pre-allocated one
450
+ # column per group (filled with 0.0 even when a group has no inflow /
451
+ # no self-discharge node / no slack), unlike the join-built unit /
452
+ # connection families which only appear when non-empty. Without this
453
+ # an ``elec`` group that is a single loss-free node still emits an
454
+ # ``internal_losses | storages`` column of zeros, which reads as a
455
+ # spurious participant in the plot legend. ``result_multi_d`` is
456
+ # derived from ``result_multi_dt`` below, so both stay consistent.
457
+ if result_multi_dt.shape[1] > 0:
458
+ result_multi_dt = result_multi_dt.loc[:, (result_multi_dt != 0).any(axis=0)]
459
+
460
+ result_multi_dt.columns.names = ['group', 'type', 'item']
461
+ result_multi_dt = result_multi_dt.sort_index(axis=1, level='group', sort_remaining=False)
462
+
463
+ # Return timestep results
464
+ results.append((result_multi_dt, 'nodeGroup_flows_dt_gpe'))
465
+
466
+ # Aggregate to period level and annualize to match node_d_ep convention.
467
+ # result_multi_dt is already energy (MWh/step); weight each (d, t) by
468
+ # par.timestep_weight (=1.0 with no/uniform timeset_weights →
469
+ # byte-identical) before the period sum.
470
+ result_multi_d = annualize_dt_to_d(result_multi_dt, par.timestep_weight, par.complete_period_share_of_year)
471
+
472
+ # Return period results
473
+ results.append((result_multi_d, 'nodeGroup_flows_d_gpe'))
474
+
475
+ return results
@@ -0,0 +1,190 @@
1
+ import pandas as pd
2
+
3
+ from flextool.process_outputs._annualize import annualize_dt_to_d
4
+
5
+ # Storage node is surfaced in the node balance/price outputs only if it is
6
+ # wired to more than this many distinct processes. A battery (one inverter,
7
+ # or separate charge + discharge units ⇒ ≤2 processes) stays hidden to avoid
8
+ # cluttering the outputs; a multi-use hub (e.g. a hydrogen node feeding an
9
+ # electrolyser, a turbine and several conversion routes ⇒ many processes) is
10
+ # surfaced automatically.
11
+ MULTI_USE_STORAGE_PROCESS_THRESHOLD = 2
12
+
13
+
14
+ def _multi_use_storage_nodes(s):
15
+ """Storage nodes wired to more than the threshold of distinct processes.
16
+
17
+ Counts distinct process *names* across the node's source- and sink-side
18
+ arcs (``s.process_source`` / ``s.process_sink``), so a bidirectional unit
19
+ appearing on both sides counts once. Arc-level only — never touches the
20
+ ``(d, t)`` timeline. Returns a ``node``-named Index that is a subset of
21
+ ``s.node_state``; the complement (boring storage) is what gets hidden.
22
+ """
23
+ if len(s.node_state) == 0:
24
+ return s.node_state
25
+ src = s.process_source.to_frame(index=False).rename(columns={"source": "node"})
26
+ snk = s.process_sink.to_frame(index=False).rename(columns={"sink": "node"})
27
+ arcs = pd.concat([src, snk], ignore_index=True)
28
+ arcs = arcs[arcs["node"].isin(s.node_state)]
29
+ if arcs.empty:
30
+ return pd.Index([], name="node", dtype=s.node_state.dtype)
31
+ counts = arcs.groupby("node")["process"].nunique()
32
+ return pd.Index(
33
+ counts.index[counts > MULTI_USE_STORAGE_PROCESS_THRESHOLD], name="node",
34
+ )
35
+
36
+
37
+ def node_summary(par, s, v, r, debug):
38
+ """Node balance summaries for periods and timesteps"""
39
+ results = []
40
+
41
+ categories = ['From units', 'From connections', 'Loss of load', 'To units', 'To connections', 'Self discharge', 'Excess load', 'Inflow']
42
+
43
+ balanced_nodes = s.node_balance.union(s.node_balance_period)
44
+ if debug:
45
+ nodes = s.node
46
+ else:
47
+ # Hide boring storage nodes (batteries); keep multi-use storage hubs.
48
+ hidden_storage = s.node_state.difference(_multi_use_storage_nodes(s))
49
+ nodes = balanced_nodes.difference(hidden_storage)
50
+ nodes_sink = s.node.copy().intersection(nodes)
51
+ nodes_sink.name = 'sink'
52
+ nodes_source = s.node.copy().intersection(nodes)
53
+ nodes_source.name = 'source'
54
+
55
+ # 1. Timestep-level node summary
56
+ node_dt = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_product([nodes, categories], names=['node', 'category']), dtype=float)
57
+
58
+ # From units
59
+ from_units = r.flow_dt[s.process_unit.join(r.flow_dt.columns).join(nodes_sink, how='inner')].T.groupby('sink').sum().T
60
+ from_units_cols = node_dt.columns[
61
+ node_dt.columns.get_level_values('node').isin(from_units.columns)
62
+ & node_dt.columns.get_level_values('category').isin(['From units'])]
63
+ node_dt[from_units_cols] = from_units[from_units_cols.get_level_values('node')]
64
+
65
+ # From connections
66
+ from_connections = r.flow_dt[s.process_connection.join(r.flow_dt.columns).join(nodes_sink, how='inner')].T.groupby('sink').sum().T
67
+ from_connections_cols = node_dt.columns[
68
+ node_dt.columns.get_level_values('node').isin(from_connections.columns)
69
+ & node_dt.columns.get_level_values('category').isin(['From connections'])]
70
+ node_dt[from_connections_cols] = from_connections[from_connections_cols.get_level_values('node')]
71
+
72
+ # Upward slack
73
+ upward_slack_data = v.q_state_up.loc[:, v.q_state_up.columns.get_level_values('node').isin(balanced_nodes.intersection(nodes))].clip(lower=0)
74
+ # node_capacity_for_scaling is complete over nodeBalance ∪
75
+ # nodeBalancePeriod (⊇ q_state nodes), so direct column indexing is safe.
76
+ upward_slack_data = upward_slack_data.mul(par.node_capacity_for_scaling[upward_slack_data.columns], axis=1)
77
+ upward_slack_cols = node_dt.columns[
78
+ node_dt.columns.get_level_values('node').isin(upward_slack_data.columns.get_level_values('node'))
79
+ & node_dt.columns.get_level_values('category').isin(['Loss of load'])]
80
+ node_dt[upward_slack_cols] = upward_slack_data[upward_slack_cols.get_level_values('node')]
81
+
82
+ # To units (negative)
83
+ to_units = -r.flow_dt[s.process_unit.join(r.flow_dt.columns).join(nodes_source, how='inner')].T.groupby('source').sum().T
84
+ to_units_cols = node_dt.columns[
85
+ node_dt.columns.get_level_values('node').isin(to_units.columns)
86
+ & node_dt.columns.get_level_values('category').isin(['To units'])]
87
+ node_dt[to_units_cols] = to_units[to_units_cols.get_level_values('node')]
88
+
89
+ # To connections (negative)
90
+ to_connections = -r.flow_dt[s.process_connection.join(r.flow_dt.columns).join(nodes_source, how='inner')].T.groupby('source').sum().T
91
+ to_connections_cols = node_dt.columns[
92
+ node_dt.columns.get_level_values('node').isin(to_connections.columns)
93
+ & node_dt.columns.get_level_values('category').isin(['To connections'])]
94
+ node_dt[to_connections_cols] = to_connections[to_connections_cols.get_level_values('node')]
95
+
96
+ # Self discharge (negative)
97
+ self_discharge = -r.self_discharge_loss_dt[r.self_discharge_loss_dt.columns.intersection(s.node_self_discharge.intersection(nodes))]
98
+ self_discharge_cols = node_dt.columns[
99
+ node_dt.columns.get_level_values('node').isin(self_discharge.columns)
100
+ & node_dt.columns.get_level_values('category').isin(['Self discharge'])]
101
+ node_dt[self_discharge_cols] = self_discharge[self_discharge_cols.get_level_values('node')]
102
+
103
+ # Downward slack (negative)
104
+ downward_slack_data = (-v.q_state_down.loc[:, v.q_state_down.columns.get_level_values('node').isin(balanced_nodes.intersection(nodes))]).clip(upper=0)
105
+ downward_slack_data = downward_slack_data.mul(par.node_capacity_for_scaling[downward_slack_data.columns], axis=1)
106
+ downward_slack_cols = node_dt.columns[
107
+ node_dt.columns.get_level_values('node').isin(downward_slack_data.columns.get_level_values('node'))
108
+ & node_dt.columns.get_level_values('category').isin(['Excess load'])]
109
+ node_dt[downward_slack_cols] = downward_slack_data[downward_slack_cols.get_level_values('node')]
110
+
111
+ # Inflow
112
+ inflow_cols = node_dt.columns[
113
+ node_dt.columns.get_level_values('node').isin(par.node_inflow.columns.intersection(nodes))
114
+ & node_dt.columns.get_level_values('category').isin(['Inflow'])]
115
+ node_dt[inflow_cols] = par.node_inflow[inflow_cols.get_level_values('node')]
116
+
117
+ # Fill any remaining NaN values with 0
118
+ node_dt = node_dt.fillna(0.0)
119
+
120
+ results.append((node_dt, 'node_dt_ep'))
121
+
122
+ # 2. Period-level node summary. Most categories (flows, slacks, self
123
+ # discharge) are MW — multiply by step_duration to get MWh per step.
124
+ # Inflow is already MWh/step (par.node_inflow is energy per step, see
125
+ # mod comment at flextool.mod:2319), so compute it separately.
126
+ energy_categories = [c for c in categories if c != 'Inflow']
127
+ energy_cols = node_dt.columns[node_dt.columns.get_level_values('category').isin(energy_categories)]
128
+ inflow_cols = node_dt.columns[node_dt.columns.get_level_values('category') == 'Inflow']
129
+
130
+ # Weight each (d, t) by par.timestep_weight (=1.0 with no/uniform
131
+ # timeset_weights → byte-identical) before the period sum, so reported
132
+ # annual energy matches the cost-weighted objective.
133
+ node_d_energy = annualize_dt_to_d(
134
+ node_dt[energy_cols], par.timestep_weight,
135
+ par.complete_period_share_of_year, par.step_duration, div_level=1)
136
+ node_d_inflow = annualize_dt_to_d(
137
+ node_dt[inflow_cols], par.timestep_weight,
138
+ par.complete_period_share_of_year, div_level=1)
139
+ node_d = pd.concat([node_d_energy, node_d_inflow], axis=1).reindex(columns=node_dt.columns)
140
+
141
+ results.append((node_d, 'node_d_ep'))
142
+
143
+ return results
144
+
145
+
146
+ def node_additional_results(par, s, v, r, debug):
147
+ """Additional node results: prices, state, and slacks"""
148
+ results = []
149
+
150
+ # 1. Nodal prices — the per-(d,t) nodal-balance dual. Per-step balance
151
+ # nodes carry ``nodeBalance_eq``; coarse variable-resolution nodes
152
+ # (``new_stepduration`` → ``nodeStateBlock``) carry ``nodeBalanceBlock_eq``
153
+ # broadcast to per-(d,t) — both are merged into ``v.dual_node_balance``
154
+ # by ``write_v_dual_node_balance``. Defensive intersection with the
155
+ # available columns (order-preserving): every requested node normally
156
+ # has a dual, but if a node genuinely lacks one (e.g. a future balance
157
+ # mode without a dual writer) this omits it rather than raising
158
+ # ``KeyError: None of [...] are in the columns``.
159
+ # Mirror the node-balance summary: hide boring storage nodes (batteries),
160
+ # keep multi-use storage hubs. For a battery the balance dual is largely
161
+ # redundant with the connected electricity node's price (and most prone to
162
+ # degeneracy at the storage margin); for a multi-use hub (e.g. a hydrogen
163
+ # node) it is the genuine, otherwise-unavailable marginal value of that
164
+ # commodity — the case worth reporting.
165
+ hidden_storage = s.node_state.difference(_multi_use_storage_nodes(s))
166
+ price_nodes = s.node_balance.difference(hidden_storage).intersection(
167
+ v.dual_node_balance.columns)
168
+ results.append((v.dual_node_balance[price_nodes], 'node_prices_dt_e'))
169
+
170
+ # 2. Node state
171
+ node_state = v.state.mul(par.entity_unitsize[s.node_state], level="node")
172
+ results.append((node_state, 'node_state_dt_e'))
173
+
174
+ # 3. Node upward slack (MW at timestep; multiply by step_duration for MWh period)
175
+ upward_slack = v.q_state_up.mul(par.node_capacity_for_scaling[s.node_balance.union(s.node_balance_period)], level=0).clip(lower=0)
176
+ results.append((upward_slack, 'node_slack_up_dt_e'))
177
+ upward_slack_d = annualize_dt_to_d(
178
+ upward_slack, par.timestep_weight, par.complete_period_share_of_year,
179
+ par.step_duration, div_level=1)
180
+ results.append((upward_slack_d, 'node_slack_up_d_e'))
181
+
182
+ # 4. Node downward slack (MW at timestep; multiply by step_duration for MWh period)
183
+ downward_slack = v.q_state_down.mul(par.node_capacity_for_scaling[s.node_balance.union(s.node_balance_period)], level=0).clip(lower=0)
184
+ results.append((downward_slack, 'node_slack_down_dt_e'))
185
+ downward_slack_d = annualize_dt_to_d(
186
+ downward_slack, par.timestep_weight, par.complete_period_share_of_year,
187
+ par.step_duration, div_level=1)
188
+ results.append((downward_slack_d, 'node_slack_down_d_e'))
189
+
190
+ return results