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,189 @@
1
+ """Output functions for flow group results.
2
+
3
+ Groups that have ``output_flowGroup_indicators = yes`` appear in
4
+ ``s.flowGroupIndicators``. Their member flows are listed in
5
+ ``s.group_process_node`` — each tuple ``(group, process, node)`` identifies
6
+ one unit-flow or connection-flow leg that should be summed into the group.
7
+
8
+ The metrics emitted here are intentionally minimal (a stub); richer
9
+ direction-aware or technology-aware metrics are future work.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import pandas as pd
14
+
15
+
16
+ def _member_legs(s, g: str) -> pd.MultiIndex:
17
+ """Return the ``(process, node)`` legs belonging to group ``g``.
18
+
19
+ ``s.group_process_node`` is a ``(group, process, node)`` MultiIndex;
20
+ filtering by the ``group`` level gives the flows aggregated into this
21
+ group. The returned index drops the ``group`` level so callers can
22
+ match against ``r.flow_dt``/``r.from_conn``/``r.to_conn`` columns
23
+ (which are ``(process, node)`` after de-duplication).
24
+ """
25
+ gpn = s.group_process_node
26
+ mask = gpn.get_level_values('group') == g
27
+ return gpn[mask].droplevel('group').unique()
28
+
29
+
30
+ def flowGroup_indicators(par, s, v, r, debug):
31
+ """Flow-group indicator results by period and a signed timewise series.
32
+
33
+ For each group in ``s.flowGroupIndicators`` two quantities are built per
34
+ timestep:
35
+
36
+ * ``magnitude_dt`` — the UNSIGNED sum of all member unit- and
37
+ connection-flow magnitudes (``.abs()``). This is weighted by step
38
+ duration to get energy per period and divided by period hours for the
39
+ average power. This path is unchanged (byte-identical).
40
+ * ``net_dt`` — the SIGNED net flow into the group's nodes (into-node
41
+ ``+``, out-of-node ``−``; matches ``calc_group_flows``/
42
+ ``calc_connections``). A unit leg whose ``node`` is the flow's *sink*
43
+ counts ``+`` (delivery into the node); whose ``node`` is the *source*
44
+ counts ``−`` (withdrawal). Connection ``from_conn`` (into node) counts
45
+ ``+`` and ``to_conn`` (out of node) counts ``−``. No ``.abs()`` and no
46
+ step-duration weighting — it is MW per timestep.
47
+
48
+ Returns a list of two ``(frame, name)`` tuples:
49
+
50
+ * ``flowGroup_gd_p`` — keyed ``(group, period)`` with columns
51
+ ``cumulative_flow`` [MWh] and ``average_flow`` [MW]. A group with no
52
+ matched member flows gets zeros rather than being dropped.
53
+ * ``flowGroup_gd_t`` — keyed ``(group, period, time)`` with the single
54
+ column ``net_flow`` [MW], the signed per-timestep net flow.
55
+ """
56
+ results: list[tuple[pd.DataFrame, str]] = []
57
+
58
+ if not list(s.flowGroupIndicators) or s.dt_realize_dispatch.empty:
59
+ return results
60
+
61
+ step_dur = par.step_duration # Series indexed by (period, time), MW→MWh weight
62
+ dt_index = s.dt_realize_dispatch
63
+
64
+ # Pre-compute lookups of member columns. ``r.flow_dt`` is keyed by
65
+ # (process, source, sink) — a unit flow touches a group's (process,
66
+ # node) membership whenever the node is the source *or* the sink.
67
+ # ``r.from_conn``/``r.to_conn`` are already keyed by (process, node),
68
+ # so membership is a direct column pick.
69
+ flow_cols = r.flow_dt.columns
70
+ flow_sink_pairs = None
71
+ flow_source_pairs = None
72
+ if not r.flow_dt.empty:
73
+ flow_sink_pairs = pd.MultiIndex.from_arrays(
74
+ [flow_cols.get_level_values('process'), flow_cols.get_level_values('sink')],
75
+ names=['process', 'node'],
76
+ )
77
+ flow_source_pairs = pd.MultiIndex.from_arrays(
78
+ [flow_cols.get_level_values('process'), flow_cols.get_level_values('source')],
79
+ names=['process', 'node'],
80
+ )
81
+
82
+ rows: list[dict] = []
83
+ net_rows: list[dict] = []
84
+ # Period hours = share of year * 8760 (same convention used by
85
+ # out_flows.unit_online_and_startup for period-hour normalization).
86
+ period_hours = par.complete_period_share_of_year.mul(8760.0)
87
+
88
+ for g in s.flowGroupIndicators:
89
+ legs = _member_legs(s, g) # MultiIndex[(process, node)]
90
+
91
+ # Accumulate |flow| (MW) at each timestep (UNSIGNED magnitude path).
92
+ magnitude_dt = pd.Series(0.0, index=dt_index)
93
+ # Accumulate signed net flow (MW) into the group's nodes at each
94
+ # timestep (into-node +, out-of-node −).
95
+ net_dt = pd.Series(0.0, index=dt_index)
96
+
97
+ if len(legs) and flow_sink_pairs is not None:
98
+ # Unit flows — pick every (process, source, sink) column whose
99
+ # (process, sink) or (process, source) is a member leg.
100
+ sink_hits = flow_sink_pairs.isin(legs)
101
+ source_hits = flow_source_pairs.isin(legs)
102
+ is_unit = flow_cols.get_level_values('process').isin(s.process_unit)
103
+ unit_mask = is_unit & (sink_hits | source_hits)
104
+ if unit_mask.any():
105
+ magnitude_dt = magnitude_dt.add(
106
+ r.flow_dt.loc[:, unit_mask].abs().sum(axis=1), fill_value=0.0,
107
+ )
108
+
109
+ # Signed: node==sink (unit delivers into the node) counts +,
110
+ # node==source (unit withdraws from the node) counts −.
111
+ unit_into = is_unit & sink_hits
112
+ unit_out = is_unit & source_hits
113
+ if unit_into.any():
114
+ net_dt = net_dt.add(
115
+ r.flow_dt.loc[:, unit_into].sum(axis=1), fill_value=0.0,
116
+ )
117
+ if unit_out.any():
118
+ net_dt = net_dt.sub(
119
+ r.flow_dt.loc[:, unit_out].sum(axis=1), fill_value=0.0,
120
+ )
121
+
122
+ # Connection flows — from_conn and to_conn are already keyed by
123
+ # (process, node). Pick every column whose key is a member leg.
124
+ if not r.from_conn.empty:
125
+ conn_legs_from = r.from_conn.columns.intersection(legs)
126
+ if len(conn_legs_from):
127
+ magnitude_dt = magnitude_dt.add(
128
+ r.from_conn[conn_legs_from].abs().sum(axis=1), fill_value=0.0,
129
+ )
130
+ # from_conn flows into the node → +.
131
+ net_dt = net_dt.add(
132
+ r.from_conn[conn_legs_from].sum(axis=1), fill_value=0.0,
133
+ )
134
+ if not r.to_conn.empty:
135
+ conn_legs_to = r.to_conn.columns.intersection(legs)
136
+ if len(conn_legs_to):
137
+ magnitude_dt = magnitude_dt.add(
138
+ r.to_conn[conn_legs_to].abs().sum(axis=1), fill_value=0.0,
139
+ )
140
+ # to_conn flows out of the node → −.
141
+ net_dt = net_dt.sub(
142
+ r.to_conn[conn_legs_to].sum(axis=1), fill_value=0.0,
143
+ )
144
+
145
+ # Weight MW → MWh per step, then sum to period level.
146
+ energy_dt = magnitude_dt.mul(step_dur, fill_value=0.0)
147
+ cumulative_d = energy_dt.groupby(level='period').sum()
148
+ # Reindex to the full realized-period set so groups with no matched
149
+ # flows still produce a row per realized period.
150
+ cumulative_d = cumulative_d.reindex(s.d_realized_period, fill_value=0.0)
151
+
152
+ hours_d = period_hours.reindex(cumulative_d.index)
153
+ average_d = cumulative_d.div(hours_d.where(hours_d != 0, pd.NA))
154
+ average_d = average_d.fillna(0.0)
155
+
156
+ for period in cumulative_d.index:
157
+ rows.append({
158
+ 'group': g,
159
+ 'period': period,
160
+ 'cumulative_flow': float(cumulative_d.loc[period]),
161
+ 'average_flow': float(average_d.loc[period]),
162
+ })
163
+
164
+ # Signed per-timestep net flow (MW) — one row per realized (period,
165
+ # time). No step-duration weighting; this is instantaneous power.
166
+ for (period, time), value in net_dt.items():
167
+ net_rows.append({
168
+ 'group': g,
169
+ 'period': period,
170
+ 'time': time,
171
+ 'net_flow': float(value),
172
+ })
173
+
174
+ if not rows:
175
+ return results
176
+
177
+ frame = pd.DataFrame(rows).set_index(['group', 'period'])[
178
+ ['cumulative_flow', 'average_flow']
179
+ ]
180
+ frame.columns.name = 'parameter'
181
+ results.append((frame, 'flowGroup_gd_p'))
182
+
183
+ if net_rows:
184
+ net_frame = pd.DataFrame(net_rows).set_index(
185
+ ['group', 'period', 'time'])[['net_flow']]
186
+ net_frame.columns.name = 'parameter'
187
+ results.append((net_frame, 'flowGroup_gd_t'))
188
+
189
+ return results
@@ -0,0 +1,301 @@
1
+ import pandas as pd
2
+
3
+ from flextool.process_outputs._annualize import annualize_dt_to_d
4
+
5
+
6
+ # Threshold below which LP residuals on flow variables are treated as exact
7
+ # zero. HiGHS's primal feasibility tolerance is 1e-8 (see HiGHS options),
8
+ # so any output below 1e-9 is conclusively below solver precision and
9
+ # represents zero. Without this clip the wide-format flow CSVs round-trip
10
+ # through ``pd.read_csv`` as ``float64`` while integer-zero goldens load
11
+ # as ``int64``, causing ``pd.testing.assert_frame_equal`` to fail on dtype
12
+ # even when values match under ``round_for_comparison``'s 4-decimal
13
+ # threshold.
14
+ #
15
+ # Apply ONLY to the wide output frames destined for CSV write — never to
16
+ # ``r.flow_dt`` itself, because curtailment derives ``potentialVRE - flow``
17
+ # and small physically-meaningful curtailment values could otherwise be
18
+ # masked.
19
+ _FLOW_RESIDUAL_TOLERANCE: float = 1e-9
20
+
21
+
22
+ def _clip_flow_residuals(df: pd.DataFrame) -> pd.DataFrame:
23
+ """Snap sub-femto LP residuals to exactly zero.
24
+
25
+ Returns a frame where every cell with ``|x| < 1e-9`` is replaced with
26
+ ``0.0``. Empty / all-NaN frames are returned unchanged.
27
+ """
28
+ if df.empty:
29
+ return df
30
+ return df.mask(df.abs() < _FLOW_RESIDUAL_TOLERANCE, 0.0)
31
+
32
+
33
+ def _indirect_units(s) -> set:
34
+ """Return set of units classified as indirect (2-arc conversion form).
35
+
36
+ An indirect unit has BOTH a row where ``source == p`` and a row where
37
+ ``sink == p`` in ``process_source_sink`` — i.e. the topology was split
38
+ into separate input-side and output-side arcs. Direct-method and
39
+ profile/noConversion units have at most one of those, never both.
40
+
41
+ This mirrors v3.32.0's ordering convention for the wide-pivot output
42
+ CSVs (``unit__outputNode__dt.csv`` etc.): direct-method units listed
43
+ first (in alphabetical order), then indirect units. v3.32.0 inherited
44
+ the partition from the legacy ``write_process_arc_unions`` union order
45
+ (process_arc_unions.py L248-263) which placed ``*_direct.csv`` sets
46
+ ahead of the indirect/noConversion ones.
47
+ """
48
+ if s.process_source_sink is None or len(s.process_source_sink) == 0:
49
+ return set()
50
+ procs = s.process_source_sink.get_level_values('process')
51
+ srcs = s.process_source_sink.get_level_values('source')
52
+ snks = s.process_source_sink.get_level_values('sink')
53
+ as_source = {p for p, src in zip(procs, srcs) if src == p}
54
+ as_sink = {p for p, snk in zip(procs, snks) if snk == p}
55
+ return as_source & as_sink
56
+
57
+
58
+ def _sort_unit_node_columns(df: pd.DataFrame, indirect: set) -> pd.DataFrame:
59
+ """Reorder a (unit, node) MultiIndex column frame to match v3.32.0.
60
+
61
+ Partition: direct units first, indirect units last. Alphabetical by
62
+ (unit, node) within each partition.
63
+ """
64
+ if df.empty or df.columns.empty:
65
+ return df
66
+ sorted_cols = sorted(
67
+ df.columns,
68
+ key=lambda c: (c[0] in indirect, c[0], c[1]),
69
+ )
70
+ return df.reindex(columns=pd.MultiIndex.from_tuples(
71
+ sorted_cols, names=df.columns.names,
72
+ ))
73
+
74
+
75
+ def unit_outputNode(par, s, v, r, debug):
76
+ """Unit output node flow for periods and time"""
77
+
78
+ results = []
79
+
80
+ if r.flow_dt.empty:
81
+ return results
82
+
83
+ # Calculate timestep-level results first
84
+ result_multi_dt = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_tuples([], names=['unit', 'node']))
85
+
86
+ # Filter columns: unit processes that have sinks
87
+ unit_sink_cols = r.flow_dt.columns[
88
+ r.flow_dt.columns.get_level_values(0).isin(s.process_unit) &
89
+ r.flow_dt.columns.to_series().apply(lambda col: (col[0], col[2]) in s.process_sink)
90
+ ]
91
+
92
+ for col in unit_sink_cols:
93
+ u, source, sink = col
94
+ result_multi_dt[(u, sink)] = r.flow_dt[col]
95
+
96
+ # Reorder columns to match v3.32.0's direct-then-indirect / alphabetical
97
+ # ordering — replaces the upstream ``flow_dt.columns`` order which is
98
+ # non-deterministic at HEAD (polars ``unique()`` after sort).
99
+ indirect = _indirect_units(s)
100
+ result_multi_dt = _sort_unit_node_columns(result_multi_dt, indirect)
101
+
102
+ # Snap sub-femto HiGHS LP residuals to exact zero before emitting so the
103
+ # CSV round-trip preserves the int dtype of zero-valued columns.
104
+ result_multi_dt = _clip_flow_residuals(result_multi_dt)
105
+
106
+ # Return timestep results
107
+ results.append((result_multi_dt, 'unit_outputNode_dt_ee'))
108
+
109
+ # Aggregate to period level. v_flow is in MW (MWh/h) — multiply by
110
+ # step_duration to get MWh per step, weight each (d, t) by
111
+ # par.timestep_weight (=1.0 with no/uniform timeset_weights →
112
+ # byte-identical), then sum and annualize.
113
+ result_multi_d = annualize_dt_to_d(
114
+ result_multi_dt, par.timestep_weight,
115
+ par.complete_period_share_of_year, par.step_duration,
116
+ )
117
+ result_multi_d = _clip_flow_residuals(result_multi_d)
118
+
119
+ # Return period results
120
+ results.append((result_multi_d, 'unit_outputNode_d_ee'))
121
+
122
+ return results
123
+
124
+
125
+ def unit_inputNode(par, s, v, r, debug):
126
+ """Unit input node flow for periods and time"""
127
+
128
+ results = []
129
+
130
+ if r.flow_dt.empty:
131
+ return results
132
+
133
+ # Calculate timestep-level results first
134
+ result_multi_dt = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_tuples([], names=['unit', 'node']))
135
+
136
+ # Filter columns: unit processes that have sources
137
+ unit_source_cols = r.flow_dt.columns[
138
+ r.flow_dt.columns.get_level_values(0).isin(s.process_unit) &
139
+ r.flow_dt.columns.to_series().apply(lambda col: (col[0], col[1]) in s.process_source)
140
+ ]
141
+
142
+ for col in unit_source_cols:
143
+ u, source, sink = col
144
+ result_multi_dt[(u, source)] = -r.flow_dt[col]
145
+
146
+ # Reorder columns to match v3.32.0's direct-then-indirect / alphabetical
147
+ # ordering (see ``unit_outputNode`` for rationale).
148
+ indirect = _indirect_units(s)
149
+ result_multi_dt = _sort_unit_node_columns(result_multi_dt, indirect)
150
+
151
+ # Snap sub-femto HiGHS LP residuals to exact zero (see ``unit_outputNode``).
152
+ result_multi_dt = _clip_flow_residuals(result_multi_dt)
153
+
154
+ # Return timestep results
155
+ results.append((result_multi_dt, 'unit_inputNode_dt_ee'))
156
+
157
+ # Aggregate to period level. v_flow is MW — multiply by step_duration
158
+ # to get MWh per step, weight each (d, t) by par.timestep_weight (=1.0
159
+ # with no/uniform timeset_weights → byte-identical), then sum/annualize.
160
+ result_multi_d = annualize_dt_to_d(
161
+ result_multi_dt, par.timestep_weight,
162
+ par.complete_period_share_of_year, par.step_duration,
163
+ )
164
+ result_multi_d = _clip_flow_residuals(result_multi_d)
165
+
166
+ # Return period results
167
+ results.append((result_multi_d, 'unit_inputNode_d_ee'))
168
+
169
+ return results
170
+
171
+
172
+ def unit_cf_outputNode(par, s, v, r, debug):
173
+ """Unit capacity factors by output node for periods"""
174
+ complete_hours = par.complete_period_share_of_year * 8760
175
+ unit_cols = r.process_sink_flow_d.columns[r.process_sink_flow_d.columns.get_level_values(0).isin(s.process_unit)]
176
+ unit_capacity = r.entity_all_capacity[unit_cols.droplevel(1).unique()].rename_axis('process', axis=1)
177
+ unit_capacity.columns = unit_capacity.columns.get_level_values(0)
178
+ results = r.process_sink_flow_d[unit_cols].div(unit_capacity, level=0).div(complete_hours, axis=0)
179
+ # Capacity factor is undefined for units with no capacity (e.g. an un-built
180
+ # investment candidate, capacity == 0). Dividing a tiny LP residual flow by
181
+ # zero capacity yields +/-inf; collapse those to NaN so downstream reductions
182
+ # (plot sums/means) skip them instead of summing +inf and -inf into a
183
+ # spurious NaN (RuntimeWarning: invalid value encountered in reduce).
184
+ results = results.replace([float('inf'), float('-inf')], float('nan'))
185
+ results.columns.names = ['unit', 'sink']
186
+ return results, 'unit_outputs_cf_d_ee'
187
+
188
+
189
+ def unit_cf_inputNode(par, s, v, r, debug):
190
+ """Unit capacity factors by input node for periods"""
191
+ # !!! This should account for efficiency losses in direct conversion units (but it does not)
192
+ complete_hours = par.complete_period_share_of_year * 8760
193
+ unit_source = r.process_source_flow_d.columns[r.process_source_flow_d.columns.get_level_values(0).isin(s.process_unit)]
194
+ unit_capacity = r.entity_all_capacity[unit_source.droplevel(1).unique()].rename_axis('process', axis=1)
195
+ unit_capacity.columns = unit_capacity.columns.get_level_values(0)
196
+ results = r.process_source_flow_d[unit_source].div(unit_capacity, level=0).div(complete_hours, axis=0)
197
+ # CF undefined for zero-capacity units -> NaN (see unit_cf_outputNode).
198
+ results = results.replace([float('inf'), float('-inf')], float('nan'))
199
+ results.columns.names = ['unit', 'source']
200
+ return results, 'unit_inputs_cf_d_ee'
201
+
202
+
203
+ def unit_VRE_curtailment_and_potential(par, s, v, r, debug):
204
+ """Unit VRE curtailment and potential for both periods and timesteps"""
205
+
206
+ results = []
207
+ vre_processes = s.process_VRE.unique()
208
+
209
+ # Timestep-level curtailment (absolute values) - calculate first
210
+ if not r.flow_dt.empty and not r.potentialVREgen_dt.empty:
211
+ curtail_dt = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_tuples([], names=['unit', 'sink']))
212
+ potential_dt = pd.DataFrame(index=s.dt_realize_dispatch, columns=pd.MultiIndex.from_tuples([], names=['unit', 'sink']))
213
+
214
+ for col in r.flow_dt.columns:
215
+ u, source, sink = col
216
+ if u in vre_processes and (u, sink) in s.process_sink and (u, sink) in r.potentialVREgen_dt.columns:
217
+ curtail_dt[u, sink] = r.potentialVREgen_dt[(u, sink)] - r.flow_dt[col]
218
+ potential_dt[u, sink] = r.potentialVREgen_dt[(u, sink)]
219
+
220
+ results.append((curtail_dt, 'unit_curtailment_outputNode_dt_ee'))
221
+ results.append((potential_dt, 'unit_VRE_potential_outputNode_dt_ee'))
222
+
223
+ # Calculate curtailment share at timestep level
224
+ curtail_share_dt = (curtail_dt / potential_dt).where(potential_dt != 0, 0)
225
+ results.append((curtail_share_dt, 'unit_curtailment_share_outputNode_dt_ee'))
226
+
227
+ # Aggregate to period level. curtail_dt / potential_dt are MW —
228
+ # multiply by step_duration to get MWh per step, then weight each
229
+ # (d, t) by par.timestep_weight (=1.0 with no/uniform timeset_weights
230
+ # → byte-identical) before summing. Both numerator and denominator
231
+ # carry the same weight, so the curtailment-share ratio below is
232
+ # unchanged; only the absolute MWh outputs move.
233
+ curtail_period = (
234
+ curtail_dt.mul(par.step_duration, axis=0)
235
+ .mul(par.timestep_weight, axis=0)
236
+ .groupby(level='period').sum()
237
+ )
238
+ potential_period = (
239
+ potential_dt.mul(par.step_duration, axis=0)
240
+ .mul(par.timestep_weight, axis=0)
241
+ .groupby(level='period').sum()
242
+ )
243
+
244
+ # Calculate curtailment share at period level (ratio — step_duration cancels)
245
+ curtail_share_period = (curtail_period / potential_period).where(potential_period != 0, 0)
246
+
247
+ results.append((curtail_period, 'unit_curtailment_outputNode_d_ee'))
248
+ results.append((curtail_share_period, 'unit_curtailment_share_outputNode_d_ee'))
249
+ results.append((potential_period, 'unit_VRE_potential_outputNode_d_ee'))
250
+
251
+ return results
252
+
253
+
254
+ def unit_ramps(par, s, v, r, debug):
255
+ """Unit ramps by input and output nodes for timesteps"""
256
+ results = []
257
+ if r.ramp_dtt.empty:
258
+ return results
259
+
260
+ # Output node ramps
261
+ process_sink_ramp_output = s.process_sink[s.process_sink.get_level_values(0).isin(s.process_unit)]
262
+ pss_ramp_output = r.ramp_dtt.columns[r.ramp_dtt.columns.droplevel(1).isin(process_sink_ramp_output)]
263
+ ramp_output = r.ramp_dtt[pss_ramp_output].droplevel('t_previous')
264
+ ramp_output.columns = ramp_output.columns.droplevel(1) # Remove 'source' from (process, source, sink) to get (unit, source)
265
+ ramp_output.columns.names = ['unit', 'sink']
266
+ results.append((ramp_output, 'unit_ramp_outputs_dt_ee'))
267
+
268
+ # Input node ramps
269
+ process_source_ramp_input = s.process_source[s.process_source.get_level_values(0).isin(s.process_unit)]
270
+ pss_ramp_input = r.ramp_dtt.columns[r.ramp_dtt.columns.droplevel(2).isin(process_source_ramp_input)]
271
+ ramp_input = r.ramp_dtt[pss_ramp_input].droplevel('t_previous')
272
+ ramp_input.columns = ramp_input.columns.droplevel(2) # Remove 'sink' from (process, source, sink) to get (unit, source)
273
+ ramp_input.columns.names = ['unit', 'source']
274
+ results.append((ramp_input, 'unit_ramp_inputs_dt_ee'))
275
+
276
+ return results
277
+
278
+
279
+ def unit_online_and_startup(par, s, v, r, debug):
280
+ """Unit online status and startups for timesteps and periods"""
281
+ results = []
282
+
283
+ # 1. Online units dt
284
+ online_units_dt = r.process_online_dt[s.process_unit.intersection(s.process_online)]
285
+ results.append((online_units_dt, 'unit_online_dt_e'))
286
+
287
+ # 2. Average online status at period level (weighted by step_duration)
288
+ complete_hours = par.complete_period_share_of_year * 8760
289
+ online_units_d = online_units_dt.mul(par.step_duration, axis=0).groupby('period').sum().div(complete_hours, axis=0)
290
+ results.append((online_units_d, 'unit_online_average_d_e'))
291
+
292
+ # 3. Startups annualized to period level. Raw startups only cover modeled
293
+ # timesteps; weight each by p_timestep_weight and divide by the period share
294
+ # of a year so the value is an annual count (same convention as flows).
295
+ units_online = s.process_unit.intersection(s.process_online)
296
+ startup_units_d = annualize_dt_to_d(
297
+ r.process_startup_dt[units_online], par.timestep_weight,
298
+ par.complete_period_share_of_year)
299
+ results.append((startup_units_d, 'unit_startup_d_e'))
300
+
301
+ return results