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,544 @@
1
+ import pandas as pd
2
+
3
+
4
+ def connection(par, s, v, r, debug):
5
+ """Connection flow for periods and time"""
6
+
7
+ results = []
8
+
9
+ # Return timestep results
10
+ results.append((r.connection_dt, 'connection_dt_eee'))
11
+ results.append((r.connection_losses_dt, 'connection_losses_dt_eee'))
12
+
13
+ # Annualize: r.connection_d / r.connection_losses_d from calc_connections
14
+ # are already in MWh (v_flow × step_duration summed per period); dividing
15
+ # by complete_period_share_of_year scales them to MWh/year.
16
+ r.connection_d = r.connection_d.div(par.complete_period_share_of_year, axis=0)
17
+ r.connection_losses_d = r.connection_losses_d.div(par.complete_period_share_of_year, axis=0)
18
+
19
+ # Return period results
20
+ results.append((r.connection_d, 'connection_d_eee'))
21
+ results.append((r.connection_losses_d, 'connection_losses_d_eee'))
22
+
23
+ return results
24
+
25
+
26
+ def connection_wards(par, s, v, r, debug):
27
+ """Connection flow to right node and to left node for periods and time"""
28
+
29
+ results = []
30
+
31
+ # Return timestep results
32
+ results.append((r.connection_to_left_node__dt, 'connection_leftward_dt_eee'))
33
+ results.append((r.connection_to_right_node__dt, 'connection_rightward_dt_eee'))
34
+
35
+ # Return period results
36
+ results.append((r.connection_to_right_node__d, 'connection_rightward_d_eee'))
37
+ results.append((r.connection_to_left_node__d, 'connection_leftward_d_eee'))
38
+
39
+ return results
40
+
41
+
42
+ def connection_cf(par, s, v, r, debug):
43
+ """Connection capacity factors for periods"""
44
+ complete_hours = par.complete_period_share_of_year * 8760
45
+ connection_cols = r.process_sink_flow_d.columns[r.process_sink_flow_d.columns.get_level_values(0).isin(s.process_connection)]
46
+ connection_capacity = r.entity_all_capacity[connection_cols.droplevel(1).unique()].rename_axis('process', axis=1)
47
+ connection_capacity.columns = connection_capacity.columns.get_level_values(0)
48
+ # v_flow is MW (MWh/h); multiply by step_duration for MWh before the CF ratio.
49
+ results = (
50
+ r.connection_dt.abs()
51
+ .mul(par.step_duration, axis=0)
52
+ .groupby('period').sum()
53
+ .div(connection_capacity, level=0)
54
+ .div(complete_hours, axis=0)
55
+ )
56
+ # CF undefined for zero-capacity connections -> NaN (see unit_cf_outputNode).
57
+ results = results.replace([float('inf'), float('-inf')], float('nan'))
58
+ results.columns.names = ['connection']
59
+ return results, 'connection_cf_d_e'
60
+
61
+
62
+ def reserves(par, s, v, r, debug):
63
+ """Process reserves for timesteps and periods"""
64
+ results = []
65
+
66
+ # Timestep-level reserves
67
+ results.append((r.reserves_dt, 'process_reserve_upDown_node_dt_eppe'))
68
+
69
+ # Period-level reserves (average)
70
+ results.append((r.reserves_d, 'process_reserve_average_d_eppe'))
71
+
72
+ # Reserve price results
73
+ results.append((v.dual_reserve_balance, 'reserve_prices_dt_ppg'))
74
+
75
+ return results
76
+
77
+
78
+ def investment_duals(par, s, v, r, debug):
79
+ """Additional node results: prices, state, and slacks"""
80
+ results = []
81
+
82
+ # Synthesized effective investment dual per entity type.
83
+ # Complete signed marginal value (objective per MW) of one more MW of
84
+ # investment capacity, folding every binding regime (a/b/c). Positive
85
+ # means more investment in this entity would lower the objective.
86
+ combined = _synthesize_invest_dual(v, par, s)
87
+ if not combined.empty:
88
+ units = [c for c in combined.columns if c in s.process_unit]
89
+ connections = [c for c in combined.columns if c in s.process_connection]
90
+ nodes = [c for c in combined.columns if c in s.node]
91
+ if units:
92
+ results.append((combined[units], 'dual_invest_effective_unit_d_e'))
93
+ if connections:
94
+ results.append((combined[connections], 'dual_invest_effective_connection_d_e'))
95
+ if nodes:
96
+ results.append((combined[nodes], 'dual_invest_effective_node_d_e'))
97
+
98
+ return results
99
+
100
+
101
+ def _synthesize_invest_dual(v, par, s=None) -> "pd.DataFrame":
102
+ """Complete SIGNED effective investment dual, per (entity, period).
103
+
104
+ Returns the full marginal value of one more MW of investment capacity in
105
+ an entity, in objective units per MW. Sign convention (user-confirmed):
106
+ POSITIVE means more investment in this entity would IMPROVE (lower) the
107
+ objective.
108
+
109
+ The value folds every binding investment regime (≤1 nonzero per
110
+ entity-period at a non-degenerate optimum). Each source is first put in
111
+ objective-per-MW units, then NEGATED, then summed:
112
+
113
+ - (b) upper-cap binds — the ``<=`` row duals
114
+ (``maxInvest_period``/``maxInvest_total``/``maxCumulative`` and the
115
+ group ``maxInvestGroup_*`` caps). Their raw HiGHS dual is NEGATIVE;
116
+ negate → POSITIVE (the cap holds the entity back, so more would help).
117
+ - (c) lower-floor binds — the ``>=`` row duals
118
+ (``minInvest_period``/``minInvest_total``/``minCumulative`` and the
119
+ group ``minInvestGroup_*`` floors). Their raw dual is POSITIVE;
120
+ negate → NEGATIVE (the floor over-forces capacity, so more would hurt).
121
+ - (a) not built — the v_invest COLUMN reduced cost
122
+ (``dual_invest_unit``/``connection``/``node``), correctly scaled by
123
+ ``1/scale_the_objective`` (Increment 2). It is in objective per
124
+ v_invest-unit, so divide by ``entity_unitsize`` (mirroring the
125
+ standalone path in ``investment_duals``) to reach objective per MW;
126
+ the reduced cost is ≥0, so negate → NEGATIVE (unprofitable to build).
127
+ - (d) interior, binds nothing → 0.
128
+
129
+ ASYMMETRY (read off ``drop_levels.py``): ``dual_maxInvest_total`` is in
130
+ ``_V_SOLVE_ONLY`` — it is collapsed to a single-row ``RangeIndex(1)``
131
+ (period-less) and so must be broadcast across the period axis of its
132
+ siblings before combining. ``dual_minInvest_total`` is in ``_V_DROP`` —
133
+ after dropping the solve level it retains a ``period`` index and is
134
+ ALREADY per-(entity, period), so it is added directly without broadcast.
135
+
136
+ The broadcast period axis is taken from a per-period sibling dual when
137
+ one is non-empty; otherwise (a binding total cap is the SOLE family, so
138
+ every per-period dual AND every regime-(a) col_dual is empty because
139
+ presolve dropped the fixed columns) it falls back to ``s.ed_invest`` —
140
+ the realized invest (entity, period) set, solution- and
141
+ presolve-independent — broadcasting each capped entity's value over
142
+ only that entity's own invest periods.
143
+
144
+ All duals are obj/MW; non-binding constraints contribute 0.
145
+ """
146
+ import pandas as pd
147
+
148
+ # Collect all entity-level constraint duals, aligned to the same columns
149
+ # and index. Every contribution is appended already NEGATED so the final
150
+ # sum carries the signed obj/MW convention documented above.
151
+ entity_duals: list[pd.DataFrame] = []
152
+
153
+ # A reference (entity, period) frame to broadcast the period-less
154
+ # ``dual_maxInvest_total`` across. Prefer a per-period sibling that
155
+ # actually carries the period axis. The per-period constraint-dual
156
+ # families are each independently emission-gated, so when a per-entity
157
+ # ``maxInvest_total`` cap is the ONLY binding family they are all empty.
158
+ # The regime-(a) column reduced-cost frames
159
+ # (``dual_invest_unit``/``connection``/``node``) usually carry a
160
+ # realized-``period`` index, but a binding total cap FIXES ``v_invest``
161
+ # at its bound, so presolve drops those columns and their col_dual is
162
+ # absent too — leaving all eight candidates empty. Only
163
+ # ``period_ref.index`` is used for the broadcast, so the differing
164
+ # column axis name on these fallbacks is irrelevant.
165
+ period_ref: pd.DataFrame | None = next(
166
+ (
167
+ df
168
+ for df in (
169
+ v.dual_maxInvest_period,
170
+ v.dual_minInvest_period,
171
+ v.dual_maxCumulative,
172
+ v.dual_minCumulative,
173
+ v.dual_minInvest_total,
174
+ v.dual_invest_unit,
175
+ v.dual_invest_connection,
176
+ v.dual_invest_node,
177
+ )
178
+ if not df.empty
179
+ ),
180
+ None,
181
+ )
182
+
183
+ # Solution-/presolve-INDEPENDENT fallback for the realized invest-period
184
+ # axis: the ``s.ed_invest`` set (entity, period), already restricted to
185
+ # realized invest periods by ``drop_levels`` (joined to
186
+ # ``d_realize_invest``). When every dual-frame candidate above is empty
187
+ # (sole-binding total cap), broadcast each capped entity's max-total
188
+ # value across ONLY that entity's own invest periods — NOT the union of
189
+ # all model periods, which would fabricate spurious (entity, period)
190
+ # rows for entities not investable in those periods.
191
+ ed_invest_idx = None
192
+ if s is not None and getattr(s, 'ed_invest', None) is not None:
193
+ ei = s.ed_invest
194
+ if getattr(ei, 'names', None) is not None and 'entity' in ei.names \
195
+ and 'period' in ei.names:
196
+ ed_invest_idx = ei
197
+
198
+ # (b) Max-side ``<=`` duals: raw < 0 → negate → POSITIVE.
199
+ # ``dual_maxInvest_total`` is solve-only → broadcast over periods.
200
+ for df in (v.dual_maxInvest_period, v.dual_maxInvest_total, v.dual_maxCumulative):
201
+ if df.empty:
202
+ continue
203
+ if df is v.dual_maxInvest_total:
204
+ if period_ref is not None:
205
+ df = pd.DataFrame(
206
+ {c: df.iloc[0][c] for c in df.columns},
207
+ index=period_ref.index,
208
+ )
209
+ df.columns.name = 'entity'
210
+ elif ed_invest_idx is not None:
211
+ # Build per-(entity, period) from the invest set: each capped
212
+ # entity's value goes only on its own realized invest periods.
213
+ cols = {}
214
+ row = df.iloc[0]
215
+ for c in df.columns:
216
+ periods = ed_invest_idx[
217
+ ed_invest_idx.get_level_values('entity') == c
218
+ ].get_level_values('period').unique()
219
+ if len(periods) == 0:
220
+ continue
221
+ cols[c] = pd.Series(row[c], index=periods)
222
+ if not cols:
223
+ continue
224
+ df = pd.DataFrame(cols)
225
+ df.index.name = 'period'
226
+ df.columns.name = 'entity'
227
+ else:
228
+ continue
229
+ entity_duals.append(df.mul(-1.0))
230
+
231
+ # (c) Min-side ``>=`` duals: raw > 0 → negate → NEGATIVE.
232
+ # ``dual_minInvest_total`` is ALREADY per-(entity, period) — add it
233
+ # directly, NO broadcast (the max-total asymmetry above).
234
+ for df in (v.dual_minInvest_period, v.dual_minInvest_total, v.dual_minCumulative):
235
+ if df.empty:
236
+ continue
237
+ entity_duals.append(df.mul(-1.0))
238
+
239
+ # (b)/(c) group caps/floors: expand group → member entities, then negate.
240
+ if not v.group_entity_invest.empty:
241
+ group_map = v.group_entity_invest # columns: group, entity
242
+ for group_df in (
243
+ v.dual_maxInvestGroup_period, v.dual_maxInvestGroup_total,
244
+ v.dual_maxInvestGroup_cumulative,
245
+ v.dual_minInvestGroup_period, v.dual_minInvestGroup_total,
246
+ v.dual_minInvestGroup_cumulative,
247
+ ):
248
+ if group_df.empty:
249
+ continue
250
+ for group_name in group_df.columns:
251
+ members = group_map.loc[group_map['group'] == group_name, 'entity'].tolist()
252
+ if not members:
253
+ continue
254
+ # Create per-entity columns with the group's dual value.
255
+ group_series = group_df[group_name]
256
+ expanded = pd.DataFrame(
257
+ {entity: group_series for entity in members},
258
+ index=group_series.index,
259
+ )
260
+ expanded.columns.name = 'entity'
261
+ entity_duals.append(expanded.mul(-1.0))
262
+
263
+ # (a) Not-built regime — v_invest COLUMN reduced cost. Divide by
264
+ # entity_unitsize (obj/v_invest-unit → obj/MW), then negate. These
265
+ # frames have a period index and a 'unit'/'connection'/'node' column
266
+ # axis; rename to the common 'entity' axis before combining.
267
+ for col_dual in (v.dual_invest_unit, v.dual_invest_connection, v.dual_invest_node):
268
+ if col_dual.empty:
269
+ continue
270
+ per_mw = col_dual.div(par.entity_unitsize[col_dual.columns])
271
+ per_mw = per_mw.rename_axis('entity', axis=1)
272
+ entity_duals.append(per_mw.mul(-1.0))
273
+
274
+ if not entity_duals:
275
+ return pd.DataFrame()
276
+
277
+ # Align all DataFrames to a common (index, columns) and sum.
278
+ combined = entity_duals[0]
279
+ for df in entity_duals[1:]:
280
+ combined = combined.add(df, fill_value=0.0)
281
+ combined = combined.fillna(0.0)
282
+
283
+ # The synthesized dual is naturally in CUR/MW (units & connections) and
284
+ # CUR/MWh (storage nodes): the LP capacity is in MW(h) and the objective
285
+ # coefficient was lifted ×1000 from the per-kW input invest_cost
286
+ # (_derived_npv.py annuity). Divide by 1000 so the REPORTED marginal
287
+ # matches the input convention the user types — CUR/kW (units &
288
+ # connections) and CUR/kWh (storage nodes). Single choke point: every
289
+ # regime flows through `combined`, and the node-vs-process split happens
290
+ # downstream by column membership, so one divide is correct for all.
291
+ return combined.div(1000.0)
292
+
293
+
294
+ def co2_duals(par, s, v, r, debug):
295
+ """CO2 emission-cap shadow prices in Currency / tCO2 (positive = cost).
296
+
297
+ Units: the polars ``co2_max_period`` / ``co2_max_total`` constraints
298
+ (``model.py``) carry the cap RHS in **raw tonnes** (input
299
+ ``co2_max_period`` is ``[tCO2]``) against a tonnes LHS
300
+ (``v_flow·unitsize·p_co2_content``), so the raw dual is already
301
+ **Δobj / Δ(tonnes) = Currency/tCO2** — there is NO ÷1000. (The legacy
302
+ ``.mod`` divided both sides by 1000 and the dual needed a ÷1000 to undo
303
+ it; the polars port does not, so applying ÷1000 here would under-report
304
+ the price 1000× — see the CO2-units investigation.) The dual is in NPV
305
+ currency because the objective discounts operational costs; for the
306
+ per-period cap we divide by inflation_factor_operations_yearly[d] to
307
+ recover nominal Currency/tCO2 at period d (mirroring
308
+ v_dual_node_balance). Sign is always flipped so binding caps show as
309
+ positive costs — negatives should ring alarm bells.
310
+
311
+ The cumulative (total) cap has no single period to un-discount against
312
+ (it spans all periods in the sum), so it is reported in NPV currency
313
+ (same convention as investment duals). Its scalar per group is
314
+ broadcast across periods for uniform (period × group) shape.
315
+
316
+ Caveat: the constraint LHS does not include p_timestep_weight whereas
317
+ the objective does. In rp (representative-period) scenarios the raw
318
+ dual therefore picks up an extra timestep_weight factor, inflating
319
+ shadow prices relative to chrono runs. Fixing that requires either
320
+ adding the weight to the constraint LHS in the mod, or post-dividing
321
+ by a representative value of timestep_weight per period.
322
+ """
323
+ import pandas as pd
324
+
325
+ results = []
326
+ periods = s.d_realized_period
327
+
328
+ # Period-limited shadow price: (period × group) in nominal Currency/tCO2
329
+ if not v.dual_co2_max_period.empty:
330
+ co2_period_price = (-v.dual_co2_max_period).astype(float)
331
+ co2_period_price = co2_period_price.div(
332
+ par.inflation_factor_operations_yearly, axis=0
333
+ )
334
+ co2_period_price.columns.name = 'group'
335
+ co2_period_price.index.name = 'period'
336
+ results.append((co2_period_price, 'co2_price_period_d_g'))
337
+
338
+ # Cumulative limit shadow price: scalar per group, broadcast across periods.
339
+ # Kept in NPV currency (no single period to un-discount against).
340
+ if not v.dual_co2_max_total.empty:
341
+ total_row = v.dual_co2_max_total.iloc[0] # Series indexed by group
342
+ total_price = (-total_row).astype(float)
343
+ co2_total_price = pd.DataFrame(
344
+ {g: [total_price[g]] * len(periods) for g in total_price.index},
345
+ index=pd.Index(periods, name='period'),
346
+ )
347
+ co2_total_price.columns.name = 'group'
348
+ results.append((co2_total_price, 'co2_price_total_d_g'))
349
+
350
+ return results
351
+
352
+
353
+ def inertia_results(par, s, v, r, debug):
354
+ """Inertia results for groups and individual entities"""
355
+
356
+ results = []
357
+
358
+ # 1. Calculate unit_inertia for all (process, node) without groups
359
+ unit_inertia = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_tuples([], names=['process', 'node']), dtype=float)
360
+
361
+ # === SOURCE-BASED INERTIA ===
362
+ s.process_source_with_inertia = par.process_source.columns[list(par.process_source.loc['inertia_constant'] > 0)]
363
+ pss_source_inertia = s.process_source_sink_alwaysProcess[
364
+ s.process_source_sink_alwaysProcess.droplevel('sink').isin(s.process_source_with_inertia)
365
+ ]
366
+ s.process_source_with_inertia.names = ['process', 'node']
367
+
368
+ # Online processes - group by (process, source) since online_dt is indexed by process only
369
+ pss_source_online_inertia = pss_source_inertia[pss_source_inertia.get_level_values('process').isin(s.process_online)]
370
+ process_online_inertia = pss_source_online_inertia.droplevel('sink').unique()
371
+ process_online_inertia.names = ['process', 'node']
372
+ online_inertia_procs = process_online_inertia.get_level_values('process').unique()
373
+
374
+ unit_inertia[process_online_inertia] = ( r.process_online_dt[online_inertia_procs]
375
+ .mul(par.entity_unitsize[online_inertia_procs], axis=1, level=0)
376
+ .mul(par.process_source.loc['inertia_constant'][process_online_inertia]) )
377
+
378
+ # Flow processes
379
+ pss_source_flow_inertia = pss_source_inertia[~pss_source_inertia.get_level_values('process').isin(s.process_online)]
380
+ flow_inertia_cols = pss_source_flow_inertia.intersection(r.flow_dt.columns)
381
+ process_flow = flow_inertia_cols.droplevel('sink').unique()
382
+ par_process_source_inertia = par.process_source[process_flow].loc['inertia_constant']
383
+ par_process_source_inertia.index = flow_inertia_cols.join(par_process_source_inertia.index)
384
+ flows_weighted_source = (
385
+ r.flow_dt[flow_inertia_cols]
386
+ .mul(par_process_source_inertia) )
387
+
388
+ # Sum across sinks for each (process, source)
389
+ unit_inertia_source_flow = flows_weighted_source.T.groupby(level=['process', 'source']).sum().T
390
+ unit_inertia_source_flow.columns.names = ['process', 'node']
391
+ unit_inertia[unit_inertia_source_flow.columns] = unit_inertia_source_flow
392
+
393
+ # === SINK-BASED INERTIA ===
394
+ s.process_sink_with_inertia = par.process_sink.columns[list(par.process_sink.loc['inertia_constant'] > 0)]
395
+ pss_sink_inertia = s.process_source_sink_alwaysProcess[
396
+ s.process_source_sink_alwaysProcess.droplevel('source').isin(s.process_sink_with_inertia)
397
+ ]
398
+ s.process_sink_with_inertia.names = ['process', 'node']
399
+
400
+ # Online processes - group by (process, sink) since online_dt is indexed by process only
401
+ pss_sink_online_inertia = pss_sink_inertia[pss_sink_inertia.get_level_values('process').isin(s.process_online)]
402
+ process_online_inertia = pss_sink_online_inertia.droplevel('source').unique()
403
+ process_online_inertia.names = ['process', 'node']
404
+ online_inertia_procs = process_online_inertia.get_level_values('process').unique()
405
+
406
+ unit_inertia[process_online_inertia] = ( r.process_online_dt[online_inertia_procs]
407
+ .mul(par.entity_unitsize[online_inertia_procs], axis=1, level=0)
408
+ .mul(par.process_sink.loc['inertia_constant'][process_online_inertia]) )
409
+
410
+ # Flow processes
411
+ pss_sink_flow_inertia = pss_sink_inertia[~pss_sink_inertia.get_level_values('process').isin(s.process_online)]
412
+ flow_inertia_cols = pss_sink_flow_inertia.intersection(r.flow_dt.columns)
413
+ process_flow = flow_inertia_cols.droplevel('source').unique()
414
+ par_process_sink_inertia = par.process_sink[process_flow].loc['inertia_constant']
415
+ par_process_sink_inertia.index = flow_inertia_cols.join(par_process_sink_inertia.index)
416
+ flows_weighted_sink = (
417
+ r.flow_dt[flow_inertia_cols]
418
+ .mul(par_process_sink_inertia) )
419
+
420
+ # Sum across sources for each (process, sink)
421
+ unit_inertia_sink_flow = flows_weighted_sink.T.groupby(level=['process', 'sink']).sum().T
422
+ unit_inertia_sink_flow.columns.names = ['process', 'node']
423
+ unit_inertia[unit_inertia_sink_flow.columns] = unit_inertia_sink_flow
424
+
425
+ # 2. Add group dimension by joining with group_node
426
+ group_unit_inertia = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_tuples([], names=['group', 'process', 'node']), dtype=float)
427
+
428
+ for g in s.groupInertia:
429
+ # Get (process, node) pairs for this group
430
+ group_pn = s.group_node[s.group_node.get_level_values('group') == g].droplevel('group')
431
+ # Filter unit_inertia to these columns
432
+ cols = unit_inertia.columns.intersection(group_pn)
433
+ # Add group level to columns
434
+ group_cols = pd.MultiIndex.from_arrays(
435
+ [[g] * len(cols), cols.get_level_values(0), cols.get_level_values(1)],
436
+ names=['group', 'process', 'node']
437
+ )
438
+ group_unit_inertia[group_cols] = unit_inertia[cols].values
439
+
440
+ results.append((group_unit_inertia, 'nodeGroup_unit_node_inertia_dt_gee'))
441
+
442
+ # 3. Group inertia - sum by group
443
+ group_inertia = group_unit_inertia.T.groupby(level='group').sum().T
444
+ results.append((group_inertia, 'nodeGroup_inertia_dt_g'))
445
+
446
+ # 4. Largest flow per group
447
+ largest_flow = pd.DataFrame(index=s.dt_realize_dispatch, dtype=float)
448
+ largest_flow.columns.name = 'group'
449
+
450
+ for g in s.groupInertia:
451
+ group_nodes = s.group_node[s.group_node.get_level_values('group') == g].get_level_values('node')
452
+ process_sink_in_group = s.process_sink[s.process_sink.get_level_values('sink').isin(group_nodes)]
453
+ pss_sink = s.process_source_sink_alwaysProcess[
454
+ s.process_source_sink_alwaysProcess.droplevel('source').isin(process_sink_in_group)
455
+ ]
456
+
457
+ flow_cols = pss_sink.intersection(r.flow_dt.columns)
458
+ largest_flow[g] = r.flow_dt[flow_cols].max(axis=1)
459
+
460
+ results.append((largest_flow, 'nodeGroup_inertia_largest_flow_dt_g'))
461
+
462
+ return results
463
+
464
+
465
+ def slack_variables(par, s, v, r, debug):
466
+ """Slack variables for reserves, non-synchronous, inertia, and capacity margin"""
467
+
468
+ results = []
469
+
470
+ # 1. Reserve slack variables
471
+ results.append((r.q_reserves_dt, 'nodeGroup_slack_reserve_dt_eeg'))
472
+
473
+ # 2. Non-synchronous slack variables
474
+ results.append((r.q_non_synchronous_dt, 'nodeGroup_slack_nonsync_dt_g'))
475
+
476
+ # 3. Inertia slack variables
477
+ results.append((r.q_inertia_dt, 'nodeGroup_slack_inertia_dt_g'))
478
+
479
+ # 4. Capacity margin slack variables (for investment periods only)
480
+ results.append((r.q_capacity_margin_d_not_annualized, 'nodeGroup_slack_capacity_margin_d_g'))
481
+
482
+ # 5. Period-level slack variables (annualized)
483
+ results.append((r.q_reserves_d, 'nodeGroup_slack_reserve_d_eeg'))
484
+ results.append((r.q_inertia_d, 'nodeGroup_slack_inertia_d_g'))
485
+ results.append((r.q_non_synchronous_d, 'nodeGroup_slack_nonsync_d_g'))
486
+
487
+ return results
488
+
489
+
490
+ def dc_power_flow(par, s, v, r, debug):
491
+ """DC power flow voltage angles and angle differences per connection"""
492
+
493
+ results = []
494
+
495
+ # Voltage angles per node (may be empty when no DC PF nodes exist)
496
+ if not r.angle_dt.empty:
497
+ results.append((r.angle_dt, 'dc_angle_dt_e'))
498
+
499
+ # Angle difference per connection
500
+ if not r.connection_angle_diff_dt.empty:
501
+ results.append((r.connection_angle_diff_dt, 'dc_angle_diff_dt_e'))
502
+
503
+ # DC PF structural sets (Index objects become parquet mapping files)
504
+ if not s.node_dc_power_flow.empty:
505
+ results.append((s.node_dc_power_flow, 'node_dc_power_flow'))
506
+ if not s.connection_dc_power_flow.empty:
507
+ results.append((s.connection_dc_power_flow, 'connection_dc_power_flow'))
508
+
509
+ return results if results else None
510
+
511
+
512
+ def input_sets(par, s, v, r, debug):
513
+ """Input sets needed for scenario results"""
514
+
515
+ results = []
516
+ results.append((s.group_node, 'group_node'))
517
+ results.append((s.group_process, 'group_process'))
518
+ results.append((s.group_process_node, 'group_process_node'))
519
+ results.append((s.nodeGroupIndicators, 'nodeGroupIndicators'))
520
+ results.append((s.flowGroupIndicators, 'flowGroupIndicators'))
521
+ results.append((s.nodeGroupDispatch, 'nodeGroupDispatch'))
522
+ results.append((s.nodeGroupDispatch__connection_Not_in_aggregate, 'nodeGroupDispatch__connection_Not_in_aggregate'))
523
+ results.append((s.nodeGroupDispatch__process__unit__to_node_Not_in_aggregate, 'nodeGroupDispatch__process__unit__to_node_Not_in_aggregate'))
524
+ results.append((s.nodeGroupDispatch__process__node__to_unit_Not_in_aggregate, 'nodeGroupDispatch__process__node__to_unit_Not_in_aggregate'))
525
+ results.append((s.nodeGroupDispatch__process__connection__to_node_Not_in_aggregate, 'nodeGroupDispatch__process__connection__to_node_Not_in_aggregate'))
526
+ results.append((s.nodeGroupDispatch__process__node__to_connection_Not_in_aggregate, 'nodeGroupDispatch__process__node__to_connection_Not_in_aggregate'))
527
+ results.append((s.nodeGroupDispatch__processGroup_Unit_to_group, 'nodeGroupDispatch__processGroup_Unit_to_group'))
528
+ results.append((s.nodeGroupDispatch__processGroup__process__unit__to_node, 'nodeGroupDispatch__processGroup__process__unit__to_node'))
529
+ results.append((s.nodeGroupDispatch__processGroup_Group_to_unit, 'nodeGroupDispatch__processGroup_Group_to_unit'))
530
+ results.append((s.nodeGroupDispatch__processGroup__process__node__to_unit, 'nodeGroupDispatch__processGroup__process__node__to_unit'))
531
+ results.append((s.nodeGroupDispatch__processGroup_Connection, 'nodeGroupDispatch__processGroup_Connection'))
532
+ results.append((s.nodeGroupDispatch__processGroup__process__connection__to_node, 'nodeGroupDispatch__processGroup__process__connection__to_node'))
533
+ results.append((s.nodeGroupDispatch__processGroup__process__node__to_connection, 'nodeGroupDispatch__processGroup__process__node__to_connection'))
534
+ results.append((s.nodeGroupDispatch__process_fully_inside, 'nodeGroupDispatch__process_fully_inside'))
535
+ results.append((par.node_inflow, 'node_inflow__dt'))
536
+
537
+ # Per-period years_represented — used by plot rules 'y' and 'z' to convert
538
+ # annualized outputs to horizon totals or years-weighted averages.
539
+ years_d = par.years_represented_d.rename('years_represented').to_frame()
540
+ years_d.index.name = 'period'
541
+ years_d.columns.name = 'param'
542
+ results.append((years_d, 'years_represented__d'))
543
+
544
+ return results