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,192 @@
1
+ import pandas as pd
2
+
3
+ from flextool.process_outputs._annualize import annualize_dt_to_d
4
+
5
+
6
+ def compute_group_flows(par, s, v, r) -> None:
7
+ """Compute all group-level flow aggregations (connection and unit flows, inflow, losses).
8
+
9
+ v_flow in FlexTool is MW (MWh/h — length-of-time independent). Period
10
+ aggregations here multiply by par.step_duration before summing so that
11
+ `_d` results are MWh (then annualized by dividing by
12
+ complete_period_share_of_year). par.node_inflow is already MWh/step
13
+ (see mod comment at line 2319), so inflow-derived frames sum as-is.
14
+ """
15
+ step_duration = par.step_duration
16
+
17
+ # --- Group connection flows (uses r.from_conn and r.to_conn from compute_connection_flows) ---
18
+
19
+ # Helper: create an empty DataFrame with the expected MultiIndex column structure
20
+ _empty_gp = pd.DataFrame(columns=pd.MultiIndex(levels=[[], []], codes=[[], []], names=['group', 'process']))
21
+ _empty_gga = pd.DataFrame(columns=pd.MultiIndex(levels=[[], [], []], codes=[[], [], []], names=['group', 'group_aggregate', 'process']))
22
+
23
+ from_conn_set = s.nodeGroupDispatch__process__connection__to_node_Not_in_aggregate.droplevel('connection')
24
+ if not from_conn_set.empty and not r.from_conn.empty:
25
+ group_sets = r.from_conn.columns.join(from_conn_set, how='inner')
26
+ from_conn_selected = r.from_conn[group_sets.droplevel('group')]
27
+ from_conn_selected.columns = group_sets
28
+ r.nodeGroupDispatch__from_connection_not_in_aggregate__dt = from_conn_selected
29
+ else:
30
+ r.nodeGroupDispatch__from_connection_not_in_aggregate__dt = _empty_gp.copy()
31
+
32
+ to_conn_set = s.nodeGroupDispatch__process__node__to_connection_Not_in_aggregate.droplevel('connection')
33
+ if not to_conn_set.empty and not r.to_conn.empty:
34
+ group_sets = r.to_conn.columns.join(to_conn_set, how='inner')
35
+ to_conn_selected = r.to_conn[group_sets.droplevel('group')]
36
+ to_conn_selected.columns = group_sets
37
+ r.nodeGroupDispatch__to_connection_not_in_aggregate__dt = to_conn_selected
38
+ else:
39
+ r.nodeGroupDispatch__to_connection_not_in_aggregate__dt = _empty_gp.copy()
40
+
41
+ r.nodeGroupDispatch__from_connection_not_in_aggregate__d = annualize_dt_to_d(r.nodeGroupDispatch__from_connection_not_in_aggregate__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration) if not r.nodeGroupDispatch__from_connection_not_in_aggregate__dt.empty else _empty_gp.copy()
42
+ r.nodeGroupDispatch__to_connection_not_in_aggregate__d = annualize_dt_to_d(r.nodeGroupDispatch__to_connection_not_in_aggregate__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration) if not r.nodeGroupDispatch__to_connection_not_in_aggregate__dt.empty else _empty_gp.copy()
43
+
44
+ from_conn_agg_set = s.nodeGroupDispatch__processGroup__process__connection__to_node.droplevel('connection')
45
+ if not from_conn_agg_set.empty and not r.from_conn.empty:
46
+ group_agg_sets = r.from_conn.columns.join(from_conn_agg_set, how='inner')
47
+ from_conn_agg_selected = r.from_conn[group_agg_sets.droplevel(['group', 'group_aggregate'])]
48
+ from_conn_agg_selected.columns = group_agg_sets
49
+ r.nodeGroupDispatch__from_connection_aggregate__dt = from_conn_agg_selected.T.groupby(level=['group', 'group_aggregate']).sum().T
50
+ else:
51
+ r.nodeGroupDispatch__from_connection_aggregate__dt = _empty_gga.copy()
52
+
53
+ to_conn_agg_set = s.nodeGroupDispatch__processGroup__process__node__to_connection.droplevel('connection')
54
+ if not to_conn_agg_set.empty and not r.to_conn.empty:
55
+ group_agg_sets = to_conn_agg_set.join(r.to_conn.columns)
56
+ to_conn_agg_selected = r.to_conn[group_agg_sets.droplevel(['group', 'group_aggregate'])]
57
+ to_conn_agg_selected.columns = group_agg_sets
58
+ r.nodeGroupDispatch__to_connection_aggregate__dt = to_conn_agg_selected.T.groupby(level=['group', 'group_aggregate']).sum().T
59
+ else:
60
+ r.nodeGroupDispatch__to_connection_aggregate__dt = _empty_gga.copy()
61
+
62
+ r.nodeGroupDispatch__from_connection_aggregate__d = annualize_dt_to_d(r.nodeGroupDispatch__from_connection_aggregate__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration) if not r.nodeGroupDispatch__from_connection_aggregate__dt.empty else _empty_gga.copy()
63
+ r.nodeGroupDispatch__to_connection_aggregate__d = annualize_dt_to_d(r.nodeGroupDispatch__to_connection_aggregate__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration) if not r.nodeGroupDispatch__to_connection_aggregate__dt.empty else _empty_gga.copy()
64
+
65
+ losses_set = s.nodeGroupDispatch__process_fully_inside
66
+ if not losses_set.empty and not r.connection_losses_dt.empty:
67
+ group_losses_sets = r.connection_losses_dt.columns.join(losses_set, how='inner')
68
+ losses_selected = r.connection_losses_dt[group_losses_sets.droplevel('group')]
69
+ losses_selected.columns = group_losses_sets
70
+ r.nodeGroupDispatch_Internal_connection_losses__dt = losses_selected
71
+ r.nodeGroupDispatch_Internal_connection_losses__d = annualize_dt_to_d(r.nodeGroupDispatch_Internal_connection_losses__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration)
72
+ else:
73
+ r.nodeGroupDispatch_Internal_connection_losses__dt = pd.DataFrame()
74
+ r.nodeGroupDispatch_Internal_connection_losses__d = pd.DataFrame()
75
+
76
+ # --- Group unit flows ---
77
+
78
+ unit_flows = r.flow_dt[r.flow_dt.columns[r.flow_dt.columns.get_level_values('process').isin(s.process_unit)]]
79
+
80
+ unit_to_node = unit_flows[unit_flows.columns[unit_flows.columns.droplevel('source').isin(s.process_sink)]]
81
+ node_to_unit = unit_flows[unit_flows.columns[unit_flows.columns.droplevel('sink').isin(s.process_source)]]
82
+
83
+ unit_to_node.columns = unit_to_node.columns.droplevel('source')
84
+ node_to_unit.columns = node_to_unit.columns.droplevel('sink')
85
+
86
+ unit_to_node.columns.names = ['process', 'node']
87
+ node_to_unit.columns.names = ['process', 'node']
88
+
89
+ unit_to_node_set = s.nodeGroupDispatch__process__unit__to_node_Not_in_aggregate.droplevel('unit')
90
+ group_sets = unit_to_node.columns.join(unit_to_node_set, how='inner')
91
+ unit_to_node_selected = unit_to_node[group_sets.droplevel('group')]
92
+ unit_to_node_selected.columns = group_sets
93
+ r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt = unit_to_node_selected
94
+
95
+ node_to_unit_set = s.nodeGroupDispatch__process__node__to_unit_Not_in_aggregate.droplevel('unit')
96
+ group_sets = node_to_unit.columns.join(node_to_unit_set, how='inner')
97
+ node_to_unit_selected = node_to_unit[group_sets.droplevel('group')]
98
+ node_to_unit_selected.columns = group_sets
99
+ r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt = node_to_unit_selected
100
+
101
+ r.nodeGroupDispatch__unit_to_node_not_in_aggregate__d = annualize_dt_to_d(r.nodeGroupDispatch__unit_to_node_not_in_aggregate__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration)
102
+ r.nodeGroupDispatch__node_to_unit_not_in_aggregate__d = annualize_dt_to_d(r.nodeGroupDispatch__node_to_unit_not_in_aggregate__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration)
103
+
104
+ unit_to_group_set = s.nodeGroupDispatch__processGroup__process__unit__to_node.droplevel('unit')
105
+ group_agg_sets = unit_to_node.columns.join(unit_to_group_set, how='inner')
106
+ unit_to_group_selected = unit_to_node[group_agg_sets.droplevel(['group', 'group_aggregate'])]
107
+ unit_to_group_selected.columns = group_agg_sets
108
+ negatives = (par.entity_unitsize[unit_to_group_selected.columns.get_level_values('process')] < 0).values
109
+ unit_to_group_selected_negative = unit_to_group_selected[unit_to_group_selected.columns[negatives]]
110
+ unit_to_group_selected_positive = unit_to_group_selected[unit_to_group_selected.columns[~negatives]]
111
+ r.nodeGroupDispatch__group_aggregate_Unit_to_group_positive__dt = unit_to_group_selected_positive.T.groupby(level=['group', 'group_aggregate']).sum().T
112
+ r.nodeGroupDispatch__group_aggregate_Unit_to_group_negative__dt = unit_to_group_selected_negative.T.groupby(level=['group', 'group_aggregate']).sum().T
113
+
114
+ group_to_unit_set = s.nodeGroupDispatch__processGroup__process__node__to_unit.droplevel('unit')
115
+ group_agg_sets = node_to_unit.columns.join(group_to_unit_set, how='inner')
116
+ group_to_unit_selected = node_to_unit[group_agg_sets.droplevel(['group', 'group_aggregate'])]
117
+ group_to_unit_selected.columns = group_agg_sets
118
+ negatives = (par.entity_unitsize[group_to_unit_selected.columns.get_level_values('process')] < 0).values
119
+ group_to_unit_selected_negative = group_to_unit_selected[group_to_unit_selected.columns[negatives]]
120
+ group_to_unit_selected_positive = group_to_unit_selected[group_to_unit_selected.columns[~negatives]]
121
+ r.nodeGroupDispatch__group_aggregate_Group_to_unit_positive__dt = group_to_unit_selected_positive.T.groupby(level=['group', 'group_aggregate']).sum().T
122
+ r.nodeGroupDispatch__group_aggregate_Group_to_unit_negative__dt = group_to_unit_selected_negative.T.groupby(level=['group', 'group_aggregate']).sum().T
123
+
124
+ r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt = r.nodeGroupDispatch__group_aggregate_Unit_to_group_positive__dt.sub(
125
+ r.nodeGroupDispatch__group_aggregate_Group_to_unit_negative__dt, fill_value=0.0)
126
+ r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt = r.nodeGroupDispatch__group_aggregate_Group_to_unit_positive__dt.sub(
127
+ r.nodeGroupDispatch__group_aggregate_Unit_to_group_negative__dt, fill_value=0.0)
128
+
129
+ r.nodeGroupDispatch__group_aggregate_Group_to_unit__d = annualize_dt_to_d(r.nodeGroupDispatch__group_aggregate_Group_to_unit__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration)
130
+ r.nodeGroupDispatch__group_aggregate_Unit_to_group__d = annualize_dt_to_d(r.nodeGroupDispatch__group_aggregate_Unit_to_group__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration)
131
+
132
+ losses_set = s.nodeGroupDispatch__process_fully_inside
133
+ group_losses_sets_node_to_unit = losses_set.join(node_to_unit.columns, how='inner')
134
+ group_losses_sets_unit_to_node = losses_set.join(unit_to_node.columns, how='inner')
135
+
136
+ node_to_unit_filtered = node_to_unit[group_losses_sets_node_to_unit.droplevel('group')]
137
+ unit_to_node_filtered = unit_to_node[group_losses_sets_unit_to_node.droplevel('group')]
138
+ node_to_unit_filtered.columns = group_losses_sets_node_to_unit
139
+ unit_to_node_filtered.columns = group_losses_sets_unit_to_node
140
+
141
+ unit_losses_dt = node_to_unit_filtered.sub(unit_to_node_filtered, axis=1, fill_value=0.0)
142
+ r.nodeGroupDispatch_Internal_unit_losses__dt = unit_losses_dt
143
+ r.nodeGroupDispatch_Internal_unit_losses__d = annualize_dt_to_d(r.nodeGroupDispatch_Internal_unit_losses__dt, par.timestep_weight, par.complete_period_share_of_year, step_duration)
144
+
145
+ # --- Node inflow by group ---
146
+ dt_dispatch_idx = s.dt_realize_dispatch
147
+ r_group_inflow = pd.DataFrame(0.0, index=dt_dispatch_idx, columns=s.nodeGroupDispatch, dtype=float)
148
+ for g in s.nodeGroupDispatch:
149
+ inflow = pd.Series(0.0, index=dt_dispatch_idx)
150
+ for n in s.node:
151
+ if (g, n) in s.group_node and (n, 'no_inflow') not in s.node__inflow_method:
152
+ if n in par.node_inflow.columns:
153
+ inflow = inflow + par.node_inflow[n]
154
+ r_group_inflow[g] = inflow
155
+ r.group_node_inflow_dt = r_group_inflow
156
+
157
+ r.group_node_inflow_d = (
158
+ r.group_node_inflow_dt[
159
+ r.group_node_inflow_dt.index.get_level_values('period').isin(s.d_realized_period)
160
+ ].groupby(level='period').sum() if not r.group_node_inflow_dt.empty
161
+ else pd.DataFrame(0.0, index=s.d_realized_period, columns=s.nodeGroupDispatch))
162
+
163
+ # --- Storage state losses by group ---
164
+ r_group_state_losses = pd.DataFrame(0.0, index=dt_dispatch_idx, columns=s.nodeGroupDispatch, dtype=float)
165
+ for g in s.nodeGroupDispatch:
166
+ losses = pd.Series(0.0, index=dt_dispatch_idx)
167
+ for n in s.node_self_discharge:
168
+ if (g, n) in s.group_node and n in r.self_discharge_loss_dt.columns:
169
+ losses += r.self_discharge_loss_dt[n].reindex(dt_dispatch_idx, fill_value=0)
170
+ r_group_state_losses[g] = losses
171
+ r.group_node_state_losses__dt = r_group_state_losses
172
+
173
+ r.group_node_state_losses__d = (
174
+ r.group_node_state_losses__dt[
175
+ r.group_node_state_losses__dt.index.get_level_values('period').isin(s.d_realized_period)
176
+ ].groupby(level='period').sum() if not r.group_node_state_losses__dt.empty
177
+ else pd.DataFrame(0.0, index=s.d_realized_period, columns=s.nodeGroupDispatch))
178
+
179
+ # --- Node slack by group ---
180
+ r.group_node_up_slack__dt = pd.DataFrame(index=s.dt_realize_dispatch)
181
+ r.group_node_down_slack__dt = pd.DataFrame(index=s.dt_realize_dispatch)
182
+ for g in s.nodeGroupDispatch:
183
+ g_node = s.group_node[s.group_node.get_level_values('node').isin(s.node_balance) & s.group_node.get_level_values('group').isin([g])].get_level_values('node')
184
+ # Nodes with penalty_method == 'off' carry NO balance-slack variables,
185
+ # so they have no column in the slack frames. Intersect (order-
186
+ # preserving) with the available columns rather than raising
187
+ # ``KeyError: None of [...] are in the columns`` — a hardened node
188
+ # simply contributes 0 to the group slack.
189
+ g_node_up = g_node.intersection(r.upward_node_slack_dt.columns)
190
+ g_node_down = g_node.intersection(r.downward_node_slack_dt.columns)
191
+ r.group_node_up_slack__dt[g] = r.upward_node_slack_dt[g_node_up].sum(axis=1)
192
+ r.group_node_down_slack__dt[g] = r.downward_node_slack_dt[g_node_down].sum(axis=1)
@@ -0,0 +1,103 @@
1
+ import pandas as pd
2
+
3
+ from flextool.process_outputs._annualize import annualize_dt_to_d
4
+
5
+
6
+ def compute_slacks(par, s, v, r) -> None:
7
+ """Compute slack and reserve quantities.
8
+
9
+ Cost-term conventions (must match the LP objective in flextool.mod):
10
+
11
+ * **Per-timestep slack terms** (node state, inertia, non-sync, reserve)
12
+ are cost amounts held across each step. The mod multiplies them by
13
+ ``step_duration[d, t] * p_rp_cost_weight[d, t] *
14
+ p_inflation_factor_operations_yearly[d] /
15
+ complete_period_share_of_year[d] * pdt_branch_weight[d, t]``. Here
16
+ we store the *per-(d, t)* contribution with ``step_duration`` and
17
+ ``timestep_weight`` already baked in (so it can still be totalled
18
+ per-step by ``costs__dt.csv``). The remaining period-level factors
19
+ (``inflation`` and ``/ period_share``) are applied at
20
+ ``calc_costs.costPenalty_d`` where ``costPenalty_dt`` is aggregated.
21
+
22
+ * **Capacity-margin slack** is a per-period decision (no timestep
23
+ dimension). The mod term is
24
+ ``vq * group_capacity_for_scaling * penalty_capacity_margin * 1000 *
25
+ p_inflation_factor_operations_yearly``. No step_duration, no
26
+ timestep_weight, no period_share division (it is already a per-period
27
+ quantity).
28
+ """
29
+ # Per-step scaling factor for all per-(d, t) cost terms:
30
+ # step_duration × timestep_weight. Combined once for readability.
31
+ step_x_rp = par.step_duration.mul(par.timestep_weight, axis=0)
32
+
33
+ r.reserves_dt = v.reserve.mul(par.step_duration, axis=0)
34
+ # Average MW held over the period: Σ(MW × step_duration) / total period hours.
35
+ # Reserves are MW capacity held per hour (not consumed energy), so the
36
+ # meaningful per-period figure is the average MW held across the period.
37
+ period_hours = par.complete_period_share_of_year * 8760
38
+ r.reserves_d = r.reserves_dt.groupby('period').sum().div(period_hours, axis=0)
39
+
40
+ # upward_node_slack_dt is already MWh per step (q_state_up × capacity × step_duration),
41
+ # so the period aggregate is a plain groupby-sum; no extra step_duration.
42
+ # node_capacity_for_scaling / node_penalty_* are complete over
43
+ # nodeBalance ∪ nodeBalancePeriod (⊇ the q_state nodes) and solve-keyed
44
+ # (concat-unioned), so direct column
45
+ # indexing is safe — no missing-column densify needed here.
46
+ # The annualised ``_d`` reporting series weights each (d, t) by
47
+ # par.timestep_weight (=1.0 with no/uniform timeset_weights →
48
+ # byte-identical) before the period sum, matching the cost-weighted
49
+ # objective. The ``_not_annualized`` diagnostic sums (used for the raw
50
+ # Created/Removed slack-event reports) stay unweighted.
51
+ r.upward_node_slack_dt = v.q_state_up.mul(par.node_capacity_for_scaling[v.q_state_up.columns]).mul(par.step_duration, axis=0)
52
+ r.upward_node_slack_d_not_annualized = r.upward_node_slack_dt.groupby('period').sum()
53
+ r.upward_node_slack_d = annualize_dt_to_d(r.upward_node_slack_dt, par.timestep_weight, par.complete_period_share_of_year)
54
+ r.downward_node_slack_dt = v.q_state_down.mul(par.node_capacity_for_scaling[v.q_state_down.columns]).mul(par.step_duration, axis=0)
55
+ r.downward_node_slack_d_not_annualized = r.downward_node_slack_dt.groupby('period').sum()
56
+ r.downward_node_slack_d = annualize_dt_to_d(r.downward_node_slack_dt, par.timestep_weight, par.complete_period_share_of_year)
57
+ # Node-state slack penalty: apply timestep_weight (step_duration already in _slack_dt).
58
+ upward_node_penalty = r.upward_node_slack_dt.mul(par.node_penalty_up[v.q_state_up.columns]) \
59
+ .mul(par.timestep_weight, axis=0)
60
+ downward_node_penalty = r.downward_node_slack_dt.mul(par.node_penalty_down[v.q_state_down.columns]) \
61
+ .mul(par.timestep_weight, axis=0)
62
+ r.costPenalty_node_state_upDown_dt = pd.concat([upward_node_penalty, downward_node_penalty], axis=1, keys=['up', 'down'], names=['upDown'])
63
+ r.costPenalty_node_state_upDown_dt = r.costPenalty_node_state_upDown_dt.reorder_levels([1, 0], axis=1)
64
+ r.costPenalty_node_state_upDown_d = r.costPenalty_node_state_upDown_dt.groupby(level='period').sum()
65
+
66
+ # Inertia penalty: mod uses × step_duration × timestep_weight (objective line ~2380).
67
+ # Per-step cost = q × inertia_limit × penalty × step_duration × timestep_weight.
68
+ r.q_inertia_dt = v.q_inertia.mul(par.group_inertia_limit)
69
+ r.q_inertia_d_not_annualized = r.q_inertia_dt.mul(par.step_duration, axis=0).groupby('period').sum()
70
+ r.q_inertia_d = annualize_dt_to_d(r.q_inertia_dt, par.timestep_weight, par.complete_period_share_of_year, par.step_duration)
71
+ r.costPenalty_inertia_dt = r.q_inertia_dt.mul(par.group_penalty_inertia).mul(step_x_rp, axis=0)
72
+
73
+ # Non-synchronous penalty: mod uses × step_duration × timestep_weight (objective line ~2382).
74
+ r.q_non_synchronous_dt = v.q_non_synchronous.mul(par.group_capacity_for_scaling[s.groupNonSync])
75
+ r.q_non_synchronous_d_not_annualized = r.q_non_synchronous_dt.mul(par.step_duration, axis=0) \
76
+ .groupby('period').sum()
77
+ r.q_non_synchronous_d = annualize_dt_to_d(
78
+ r.q_non_synchronous_dt, par.timestep_weight,
79
+ par.complete_period_share_of_year, par.step_duration)
80
+ r.costPenalty_non_synchronous_dt = r.q_non_synchronous_dt \
81
+ .mul(par.group_penalty_non_synchronous) \
82
+ .mul(step_x_rp, axis=0)
83
+
84
+ # Capacity-margin penalty: per-period event; no step_duration, no rp_weight.
85
+ # Mod (~line 2414-2417): vq × group_capacity × penalty × 1000 × inflation.
86
+ # Penalty is expressed per kW in the template; the ×1000 converts to /MW.
87
+ r.q_capacity_margin_d_not_annualized = v.q_capacity_margin \
88
+ .mul(par.group_capacity_for_scaling[s.groupCapacityMargin])
89
+ r.costPenalty_capacity_margin_d = r.q_capacity_margin_d_not_annualized \
90
+ .mul(par.group_penalty_capacity_margin[s.groupCapacityMargin]) \
91
+ .mul(1000.0) \
92
+ .mul(par.inflation_factor_operations_yearly, axis=0) \
93
+ .sum(axis=1)
94
+
95
+ r.q_reserves_dt = v.q_reserve.mul(par.reserve_upDown_group_reservation[v.q_reserve.columns], axis=1)
96
+ r.q_reserves_d_not_annualized = r.q_reserves_dt.mul(par.step_duration, axis=0).groupby(level='period').sum()
97
+ r.q_reserves_d = annualize_dt_to_d(r.q_reserves_dt, par.timestep_weight, par.complete_period_share_of_year, par.step_duration)
98
+ # Reserve slack penalty: step_duration is already here (line below);
99
+ # add timestep_weight to match mod line ~2388.
100
+ r.costPenalty_reserve_upDown_dt = v.q_reserve.mul(par.step_duration, axis=0) \
101
+ .mul(par.timestep_weight, axis=0) \
102
+ .mul(par.reserve_upDown_group_penalty, axis=1) \
103
+ .mul(par.reserve_upDown_group_reservation, axis=1)
@@ -0,0 +1,160 @@
1
+ import pandas as pd
2
+
3
+ from flextool.process_outputs._annualize import annualize_dt_to_d
4
+
5
+
6
+ def compute_storage_and_vre(par, s, v, r) -> None:
7
+ """Compute storage state changes, self-discharge, node inflow, VRE potential, and storage usage."""
8
+ step_duration = par.step_duration
9
+ unitsize = par.entity_unitsize
10
+
11
+ # node_state_change_dt (four storage binding methods)
12
+ current_idx = s.dtt.droplevel('t_previous')
13
+ prev_period_idx = (
14
+ s.dtttdt
15
+ .droplevel(['time', 't_previous_within_timeset', 'd_previous', 't_previous_within_solve'])
16
+ .set_names(['period', 'time'])
17
+ )
18
+ prev_timeset_idx = (
19
+ s.dtttdt
20
+ .droplevel(['time', 't_previous', 'd_previous', 't_previous_within_solve'])
21
+ .set_names(['period', 'time'])
22
+ )
23
+ prev_forward_only_idx = (
24
+ s.dtttdt
25
+ .droplevel(['period', 'time', 't_previous', 't_previous_within_timeset'])
26
+ .set_names(['period', 'time'])
27
+ )
28
+ exclude_idx = s.period__time_first[
29
+ s.period__time_first.get_level_values('period').isin(s.period_first_of_solve)
30
+ ]
31
+
32
+ r_state_change = pd.DataFrame(0.0, index=s.dt_realize_dispatch, columns=s.node_state, dtype=float)
33
+ for n in s.node_state:
34
+ if n not in v.state.columns:
35
+ continue
36
+
37
+ state_change = pd.Series(0.0, index=current_idx)
38
+ # ``v.state[n]`` is keyed by every (period, time) that appears in
39
+ # any sub-solve's parquet (after the drop_levels dedup), whereas
40
+ # ``current_idx`` is filtered to ``dt_realize_dispatch``. Align
41
+ # v_current to current_idx so the arithmetic below has the same
42
+ # shape as ``mask`` — otherwise pandas raises
43
+ # "Array conditional must be same shape as self" on rolling /
44
+ # nested-multi-invest scenarios.
45
+ v_current = pd.Series(v.state[n].squeeze().reindex(current_idx).values, index=current_idx)
46
+ v_prev_period = pd.Series(v.state[n].squeeze().reindex(prev_period_idx).values, index=current_idx)
47
+ v_prev_timeblock = pd.Series(v.state[n].squeeze().reindex(prev_timeset_idx).values, index=current_idx)
48
+ v_forward = pd.Series(v.state[n].squeeze().reindex(prev_forward_only_idx).values, index=current_idx)
49
+
50
+ # After the single-valued migration (v54), each node appears in
51
+ # ``s.node__storage_binding_method`` with exactly one method string,
52
+ # so at most one of these branches fires per node. Pre-v54 DBs
53
+ # with array-valued methods were rejected at ingestion
54
+ # (spinedb_backend/_backend.py). The four if-blocks are left as
55
+ # independent membership checks for readability; their additive
56
+ # form is functionally equivalent to a switch under single-valued
57
+ # semantics.
58
+ if (n, 'bind_forward_only') in s.node__storage_binding_method:
59
+ mask = ~current_idx.isin(exclude_idx)
60
+ state_change += ((v_current - v_forward) * unitsize[n]).where(mask, 0)
61
+
62
+ if (n, 'bind_within_solve') in s.node__storage_binding_method:
63
+ state_change += (v_current - v_forward) * unitsize[n]
64
+
65
+ if (n, 'bind_within_period') in s.node__storage_binding_method:
66
+ state_change += (v_current - v_prev_period) * unitsize[n]
67
+
68
+ if (n, 'bind_within_timeblock') in s.node__storage_binding_method:
69
+ state_change += (v_current - v_prev_timeblock) * unitsize[n]
70
+
71
+ r_state_change[n] = state_change
72
+
73
+ r.node_state_change_dt = r_state_change
74
+ r.node_state_change_d = r.node_state_change_dt.groupby(level='period').sum()
75
+
76
+ # self_discharge_loss
77
+ # par.node_self_discharge_loss broadcasts the authored
78
+ # ``state_self_discharge`` param across every node that carries it,
79
+ # including supply-curve / commodity nodes that have no ``v_state``
80
+ # LP variable. Self-discharge is only meaningful on storage nodes,
81
+ # so restrict to the intersection with ``v.state.columns`` before
82
+ # multiplying — otherwise pandas raises ``KeyError: None of [...] are
83
+ # in the [columns]`` on real scenarios with non-storage authored
84
+ # values.
85
+ #
86
+ # In rolling / multi-solve scenarios ``par.node_self_discharge_loss``
87
+ # is built from the last sub-solve's ``flex_data.dt`` only, whereas
88
+ # ``v.state`` is concatenated across every sub-solve's parquet.
89
+ # Their (period, time) row indices then differ, and pandas raises
90
+ # ``TypeError: Join on level between two MultiIndex objects is
91
+ # ambiguous`` on the broadcast-multiply. The underlying
92
+ # ``p_state_self_discharge`` is dimensioned ``(n,)`` (one constant
93
+ # value per node — see ``read_parameters.py``), so collapse the
94
+ # per-(d, t) repeats to a per-node Series before broadcasting; the
95
+ # mul then aligns the Series's ``node`` axis against
96
+ # ``v.state.columns`` regardless of row index.
97
+ loss_cols = par.node_self_discharge_loss.columns.intersection(
98
+ v.state.columns
99
+ )
100
+ if len(loss_cols) > 0:
101
+ per_node_loss = par.node_self_discharge_loss[loss_cols].iloc[0]
102
+ r.self_discharge_loss_dt = (
103
+ v.state[loss_cols]
104
+ .mul(per_node_loss, axis='columns')
105
+ .mul(unitsize[loss_cols], axis='columns', level=0)
106
+ )
107
+ else:
108
+ r.self_discharge_loss_dt = pd.DataFrame(
109
+ 0.0, index=v.state.index, columns=[],
110
+ )
111
+ r.self_discharge_loss_d = r.self_discharge_loss_dt.mul(step_duration, axis=0).groupby('period').sum()
112
+
113
+ # node_inflow_d — weight each (d, t) by par.timestep_weight (=1.0 with
114
+ # no/uniform timeset_weights → byte-identical) before the period sum,
115
+ # matching the cost-weighted annualisation. par.node_inflow is already
116
+ # MWh/step, so no step_duration.
117
+ r.node_inflow_d = annualize_dt_to_d(par.node_inflow, par.timestep_weight, par.complete_period_share_of_year)
118
+
119
+ # potentialVREgen
120
+ vre_with_sink = s.process_VRE[s.process_VRE.isin(s.process_sink)]
121
+ vre_node_profile = vre_with_sink.join(s.process__node__profile__profile_method)
122
+ vre_node_profile_upper = vre_node_profile[
123
+ vre_node_profile.get_level_values('profile_method').isin(['upper_limit'])
124
+ ]
125
+ vre_profiles_in_use = par.profile[vre_node_profile_upper.get_level_values('profile').unique()]
126
+ profile_level_of_vre_node_profile_upper = vre_node_profile_upper.get_level_values('profile')
127
+ vre_processes_in_use = vre_profiles_in_use[profile_level_of_vre_node_profile_upper]
128
+ vre_processes_in_use.columns = vre_node_profile_upper
129
+ r.potentialVREgen_dt = (
130
+ vre_processes_in_use
131
+ .mul(par.process_availability)
132
+ .mul(r.entity_all_capacity, axis=1, level=0)
133
+ .droplevel(axis=1, level=['profile', 'profile_method'])
134
+ )
135
+ # potentialVREgen_dt is MW (profile × availability × capacity); multiply
136
+ # by step_duration to get MWh per step before summing to MWh per period.
137
+ r.potentialVREgen_d = (
138
+ r.potentialVREgen_dt.mul(step_duration, axis=0).groupby('period').sum()
139
+ )
140
+
141
+ # storage_usage_dt (for nested model storage fixing)
142
+ dt_fix_idx = s.dt_fix_storage_timesteps
143
+ r_storage_usage = {}
144
+ for n in s.node:
145
+ if (n, 'fix_usage') in s.node__storage_nested_fix_method:
146
+ usage = pd.Series(0.0, index=dt_fix_idx)
147
+ for col in r.flow_dt.columns:
148
+ p, source, sink = col
149
+ if source == n:
150
+ usage += (r.flow_dt[col].reindex(dt_fix_idx, fill_value=0) *
151
+ par.step_duration.reindex(dt_fix_idx, fill_value=0))
152
+ if sink == n:
153
+ usage -= (r.flow_dt[col].reindex(dt_fix_idx, fill_value=0) *
154
+ par.step_duration.reindex(dt_fix_idx, fill_value=0))
155
+ r_storage_usage[n] = usage
156
+ r.storage_usage_dt = (
157
+ pd.DataFrame(r_storage_usage, dtype=float)
158
+ if r_storage_usage
159
+ else pd.DataFrame(0.0, index=dt_fix_idx, columns=[])
160
+ )