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,1286 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import re
5
+ import time
6
+ import warnings
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ import matplotlib
11
+ matplotlib.use('Agg') # Use non-interactive backend; must precede any pyplot import
12
+ import pandas as pd
13
+ import yaml
14
+ from spinedb_api import DatabaseMapping, from_database, Array
15
+
16
+ from flextool.lean_parquet import read_lean_parquet, write_lean_parquet
17
+ from flextool.process_outputs._output_meta import (
18
+ datapackage_resource,
19
+ derive_column_meta,
20
+ output_metadata_rows,
21
+ )
22
+ from flextool.plot_outputs.orchestrator import plot_dict_of_dataframes
23
+ from flextool.plot_outputs.color_template import resolve_plot_settings_path
24
+ from flextool.process_outputs.out_ancillary import (
25
+ connection, connection_wards, connection_cf,
26
+ reserves, investment_duals, inertia_results,
27
+ slack_variables, input_sets, dc_power_flow,
28
+ co2_duals,
29
+ )
30
+ from flextool.process_outputs.out_capacity import unit_capacity, connection_capacity, node_capacity
31
+ from flextool.process_outputs.out_costs import (
32
+ generic, cost_summaries, cost_breakdown_by_entity, CO2,
33
+ )
34
+ from flextool.process_outputs.out_flowgroup import flowGroup_indicators
35
+ from flextool.process_outputs.out_flows import (
36
+ unit_outputNode, unit_inputNode,
37
+ unit_cf_outputNode, unit_cf_inputNode,
38
+ unit_VRE_curtailment_and_potential, unit_ramps,
39
+ unit_online_and_startup,
40
+ )
41
+ from flextool.process_outputs.out_group import (
42
+ nodeGroup_indicators, nodeGroup_VRE_share,
43
+ nodeGroup_total_inflow, nodeGroup_flows,
44
+ )
45
+ from flextool.process_outputs.out_node import node_summary, node_additional_results
46
+ from flextool.process_outputs.process_results import post_process_results
47
+ from flextool.process_outputs.read_parameters import (
48
+ read_parameters,
49
+ read_parameters_multi,
50
+ )
51
+ from flextool.process_outputs.read_sets import (
52
+ read_sets,
53
+ read_sets_multi,
54
+ )
55
+ from flextool.process_outputs.read_variables import read_variables
56
+ from flextool.process_outputs.union_realized_slice import (
57
+ has_persisted_slices,
58
+ union_params,
59
+ union_sets,
60
+ )
61
+ from flextool.process_outputs.write_spinedb import write_spinedb
62
+
63
+
64
+ def _parse_rename_entry(entry) -> tuple[str, bool]:
65
+ """Parse a rename map value: ``[display_name, export_to_excel]``.
66
+
67
+ Returns (display_name, export_to_excel).
68
+ """
69
+ if isinstance(entry, list) and len(entry) >= 2:
70
+ return str(entry[0]), bool(entry[1])
71
+ # Bare string (legacy) — treat as name with export=True
72
+ return str(entry), True
73
+
74
+
75
+ def _frame_axes(df) -> tuple[list, list]:
76
+ """Return ``(index_level_names, measure_columns)`` for a DataFrame or
77
+ Series — the dimension + measure axes the CSV datapackage describes.
78
+
79
+ A Series (e.g. a single-value summary row) has its ``name`` as its one
80
+ measure column, ``'value'`` when unnamed.
81
+ """
82
+ index_names = list(df.index.names)
83
+ if isinstance(df, pd.Series):
84
+ return index_names, [df.name if df.name is not None else 'value']
85
+ return index_names, list(df.columns)
86
+
87
+
88
+ def write_excel_with_metadata(excel_path, sheets) -> None:
89
+ """Write ``sheets`` to ``excel_path`` with embedded output metadata.
90
+
91
+ ``sheets`` is a list of ``(sheet_name, raw_output_key, df)``. Each frame
92
+ is written to its own sheet; per-column unit/semantics (derived from the
93
+ metadata catalog via the raw output key) are surfaced two ways:
94
+
95
+ * header-cell **comments** (hover notes) on the measure columns of
96
+ single-level-column frames, and
97
+ * a trailing ``_output_metadata`` index sheet listing every documented
98
+ ``(sheet, output, column, unit, semantics, tooltip, formula)``.
99
+
100
+ The data grid itself is untouched — metadata rides alongside it.
101
+ """
102
+ meta_rows: list[dict] = []
103
+ with pd.ExcelWriter(excel_path, engine='xlsxwriter') as writer:
104
+ for sheet_name, raw_key, df in sheets:
105
+ df.to_excel(writer, sheet_name=sheet_name)
106
+ _, measure_cols = _frame_axes(df)
107
+ rows = output_metadata_rows(raw_key, measure_cols)
108
+ for r in rows:
109
+ meta_rows.append({"sheet": sheet_name, **r})
110
+ # Header comments only for single-level DataFrame columns — pandas
111
+ # writes multi-level column headers across several rows (plus an
112
+ # index-name row), which the simple (0, nidx+j) anchor below would
113
+ # not address correctly; the _output_metadata sheet still covers
114
+ # those (and Series-valued sheets).
115
+ if rows and isinstance(df, pd.DataFrame) and df.columns.nlevels == 1:
116
+ worksheet = writer.sheets[sheet_name]
117
+ nidx = df.index.nlevels
118
+ by_col = {r["column"]: r for r in rows}
119
+ for j, col in enumerate(df.columns):
120
+ r = by_col.get(str(col))
121
+ if r is None:
122
+ continue
123
+ note = [f"unit: {r['unit'] or '—'}",
124
+ f"semantics: {r['semantics']}"]
125
+ if r["tooltip"]:
126
+ note.append(r["tooltip"])
127
+ if r["formula"]:
128
+ note.append(f"formula: {r['formula']}")
129
+ worksheet.write_comment(0, nidx + j, "\n".join(note))
130
+ if meta_rows:
131
+ meta_df = pd.DataFrame(
132
+ meta_rows,
133
+ columns=["sheet", "output", "column", "unit",
134
+ "semantics", "tooltip", "long", "formula"],
135
+ )
136
+ meta_df.to_excel(writer, sheet_name="_output_metadata", index=False)
137
+
138
+
139
+ def _provider_lookup_df(provider, parent: str, stem: str):
140
+ """Return the polars frame for ``<parent>/<stem>`` from *provider*,
141
+ or ``None`` when the Provider doesn't carry it.
142
+
143
+ Provider key convention matches the emit_* writers' ``_emit(...)``
144
+ callsites — ``"<parent>/<basename-without-csv>"`` (see
145
+ :mod:`flextool.engine_polars._emit_provider_io`). The cascade keeps
146
+ these frames in memory only (no on-disk fallback), so a ``None``
147
+ *provider* yields ``None`` for every key.
148
+ """
149
+ if provider is None:
150
+ return None
151
+ name = f"{parent}/{stem}" if parent else stem
152
+ if provider.has(name):
153
+ return provider.get(name)
154
+ return None
155
+
156
+
157
+ def _backfill_group_indicator_sets(s, output_dir, *, provider=None):
158
+ """Populate ``nodeGroupDispatch`` / ``*Indicators`` from cascade outputs.
159
+
160
+ These three sets are derived from per-group user parameters
161
+ (``output_nodeGroup_dispatch`` / ``output_nodeGroup_indicators`` /
162
+ ``output_flowGroup_indicators``). No FlexData field carries them
163
+ today; ``read_sets`` therefore hardcodes them empty. Without this
164
+ backfill, every ``nodeGroup_flows`` / ``flowGroup_indicators`` writer
165
+ short-circuits to "empty" and the group_flows__dt / group_flows__d /
166
+ flowGroup CSVs go missing.
167
+
168
+ Also backfills ``s.group_process_node`` — the flowGroup membership
169
+ MultiIndex consumed by ``out_flowgroup.flowGroup_indicators`` — from the
170
+ Provider key ``input/flowGroup__process__node``. ``read_sets`` leaves it
171
+ empty (no FlexData field carries it), so without this backfill the
172
+ flowGroup indicators sum nothing and emit all-zero rows in production.
173
+
174
+ Also backfills the 12 ``nodeGroupDispatch__*`` arc-union MultiIndex
175
+ sets emitted by ``flextool/engine_polars/_emit_arc_unions.py``.
176
+ Without these, ``calc_group_flows`` finds zero rows for the unit /
177
+ connection aggregator joins, and ``out_group.nodeGroup_flows`` emits
178
+ only the slack/inflow/loss column families — group_flows__dt loses
179
+ its ``from_unitGroup`` / ``from_unit`` / ``from_connectionGroup`` /
180
+ ``to_connectionGroup`` / per-connection ``internal_losses`` columns.
181
+
182
+ Provider-only: reads come from the per-sub-solve
183
+ ``FlexDataProvider`` keyed under ``"input/<basename>"`` /
184
+ ``"solve_data/<basename>"`` — the writer→emitter refactor no longer
185
+ flushes these to disk on the default cascade path, so there is no
186
+ on-disk fallback (matching the B6 ``_emit_arc_unions`` precedent).
187
+
188
+ ``provider`` contract (STRICT): on an in-memory output path a
189
+ Provider MUST exist. This function is only reached on the in-memory
190
+ read path (the disk/parquet re-plot path short-circuits earlier), so
191
+ a ``None`` *provider* is a wiring bug — it would silently drop the
192
+ group-flow output families (``from_unitGroup`` /
193
+ ``from_connectionGroup`` / ``from_unit`` / ``to_connectionGroup``)
194
+ from ``group_flows__dt.csv``. We therefore RAISE rather than warn-
195
+ and-continue.
196
+
197
+ Emptiness is legitimate, but it must be *stated* by a Provider that
198
+ yields nothing for the group keys, NOT by a missing Provider. An
199
+ in-memory caller with no groups must pass an explicit empty
200
+ ``FlexDataProvider`` — the keyed frames are then absent → the sets
201
+ stay empty without any error, which is the correct tolerant
202
+ behaviour for a genuinely group-less model.
203
+ """
204
+ if provider is None:
205
+ raise ValueError(
206
+ "_backfill_group_indicator_sets: no flex_data_provider was "
207
+ "wired through write_outputs on an in-memory output path. On "
208
+ "an in-memory path a Provider is REQUIRED: empty group sets "
209
+ "must be stated by an (empty) FlexDataProvider, not by a "
210
+ "missing one. Production callers must forward "
211
+ "last_step.flex_data_provider; an in-memory run with no groups "
212
+ "must pass an explicit empty FlexDataProvider()."
213
+ )
214
+ for attr, fname in (
215
+ ('nodeGroupDispatch', 'nodeGroupDispatch.csv'),
216
+ ('nodeGroupIndicators', 'nodeGroupIndicators.csv'),
217
+ ('flowGroupIndicators', 'flowGroupIndicators.csv'),
218
+ ):
219
+ stem = fname[:-4]
220
+ df_pl = _provider_lookup_df(provider, "input", stem)
221
+ if df_pl is None:
222
+ continue
223
+ if df_pl.is_empty() or df_pl.width == 0:
224
+ continue
225
+ col0 = df_pl.columns[0]
226
+ vals = df_pl.select(col0).to_series().drop_nulls().to_list()
227
+ if not vals:
228
+ continue
229
+ setattr(s, attr, pd.Index(vals, name='group'))
230
+
231
+ # --- flowGroup membership (group_process_node) ---------------------------
232
+ # ``read_sets`` hardcodes ``s.group_process_node`` empty (no FlexData
233
+ # field carries it), so without this backfill ``out_flowgroup`` sums
234
+ # nothing and emits all-zero rows. Source = the cascade Provider key
235
+ # ``input/flowGroup__process__node`` (3 cols: flowGroup, process, node).
236
+ # ``solve_data/group_process_node`` is ABSENT on the cascade path. A
237
+ # missing key yields ``None`` here → the set stays empty, which is the
238
+ # correct tolerant behaviour for a group-less model.
239
+ gpn_pl = _provider_lookup_df(provider, "input", "flowGroup__process__node")
240
+ if gpn_pl is not None and not gpn_pl.is_empty() and gpn_pl.width >= 3:
241
+ gpn = gpn_pl.to_pandas()
242
+ gpn.columns = ['group', 'process', 'node']
243
+ s.group_process_node = pd.MultiIndex.from_frame(
244
+ gpn[['group', 'process', 'node']], names=['group', 'process', 'node'])
245
+
246
+ # --- 12 nodeGroupDispatch__* arc-union sets (from solve_data/) -----------
247
+ # Each entry: (attr-on-s, filename, csv→multi-index column map).
248
+ # The column map is ``[(csv_col, level_name), ...]`` — same CSV column may
249
+ # appear under multiple level names (e.g. ``unit`` populates both
250
+ # ``process`` and ``unit`` in the unit-aggregator sets where process==unit).
251
+ dispatch_specs = (
252
+ # 4-col / 2-col Not-in-aggregate sets — CSV columns map 1:1 to levels.
253
+ ('nodeGroupDispatch__process_fully_inside',
254
+ 'nodeGroupDispatch__process_fully_inside.csv',
255
+ (('group', 'group'), ('process', 'process'))),
256
+ ('nodeGroupDispatch__process__unit__to_node_Not_in_aggregate',
257
+ 'nodeGroupDispatch__process__unit__to_node_Not_in_aggregate.csv',
258
+ (('group', 'group'), ('process', 'process'),
259
+ ('unit', 'unit'), ('node', 'node'))),
260
+ ('nodeGroupDispatch__process__node__to_unit_Not_in_aggregate',
261
+ 'nodeGroupDispatch__process__node__to_unit_Not_in_aggregate.csv',
262
+ (('group', 'group'), ('process', 'process'),
263
+ ('node', 'node'), ('unit', 'unit'))),
264
+ ('nodeGroupDispatch__process__connection__to_node_Not_in_aggregate',
265
+ 'nodeGroupDispatch__process__connection__to_node_Not_in_aggregate.csv',
266
+ (('group', 'group'), ('process', 'process'),
267
+ ('connection', 'connection'), ('node', 'node'))),
268
+ ('nodeGroupDispatch__process__node__to_connection_Not_in_aggregate',
269
+ 'nodeGroupDispatch__process__node__to_connection_Not_in_aggregate.csv',
270
+ (('group', 'group'), ('process', 'process'),
271
+ ('node', 'node'), ('connection', 'connection'))),
272
+ ('nodeGroupDispatch__connection_Not_in_aggregate',
273
+ 'nodeGroupDispatch__connection_Not_in_aggregate.csv',
274
+ (('group', 'group'), ('connection', 'connection'))),
275
+ # Unit aggregator sets: CSV stores (group, group_aggregate, unit,
276
+ # source, sink); a unit's process name equals its unit name, and the
277
+ # node is the sink for to-node side / source for to-unit side.
278
+ ('nodeGroupDispatch__processGroup__process__unit__to_node',
279
+ 'nodeGroupDispatch__group_aggregate__process__unit__to_node.csv',
280
+ (('group', 'group'), ('group_aggregate', 'group_aggregate'),
281
+ ('unit', 'process'), ('unit', 'unit'), ('sink', 'node'))),
282
+ ('nodeGroupDispatch__processGroup__process__node__to_unit',
283
+ 'nodeGroupDispatch__group_aggregate__process__node__to_unit.csv',
284
+ (('group', 'group'), ('group_aggregate', 'group_aggregate'),
285
+ ('unit', 'process'), ('source', 'node'), ('unit', 'unit'))),
286
+ # Connection aggregator sets: CSV stores (group, group_aggregate,
287
+ # connection, source, sink); process==connection, node side depends.
288
+ ('nodeGroupDispatch__processGroup__process__connection__to_node',
289
+ 'nodeGroupDispatch__group_aggregate__process__connection__to_node.csv',
290
+ (('group', 'group'), ('group_aggregate', 'group_aggregate'),
291
+ ('connection', 'process'), ('connection', 'connection'),
292
+ ('sink', 'node'))),
293
+ ('nodeGroupDispatch__processGroup__process__node__to_connection',
294
+ 'nodeGroupDispatch__group_aggregate__process__node__to_connection.csv',
295
+ (('group', 'group'), ('group_aggregate', 'group_aggregate'),
296
+ ('connection', 'process'), ('source', 'node'),
297
+ ('connection', 'connection'))),
298
+ # 2-col projection sets — (group, group_aggregate).
299
+ ('nodeGroupDispatch__processGroup_Unit_to_group',
300
+ 'nodeGroupDispatch__group_aggregate_Unit_to_group.csv',
301
+ (('group', 'group'), ('group_aggregate', 'group_aggregate'))),
302
+ ('nodeGroupDispatch__processGroup_Group_to_unit',
303
+ 'nodeGroupDispatch__group_aggregate_Group_to_unit.csv',
304
+ (('group', 'group'), ('group_aggregate', 'group_aggregate'))),
305
+ ('nodeGroupDispatch__processGroup_Connection',
306
+ 'nodeGroupDispatch__group_aggregate_Connection.csv',
307
+ (('group', 'group'), ('group_aggregate', 'group_aggregate'))),
308
+ )
309
+ for attr, fname, col_map in dispatch_specs:
310
+ stem = fname[:-4]
311
+ df_pl = _provider_lookup_df(provider, "solve_data", stem)
312
+ if df_pl is None or df_pl.is_empty():
313
+ continue
314
+ df = df_pl.to_pandas()
315
+ # Skip if frame is missing any required source column.
316
+ csv_cols = [c for c, _ in col_map]
317
+ if not all(c in df.columns for c in csv_cols):
318
+ continue
319
+ level_names = [lvl for _, lvl in col_map]
320
+ arrays = [df[c].tolist() for c, _ in col_map]
321
+ mi = pd.MultiIndex.from_arrays(arrays, names=level_names)
322
+ setattr(s, attr, mi)
323
+
324
+
325
+ def _read_outputs(
326
+ output_dir,
327
+ *,
328
+ flex_data=None,
329
+ solution=None,
330
+ solve_name=None,
331
+ solve_steps=None,
332
+ flex_data_provider=None,
333
+ ):
334
+ """Read solver output files and return (par, s, v).
335
+
336
+ ``flex_data`` + ``solution`` are required for the in-memory
337
+ parameter / set reading. Variables (parquets) are read from
338
+ ``output_raw/`` where the polars-LP write path emits them.
339
+
340
+ ``solve_steps`` (multi-solve): an ordered list of
341
+ ``(solve_name, FlexData)`` pairs covering every sub-solve (roll)
342
+ of the orchestration cascade. When supplied, ``par`` / ``s`` are
343
+ built as the union over every sub-solve's dt axis — matching the
344
+ ``solve``-axis union ``v`` carries from per-sub-solve parquet
345
+ aggregation. Falls back to the single-``flex_data`` path when
346
+ not supplied (single-solve scenarios).
347
+ """
348
+ # Multi-solve output union (stage 3) — when the per-roll realized
349
+ # param/set slices have been persisted to ``output_dir`` (the
350
+ # ``keep_solutions=False`` memory-flat normal flow,
351
+ # ``persist_realized_slice``), assemble ``par`` / ``s`` by unioning
352
+ # those parquets instead of holding every roll's flex_data/solution
353
+ # in memory. This is byte-equivalent to the in-memory
354
+ # ``read_parameters_multi`` / ``read_sets_multi`` oracle (the persisted
355
+ # slices are already realized-filtered + post-hack), but works on the
356
+ # default flow where prior steps' flex_data/solution are nulled — so
357
+ # it replaces the broken single-flex_data last-roll fallback. The
358
+ # last step's ``flex_data`` / ``solution`` supply the static
359
+ # (solve-invariant) attrs + the per-attr shape template.
360
+ if (
361
+ solve_steps is None
362
+ and flex_data is not None
363
+ and solution is not None
364
+ and has_persisted_slices(output_dir)
365
+ ):
366
+ last_par = read_parameters(
367
+ flex_data, solution, solve_name=solve_name or "solve"
368
+ )
369
+ last_s = read_sets(
370
+ flex_data, solution, solve_name=solve_name or "solve"
371
+ )
372
+ p = union_params(last_par, output_dir)
373
+ s = union_sets(last_s, output_dir)
374
+ _backfill_group_indicator_sets(s, output_dir, provider=flex_data_provider)
375
+ v = read_variables(output_dir)
376
+ return p, s, v
377
+ if solve_steps is not None:
378
+ if solution is None:
379
+ raise ValueError(
380
+ "_read_outputs requires solution alongside solve_steps."
381
+ )
382
+ p = read_parameters_multi(solve_steps, solution)
383
+ s = read_sets_multi(solve_steps, solution)
384
+ _backfill_group_indicator_sets(s, output_dir, provider=flex_data_provider)
385
+ v = read_variables(output_dir)
386
+ return p, s, v
387
+ if flex_data is None or solution is None:
388
+ raise ValueError(
389
+ "_read_outputs requires flex_data and solution; "
390
+ "the CSV-based read_parameters / read_sets path is gone."
391
+ )
392
+ p = read_parameters(flex_data, solution, solve_name=solve_name or "solve")
393
+ s = read_sets(flex_data, solution, solve_name=solve_name or "solve")
394
+ _backfill_group_indicator_sets(s, output_dir, provider=flex_data_provider)
395
+ v = read_variables(output_dir)
396
+ return p, s, v
397
+
398
+
399
+ def log_time(log_string, start, timing_recorder=None):
400
+ """Print and record one ``write_outputs`` sub-phase.
401
+
402
+ Feeds the ``TimingRecorder`` when supplied. The ``print(...)`` line
403
+ is kept for human-readable stdout — that's the file most operators
404
+ eyeball.
405
+ """
406
+ elapsed = time.perf_counter() - start
407
+ print(f"---{log_string}: {elapsed:.4f} seconds")
408
+ if timing_recorder is not None:
409
+ timing_recorder.record(
410
+ 'write_outputs',
411
+ subphase=log_string,
412
+ seconds=elapsed,
413
+ t_start=start,
414
+ )
415
+ return time.perf_counter()
416
+
417
+
418
+ def print_namespace_structure(namespace, name='r', max_items=3, output_file='namespace_structure.txt'):
419
+ import pandas as pd
420
+ import sys
421
+
422
+ def format_list(items, max_n=max_items):
423
+ items_list = list(items)
424
+ if len(items_list) <= max_n:
425
+ return items_list
426
+ return items_list[:max_n] + [f'... ({len(items_list)} total)']
427
+
428
+ with open(output_file, 'a') as f:
429
+ original_stdout = sys.stdout
430
+ sys.stdout = f
431
+
432
+ for attr_name in dir(namespace):
433
+ if attr_name.startswith('_'):
434
+ continue
435
+
436
+ obj = getattr(namespace, attr_name)
437
+ print(f"\n{name}.{attr_name}")
438
+
439
+ if isinstance(obj, pd.DataFrame):
440
+ print("Type: DataFrame")
441
+ print(f"Shape: {obj.shape}")
442
+ print(f"Index: {obj.index.names if hasattr(obj.index, 'names') else 'default'}")
443
+ print(f"Columns: {format_list(obj.columns)}")
444
+ print(f"Dtypes:\n{obj.dtypes}")
445
+
446
+ elif isinstance(obj, pd.Series):
447
+ print("Type: Series")
448
+ print(f"Shape: {obj.shape}")
449
+ print(f"Index: {obj.index.name or 'default'}")
450
+ print(f"Dtype: {obj.dtype}")
451
+
452
+ elif isinstance(obj, pd.Index):
453
+ print("Type: Index")
454
+ print(f"Name: {obj.name}")
455
+ print(f"Values: {format_list(obj)}")
456
+ print(f"Dtype: {obj.dtype}")
457
+
458
+ sys.stdout = original_stdout
459
+
460
+
461
+ def write_summary_csv(par, s, v, r, csv_dir):
462
+ """Write summary CSV file matching the GNU MathProg format"""
463
+ import os
464
+
465
+ # Output file path
466
+ fn_summary = os.path.join(csv_dir, 'summary_solve.csv')
467
+
468
+ # Get common parameters - these are Series indexed by period
469
+ p_inflation_factor_operations_yearly = par.inflation_factor_operations_yearly
470
+ p_inflation_factor_investment_yearly = par.inflation_factor_investment_yearly
471
+ complete_period_share_of_year = par.complete_period_share_of_year
472
+
473
+ # Get period sets
474
+ period_in_use = complete_period_share_of_year.index
475
+ d_realized_period = s.d_realized_period
476
+
477
+ # Open file and write all content
478
+ with open(fn_summary, 'w') as f:
479
+ # Header with timestamp
480
+ timestamp = datetime.now(timezone.utc)
481
+ f.write(f'"Diagnostic results from all solves. Output at (UTC): {timestamp}"\n\n')
482
+
483
+ # Total cost from solver (M CUR). Iterate solve names in
484
+ # natural-sort order (so ``roll_0, roll_1, ..., roll_10, ...``
485
+ # not ``roll_1, roll_10, ...``). ``v.obj.index`` carries the
486
+ # parquet-concat order, which depends on
487
+ # ``solve_data/solve__p_entity_pre_existing.csv`` — written as a
488
+ # header-only stub by the engine cascade for some rolling-solve
489
+ # paths, leaving the sort key unstable. Natural-sort here is
490
+ # the writer-side guard that matches v3.32.0's numeric solve
491
+ # ordering.
492
+ f.write('\n')
493
+ f.write('"Solve","Objective","Total cost from solver, includes all penalty costs"\n')
494
+ sorted_solves = sorted(
495
+ v.obj.index,
496
+ key=lambda s: [int(t) if t.isdigit() else t
497
+ for t in re.split(r"(\d+)", str(s))],
498
+ )
499
+ for row_idx in sorted_solves:
500
+ f.write(f'{row_idx},{v.obj.loc[row_idx, "objective"] / 1000000:.12g}\n')
501
+
502
+ # Total cost (calculated) full horizon (M CUR). Mirrors the LP
503
+ # objective: oper + penalty + invest + divest + fixed (pre-existing
504
+ # + invested + divested). Fixed-cost terms are in the LP obj at
505
+ # flextool.mod:2107-2126; omitting them here would under-report
506
+ # the objective for any scenario with non-zero existing capacity
507
+ # or a non-zero lifetime_fixed_cost on invest/divest entities.
508
+ total_cost_full = (
509
+ r.costOper_and_penalty_d
510
+ .add(r.costInvest_d, fill_value=0.0)
511
+ .add(r.costDivest_d, fill_value=0.0)
512
+ .add(r.costFixedPreExisting_d, fill_value=0.0)
513
+ .add(r.costFixedInvested_d, fill_value=0.0)
514
+ .add(r.costFixedDivested_d, fill_value=0.0)
515
+ ).sum(axis=0) / 1000000
516
+
517
+ f.write(f'"Total cost (calculated) full horizon (M CUR)",{total_cost_full:.12g},"Annualized operational, penalty and investment costs"\n')
518
+ f.write(f'"Total cost (calculated) realized periods (M CUR)",{total_cost_full:.12g}\n')
519
+
520
+ # Operational costs for realized periods (M CUR)
521
+ operational_costs = r.costOper_d.sum(axis=0) / 1000000
522
+ f.write(f'"Operational costs for realized periods (M CUR)",{operational_costs:.12g}\n')
523
+
524
+ # Investment costs for realized periods (M CUR)
525
+ investment_costs = r.costInvest_d.sum(axis=0) / 1000000
526
+ f.write(f'"Investment costs for realized periods (M CUR)",{investment_costs:.12g}\n')
527
+
528
+ # Retirement costs / salvage revenue for realized periods (M CUR).
529
+ # Sign follows the user's salvage_value input: positive salvage
530
+ # (scrap revenue) shows as a negative cost; negative salvage
531
+ # (decommissioning cost) shows as a positive cost. Included in
532
+ # the solver objective since v_divest is a decision variable.
533
+ retirement_costs = r.costDivest_d.sum(axis=0) / 1000000
534
+ f.write(f'"Retirement costs / salvage revenue for realized periods (M CUR)",{retirement_costs:.12g}\n')
535
+
536
+ # Fixed costs for existing entities (M CUR)
537
+ fixed_costs_pre_existing = r.costFixedPreExisting_d.sum(axis=0) / 1000000
538
+ fixed_costs_invested = r.costFixedInvested_d.sum(axis=0) / 1000000
539
+ fixed_costs_divested = r.costFixedDivested_d.sum(axis=0) / 1000000
540
+
541
+ f.write(f'"Fixed costs for pre-existing entities (M CUR)",{fixed_costs_pre_existing:.12g}\n')
542
+ f.write(f'"Fixed costs for invested entities (M CUR)",{fixed_costs_invested:.12g}\n')
543
+ f.write(f'"Fixed cost removal due to divested entities (M CUR)",{fixed_costs_divested:.12g}\n')
544
+
545
+ # Penalty (slack) costs for realized periods (M CUR)
546
+ penalty_costs = r.costPenalty_d.sum(axis=0) / 1000000
547
+ f.write(f'"Penalty (slack) costs for realized periods (M CUR)",{penalty_costs:.12g}\n')
548
+
549
+ # Period information table
550
+ f.write('\nPeriod')
551
+ for d in period_in_use:
552
+ f.write(f',{d}')
553
+ f.write('\n')
554
+
555
+ # Time in use in years
556
+ f.write('"Time in use in years"')
557
+ for d in period_in_use:
558
+ f.write(f',{complete_period_share_of_year[d]:.12g}')
559
+ f.write('\n')
560
+
561
+ # Operational discount factor
562
+ f.write('"Operational discount factor"')
563
+ for d in period_in_use:
564
+ f.write(f',{p_inflation_factor_operations_yearly[d]:.12g}')
565
+ f.write('\n')
566
+
567
+ # Investment discount factor — iterate ``period_in_use`` (the
568
+ # same axis as the "Operational discount factor" / "Time in use
569
+ # in years" rows above) so the per-period values align under
570
+ # the period header. Earlier HEAD iterated ``s.d_realize_invest``
571
+ # which (a) emits no values for scenarios without realized
572
+ # invest decisions (e.g. ``capacity_margin``, where v3.32.0
573
+ # still wrote ``,1`` against the single p2020 period) and
574
+ # (b) carries a different period order on multi-period invest
575
+ # scenarios than the surrounding rows. v3.32.0 wrote one
576
+ # value per period in ``period_in_use``.
577
+ f.write('"Investment discount factor"')
578
+ for d in period_in_use:
579
+ f.write(f',{p_inflation_factor_investment_yearly[d]:.12g}')
580
+ f.write('\n\n')
581
+
582
+ # Emissions section
583
+ f.write('Emissions\n')
584
+ co2_total = r.emissions_co2_d.sum(axis=0) / 1000000
585
+ f.write(f'"CO2 [Mt]",{co2_total:.6g},"System-wide annualized CO2 emissions for realized periods"\n')
586
+
587
+ # Slack variables section
588
+ f.write('\n"Slack variables multiplied by timestep duration (creating or removing energy/matter, ')
589
+ f.write('creating inertia, adding synchronous generation, decreasing capacity margin, creating reserve)"\n')
590
+
591
+ # Node state slack - upward (creating energy)
592
+ for node in r.upward_node_slack_d_not_annualized.columns:
593
+ for period in d_realized_period:
594
+ if period in r.upward_node_slack_d_not_annualized.index and r.upward_node_slack_d_not_annualized.loc[period, node] > 0:
595
+ f.write(f'Created, {node}, {period}, {r.upward_node_slack_d_not_annualized.loc[period, node]:.5g}\n')
596
+
597
+ # Node state slack - downward (removing energy)
598
+ for node in r.downward_node_slack_d_not_annualized.columns:
599
+ for period in d_realized_period:
600
+ if period in r.downward_node_slack_d_not_annualized.index and r.downward_node_slack_d_not_annualized.loc[period, node] > 0:
601
+ f.write(f'Removed, {node}, {period}, {r.downward_node_slack_d_not_annualized.loc[period, node]:.5g}\n')
602
+
603
+ # Inertia slack
604
+ for group in r.q_inertia_d_not_annualized.columns:
605
+ for period in d_realized_period:
606
+ if period in r.q_inertia_d_not_annualized.index and r.q_inertia_d_not_annualized.loc[period, group] > 0:
607
+ f.write(f'Inertia, {group}, {period}, {r.q_inertia_d_not_annualized.loc[period, group]:.5g}\n')
608
+
609
+ # Non-synchronous slack
610
+ for group in r.q_non_synchronous_d_not_annualized.columns:
611
+ for period in d_realized_period:
612
+ if period in r.q_non_synchronous_d_not_annualized.index and r.q_non_synchronous_d_not_annualized.loc[period, group] > 0:
613
+ f.write(f'NonSync, {group}, {period}, {r.q_non_synchronous_d_not_annualized.loc[period, group]:.5g}\n')
614
+
615
+ # Capacity margin slack — iterate realized periods rather than
616
+ # d_realize_invest. The capacity-margin constraint is defined
617
+ # per realized period and is independent of invest/divest
618
+ # entities; scenarios such as ``capacity_margin`` have no
619
+ # invest entities, so d_realize_invest is empty and this loop
620
+ # would silently drop legitimate slack rows.
621
+ for group in r.q_capacity_margin_d_not_annualized.columns:
622
+ for period in d_realized_period:
623
+ if period in r.q_capacity_margin_d_not_annualized.index and r.q_capacity_margin_d_not_annualized.loc[period, group] > 0:
624
+ f.write(f'CapMargin, {group}, {period}, {r.q_capacity_margin_d_not_annualized.loc[period, group]:.5g}\n')
625
+
626
+ # Reserve slack
627
+ for group in r.q_reserves_d_not_annualized.columns:
628
+ for period in d_realized_period:
629
+ if period in r.q_reserves_d_not_annualized.index and r.q_reserves_d_not_annualized.loc[period, group] > 0:
630
+ f.write(f'Reserve, {group}, {period}, {r.q_reserves_d_not_annualized.loc[period, group]:.5g}\n')
631
+
632
+
633
+ # List of all output functions
634
+ ALL_OUTPUTS = [
635
+ input_sets,
636
+ generic,
637
+ cost_summaries,
638
+ cost_breakdown_by_entity,
639
+ reserves,
640
+ unit_online_and_startup,
641
+ node_summary,
642
+ node_additional_results,
643
+ investment_duals,
644
+ inertia_results,
645
+ slack_variables,
646
+ unit_capacity,
647
+ connection_capacity,
648
+ node_capacity,
649
+ nodeGroup_indicators,
650
+ nodeGroup_VRE_share,
651
+ flowGroup_indicators,
652
+ CO2,
653
+ co2_duals,
654
+ nodeGroup_flows,
655
+ unit_outputNode,
656
+ unit_inputNode,
657
+ connection,
658
+ connection_wards,
659
+ nodeGroup_total_inflow,
660
+ connection_cf,
661
+ unit_cf_outputNode,
662
+ unit_cf_inputNode,
663
+ unit_VRE_curtailment_and_potential,
664
+ unit_ramps,
665
+ dc_power_flow,
666
+ ]
667
+
668
+
669
+ def _resolve_settings(write_methods, output_config_path, active_configs, plot_rows,
670
+ output_location, plot_file_format, settings_db_url,
671
+ fallback_output_location, results_db_url=None):
672
+ """Resolve output settings: explicit args > settings DB > hardcoded defaults.
673
+
674
+ ``output-spinedb`` from the settings DB is honored on BOTH the native
675
+ solve path and the parquet-replay path: a True flag writes the results
676
+ SpineDB either way (the run produces it from the live solve; re-create
677
+ rebuilds/augments it from parquet without re-solving).
678
+ """
679
+ db_reachable = False
680
+ if settings_db_url:
681
+ if settings_db_url.startswith('sqlite:///'):
682
+ db_reachable = os.path.exists(settings_db_url.replace('sqlite:///', ''))
683
+ else:
684
+ db_reachable = True # HTTP or other remote URLs — let DatabaseMapping handle errors
685
+ if db_reachable:
686
+ with DatabaseMapping(settings_db_url) as settings_db:
687
+ settings_entities = settings_db.get_entity_items(entity_class_name="settings")
688
+ if len(settings_entities) == 1:
689
+ settings_name = settings_entities[0]["name"]
690
+ settings_params: dict = {}
691
+ for pv in settings_db.get_parameter_value_items(entity_class_name="settings"):
692
+ if pv["entity_byname"] == (settings_name,):
693
+ settings_params[pv["parameter_definition_name"]] = from_database(pv["value"], pv["type"])
694
+ logging.debug(f"Settings DB parameters: {settings_params}")
695
+
696
+ if write_methods is None:
697
+ method_keys = [f'output-{m}' for m in ['plot', 'parquet', 'csv', 'excel', 'spinedb']]
698
+ if any(k in settings_params for k in method_keys):
699
+ write_methods = [m for m in ['plot', 'parquet', 'csv', 'excel', 'spinedb']
700
+ if settings_params.get(f'output-{m}', False)]
701
+
702
+ # CLI-supplied results_db_url wins; otherwise honor the
703
+ # optional settings value (mirrors the write_methods
704
+ # CLI-vs-settings precedence above).
705
+ if results_db_url is None and 'output-results-db-url' in settings_params:
706
+ results_db_url = str(settings_params['output-results-db-url'])
707
+
708
+ if output_config_path is None and 'output-config-path' in settings_params:
709
+ output_config_path = str(settings_params['output-config-path'])
710
+
711
+ if active_configs is None and 'active-output-configs' in settings_params:
712
+ val = settings_params['active-output-configs']
713
+ if isinstance(val, str):
714
+ active_configs = [val]
715
+ elif isinstance(val, Array):
716
+ active_configs = list(val.values)
717
+ else:
718
+ active_configs = list(val)
719
+
720
+ if plot_rows is None:
721
+ first = settings_params.get('plot_first_timestep')
722
+ duration = settings_params.get('plot_duration')
723
+ if first is not None and duration is not None:
724
+ plot_rows = (int(first), int(first) + int(duration))
725
+
726
+ if output_location is None and 'output-location' in settings_params:
727
+ output_location = str(settings_params['output-location'])
728
+
729
+ if plot_file_format is None and 'plot-file-format' in settings_params:
730
+ plot_file_format = str(settings_params['plot-file-format'])
731
+
732
+ # Apply hardcoded defaults for anything still unset
733
+ from flextool._resources import package_data_path
734
+ _default_plots = str(package_data_path("schemas/default_plots.yaml"))
735
+ if write_methods is None:
736
+ write_methods = ['plot', 'parquet', 'excel']
737
+ if output_config_path is None:
738
+ output_config_path = _default_plots
739
+ elif not os.path.isabs(output_config_path):
740
+ # Legacy: a settings DB may carry a repo-relative override like
741
+ # ``templates/default_plots.yaml``. Treat such relative paths as
742
+ # references to the bundled default — installing from a wheel no
743
+ # longer has a repo root to resolve against.
744
+ if os.path.basename(output_config_path) in {
745
+ 'default_plots.yaml', 'default_comparison_plots.yaml'
746
+ }:
747
+ output_config_path = _default_plots
748
+ else:
749
+ output_config_path = os.path.abspath(output_config_path)
750
+ # Self-heal for stale settings DBs that still point at the deleted
751
+ # ``default_comparison_plots.yaml`` (comparison rules now live inside
752
+ # default_plots.yaml via per-leaf ``scenario_rule``).
753
+ if (os.path.basename(output_config_path) == 'default_comparison_plots.yaml'
754
+ or not os.path.isfile(output_config_path)):
755
+ if output_config_path != _default_plots:
756
+ logging.info(
757
+ "output-config-path %s not found or superseded — using "
758
+ "bundled %s instead.", output_config_path, _default_plots,
759
+ )
760
+ output_config_path = _default_plots
761
+ if active_configs is None:
762
+ active_configs = ['default']
763
+ if plot_rows is None:
764
+ plot_rows = (0, 167)
765
+ if output_location is None:
766
+ output_location = fallback_output_location or ''
767
+ if plot_file_format is None:
768
+ plot_file_format = 'png'
769
+
770
+ return write_methods, output_config_path, active_configs, plot_rows, output_location, plot_file_format, results_db_url
771
+
772
+
773
+ def write_outputs(scenario_name, output_config_path=None, active_configs=None, output_funcs=None, output_location=None, subdir=None, read_parquet_dir=False, write_methods=None, plot_rows=None, debug=False, single_result=None, settings_db_url=None, fallback_output_location=None, plot_file_format=None, raw_output_dir=None, only_first_file=False, timing_recorder=None, flex_data=None, solution=None, solve_name=None, solve_steps=None, flex_data_provider=None, results_db_url=None):
774
+ """
775
+ Write FlexTool outputs to various formats.
776
+
777
+ Args:
778
+ scenario_name: Name of the scenario
779
+ output_config_path: Path to YAML configuration file defining outputs
780
+ active_configs: output_config yaml can contain multiple plot configurations for same data, choose which ones to use. Defaults to 'default' only.
781
+ output_funcs: list of functions to run, or None for ALL_OUTPUTS
782
+ subdir: Subdirectory for outputs
783
+ read_parquet_dir: Directory to read existing parquet files from
784
+ write_methods: List of output methods ('plot', 'parquet', 'excel', 'csv')
785
+ plot_rows: Tuple of first and last row to plot in a time series plots. Default is (0, 167).
786
+ debug: Enable debug output
787
+ single_result: Tuple of (key, csv_name, plot_name, plot_type, subplots_per_row, legend_position)
788
+ for processing a single result. Overrides config file.
789
+ settings_db_url: URL of the settings database (optional, fills in unset params)
790
+ fallback_output_location: Used as output_location if not set by caller or settings DB
791
+ raw_output_dir: Path to the directory containing solver raw output CSV files
792
+ (default: 'output_raw' relative to CWD)
793
+ flex_data: FlexData (polars input bundle) — required for the
794
+ in-memory parameter / set reading. Pass-through from the
795
+ orchestration step's last solve.
796
+ solution: polar_high.Solution — required; used by the in-memory
797
+ read_parameters for entity_all_capacity (and friends)
798
+ derivation.
799
+ solve_name: complete solve identifier (e.g. ``"y2020_2day_dispatch"``)
800
+ used as the leading ``solve`` index level on the wide-format
801
+ params / sets. Defaults to ``scenario_name``.
802
+ solve_steps: optional ordered list of ``(solve_name, FlexData)``
803
+ pairs covering every sub-solve (roll) of the orchestration
804
+ cascade. Multi-solve / rolling scenarios MUST supply this
805
+ so ``par`` and ``s`` are unioned across every sub-solve's
806
+ dt axis — otherwise they would only carry the last roll's
807
+ (d, t) rows while ``v`` carries the union from per-sub-
808
+ solve parquet aggregation, and pandas joins/muls would
809
+ mismatch. Single-solve scenarios can ignore.
810
+ flex_data_provider: optional per-sub-solve
811
+ :class:`flextool.engine_polars._flex_data_provider.FlexDataProvider`
812
+ from the orchestration step (typically ``last_step.flex_data_provider``).
813
+ Used by :func:`_backfill_group_indicator_sets` to read the
814
+ ``input/nodeGroup*`` + ``solve_data/nodeGroupDispatch__*``
815
+ frames in-memory — the writer→emitter refactor no longer
816
+ flushes these to disk on the default cascade path, so
817
+ without a Provider here the ``group_flows__dt.csv`` /
818
+ ``flowGroup__*.csv`` outputs would silently disappear.
819
+ results_db_url: optional target SpineDB URL for the ``spinedb``
820
+ write-method. When omitted, defaults to a ``results.sqlite``
821
+ file next to the other outputs (``output_location``). Ignored
822
+ unless ``'spinedb'`` is in ``write_methods``. The ``spinedb``
823
+ write-method runs on BOTH paths and ``output-spinedb`` from the
824
+ settings DB is honored on both: on the native solve path it uses
825
+ the live ``s`` / ``par`` namespaces, while on the
826
+ ``read_parquet_dir`` REPLAY path it rebuilds the ``s`` shim from
827
+ the processed parquet (``build_replay_s``, with ``par=None`` —
828
+ the two discount/inflation params are dropped on the replay path).
829
+ """
830
+ write_methods, output_config_path, active_configs, plot_rows, output_location, plot_file_format, results_db_url = _resolve_settings(
831
+ write_methods, output_config_path, active_configs, plot_rows,
832
+ output_location, plot_file_format, settings_db_url, fallback_output_location,
833
+ results_db_url,
834
+ )
835
+
836
+ logging.debug(
837
+ f"Resolved output settings: write_methods={write_methods}, "
838
+ f"output_config_path={output_config_path}, active_configs={active_configs}, "
839
+ f"plot_rows={plot_rows}, output_location={output_location}"
840
+ )
841
+
842
+ warnings.filterwarnings('ignore', category=pd.errors.PerformanceWarning)
843
+ start = time.perf_counter()
844
+
845
+ # Load output configuration from YAML or create from single_result
846
+ if single_result:
847
+ # Parse single_result tuple
848
+ key, csv_name, plot_name, plot_type, subplots_per_row, legend_position = single_result
849
+
850
+ # Convert string "null" to None
851
+ def parse_value(val):
852
+ if val == "null" or val == "None":
853
+ return None
854
+ # Try to convert to int if it's a numeric string
855
+ try:
856
+ return int(val)
857
+ except (ValueError, TypeError):
858
+ return val
859
+
860
+ csv_name = parse_value(csv_name)
861
+ plot_name = parse_value(plot_name)
862
+ plot_type = parse_value(plot_type)
863
+ subplots_per_row = parse_value(subplots_per_row)
864
+ legend_position = parse_value(legend_position)
865
+
866
+ # Create single-entry settings dict
867
+ settings = {
868
+ "plots": {
869
+ key: [plot_name, plot_type, subplots_per_row, legend_position]
870
+ },
871
+ "rename": {
872
+ key: csv_name
873
+ }
874
+ }
875
+ else:
876
+ # Load output configuration from YAML
877
+ with open(output_config_path, 'r', encoding='utf-8') as f:
878
+ settings = yaml.safe_load(f)
879
+
880
+ if subdir:
881
+ parquet_dir = os.path.join(output_location, 'output_parquet', subdir)
882
+ csv_dir = os.path.join(output_location, 'output_csv', subdir)
883
+ plot_dir = os.path.join(output_location, 'output_plots', subdir)
884
+ else:
885
+ parquet_dir = os.path.join(output_location, 'output_parquet')
886
+ csv_dir = os.path.join(output_location, 'output_csv')
887
+ plot_dir = os.path.join(output_location, 'output_plots')
888
+
889
+ # Read and process data
890
+ start = log_time("Read configuration files", start, timing_recorder)
891
+
892
+ # If results already exist as parquet files, read them
893
+ if read_parquet_dir:
894
+ results = {}
895
+ for filename in os.listdir(parquet_dir):
896
+ if filename.endswith('.parquet') and filename != 'timeline_breaks.parquet':
897
+ key = filename[:-8] # Remove '.parquet' extension
898
+ filepath = os.path.join(parquet_dir, filename)
899
+ results[key] = read_lean_parquet(filepath)
900
+ if len(results[key].columns.names) == 1:
901
+ results[key] = results[key].squeeze()
902
+ else:
903
+ results[key] = results[key].droplevel('scenario', axis=1)
904
+ start = log_time("Read parquet files", start, timing_recorder)
905
+
906
+ # Read original raw outputs from FlexTool
907
+ else:
908
+ par, s, v = _read_outputs(
909
+ raw_output_dir or 'output_raw',
910
+ flex_data=flex_data,
911
+ solution=solution,
912
+ solve_name=solve_name or scenario_name,
913
+ solve_steps=solve_steps,
914
+ flex_data_provider=flex_data_provider,
915
+ )
916
+ start = log_time("Read flextool outputs", start, timing_recorder)
917
+
918
+ # Pre-process results to be closer to what needed for output writing
919
+ r = post_process_results(par, s, v)
920
+ start = log_time("Post-processed outputs", start, timing_recorder)
921
+
922
+ # Call the final processing functions for each category of outputs
923
+ # and make a dict of dataframes to hold final results
924
+ output_funcs = output_funcs or ALL_OUTPUTS
925
+
926
+ all_results = {}
927
+ for func in output_funcs:
928
+ func_results = func(par, s, v, r, debug)
929
+ if not func_results:
930
+ continue
931
+
932
+ # Handle both single result (wrapped in list) and multiple results
933
+ if not isinstance(func_results, list):
934
+ func_results = [func_results]
935
+
936
+ for result_df, table_name in func_results:
937
+ # Use excel_sheet as the key to allow multiple outputs per function
938
+ all_results[table_name] = result_df
939
+
940
+ results = all_results
941
+ start = log_time("Formatted for output", start, timing_recorder)
942
+
943
+ # Write files for debugging purposes
944
+ if debug:
945
+ open('namespace_structure.txt', 'w').close()
946
+ print_namespace_structure(r, 'r')
947
+ print_namespace_structure(s, 's')
948
+ print_namespace_structure(v, 'v')
949
+ print_namespace_structure(par, 'par')
950
+ start = log_time("Wrote debugging files", start, timing_recorder)
951
+
952
+ # Write to parquet
953
+ if 'parquet' in write_methods and not read_parquet_dir:
954
+ os.makedirs(parquet_dir, exist_ok=True)
955
+ if not os.path.exists(parquet_dir):
956
+ os.makedirs(parquet_dir)
957
+
958
+ # Empty the scenario's parquet dir before writing so retired/renamed
959
+ # outputs don't linger (a stale file would be re-read by the
960
+ # scenario-comparison combine and trip the "not registered in
961
+ # TimeSeriesResults" warning). Done here — as late as possible, after
962
+ # the solve and post-processing have produced ``results`` — so a
963
+ # failed solve/post-process leaves the previous run's outputs intact.
964
+ # Removes top-level files only; the ``plot_plans/`` subdir and the
965
+ # regenerated ``_dispatch_metadata.json`` are rewritten later in this
966
+ # same call. Mirrors the csv-dir cleanup below, including the
967
+ # single-result guard (partial runs must not wipe sibling outputs).
968
+ if not single_result:
969
+ for filename in os.listdir(parquet_dir):
970
+ file_path = os.path.join(parquet_dir, filename)
971
+ if os.path.isfile(file_path):
972
+ os.remove(file_path)
973
+
974
+ for name, df in results.items():
975
+ col_meta = None
976
+ if isinstance(df, (pd.MultiIndex, pd.Index)):
977
+ df = df.to_frame(index=False)
978
+ df.insert(0, 'scenario', scenario_name)
979
+ df.set_index(list(df.columns)).index
980
+ else:
981
+ # Derive per-column unit/semantics from the leaf columns
982
+ # (before the scenario level is prepended) so the keys match
983
+ # the logical column names, not arrow's flattened tuples.
984
+ # Series-valued outputs have no columns — skip them for now.
985
+ if isinstance(df, pd.DataFrame):
986
+ derived = derive_column_meta(name, df.columns)
987
+ if derived:
988
+ col_meta = {k: v.as_dict() for k, v in derived.items()}
989
+ df = pd.concat({scenario_name: df}, axis=1, names=['scenario'])
990
+ write_lean_parquet(df, f'{parquet_dir}/{name}.parquet',
991
+ column_metadata=col_meta)
992
+
993
+ # Copy timeline_breaks from solve_data to parquet dir.
994
+ raw_dir = raw_output_dir or 'output_raw'
995
+ work_dir = os.path.dirname(raw_dir) or '.'
996
+ breaks_csv = os.path.join(work_dir, 'solve_data', 'timeline_breaks.csv')
997
+ if os.path.exists(breaks_csv):
998
+ breaks_df = pd.read_csv(breaks_csv)
999
+ write_lean_parquet(breaks_df, f'{parquet_dir}/timeline_breaks.parquet', index=False)
1000
+
1001
+ start = log_time("Wrote to parquet", start, timing_recorder)
1002
+
1003
+ # Compute plot plans for the viewer (always, when parquets exist)
1004
+ if 'parquet' in write_methods or read_parquet_dir:
1005
+ try:
1006
+ from flextool.plot_outputs.orchestrator import compute_all_plot_plans
1007
+ from flextool.plot_outputs.format_helpers import load_timeline_breaks
1008
+ plan_break_times = load_timeline_breaks(parquet_dir)
1009
+ plan_results = {k: v.to_frame() if isinstance(v, pd.Series) else v for k, v in results.items()}
1010
+ single_plot_settings = settings.get('plots', {})
1011
+ compute_all_plot_plans(
1012
+ plan_results, single_plot_settings, parquet_dir,
1013
+ active_settings=active_configs, plot_rows=plot_rows,
1014
+ break_times=plan_break_times,
1015
+ color_path=resolve_plot_settings_path(Path(output_location)),
1016
+ )
1017
+ start = log_time("Computed plot plans", start, timing_recorder)
1018
+ except Exception as exc:
1019
+ logging.warning("Plot plan computation failed (non-fatal): %s", exc)
1020
+
1021
+ # Phase D: write per-scenario dispatch metadata so the viewer can
1022
+ # union ylims across scenarios on demand (without waiting for the
1023
+ # cross-scenario combine). Loads the just-written parquets back
1024
+ # via the existing readers, restricted to this single scenario.
1025
+ try:
1026
+ import json as _json
1027
+ from flextool.scenario_comparison.db_reader import (
1028
+ build_scenario_folders_from_dir,
1029
+ collect_parquet_files,
1030
+ combine_parquet_files,
1031
+ )
1032
+ from flextool.scenario_comparison.dispatch_mappings import (
1033
+ combine_dispatch_mappings,
1034
+ resolve_data_scenario_tag,
1035
+ )
1036
+ from flextool.scenario_comparison.data_models import TimeSeriesResults
1037
+ from flextool.scenario_comparison.dispatch_plots import (
1038
+ compute_dispatch_metadata_for_scenario,
1039
+ )
1040
+
1041
+ # parquet_dir == <output_location>/output_parquet/<scenario>/
1042
+ # The folder name (subdir) drives folder discovery, but the
1043
+ # dispatch data is tagged with the model scenario name, which
1044
+ # may differ (GUI run-index suffix: folder S2_Dry_1 → tag
1045
+ # S2_Dry). Keep the two roles separate.
1046
+ scenario_pq_dir = os.path.dirname(parquet_dir) if subdir else parquet_dir
1047
+ scenario_folder_name = subdir or scenario_name
1048
+
1049
+ sc_folders = build_scenario_folders_from_dir(
1050
+ scenario_pq_dir, [scenario_folder_name],
1051
+ )
1052
+ if sc_folders:
1053
+ files_by_name = collect_parquet_files(sc_folders, output_subdir="")
1054
+ combined = combine_parquet_files(files_by_name, num_scenarios=1)
1055
+ ts_results = TimeSeriesResults.from_dict(combined)
1056
+ dispatch_mappings = combine_dispatch_mappings(sc_folders, "")
1057
+ # Slice by the in-data scenario tag, not the folder name.
1058
+ data_scenario_tag = resolve_data_scenario_tag(
1059
+ dispatch_mappings, scenario_name,
1060
+ )
1061
+
1062
+ if plot_rows and len(plot_rows) >= 2:
1063
+ meta_timeline = (int(plot_rows[0]), int(plot_rows[1]) + 1)
1064
+ else:
1065
+ meta_timeline = (0, 168)
1066
+
1067
+ disp_meta = compute_dispatch_metadata_for_scenario(
1068
+ ts_results, dispatch_mappings,
1069
+ data_scenario_tag, meta_timeline,
1070
+ )
1071
+ else:
1072
+ disp_meta = {"nodeGroups": {}}
1073
+
1074
+ meta_path = os.path.join(parquet_dir, "_dispatch_metadata.json")
1075
+ with open(meta_path, "w", encoding="utf-8") as f:
1076
+ _json.dump(disp_meta, f, indent=2)
1077
+ start = log_time("Wrote dispatch metadata", start, timing_recorder)
1078
+ except Exception as exc:
1079
+ logging.warning(
1080
+ "Per-scenario dispatch metadata computation failed (non-fatal): %s",
1081
+ exc,
1082
+ )
1083
+
1084
+ # Plot results
1085
+ if 'plot' in write_methods:
1086
+ os.makedirs(plot_dir, exist_ok=True)
1087
+ if not os.path.exists(plot_dir):
1088
+ os.makedirs(plot_dir)
1089
+ # Don't delete existing plots when processing single result
1090
+ delete_plots = not bool(single_result)
1091
+ results = {k: v.to_frame() if isinstance(v, pd.Series) else v for k, v in results.items()}
1092
+ # Load timeline breaks for visual gaps in time-series plots
1093
+ from flextool.plot_outputs.format_helpers import load_timeline_breaks
1094
+ break_times = load_timeline_breaks(parquet_dir)
1095
+ plot_dict_of_dataframes(results, plot_dir, settings['plots'], active_settings=active_configs, plot_rows=plot_rows, delete_existing_plots=delete_plots, plot_file_format=plot_file_format, only_first_file=only_first_file, break_times=break_times, color_path=resolve_plot_settings_path(Path(output_location)))
1096
+
1097
+ start = log_time('Plotted figures', start, timing_recorder)
1098
+
1099
+ # Write to csv
1100
+ if 'csv' in write_methods:
1101
+ os.makedirs(csv_dir, exist_ok=True)
1102
+ if not os.path.exists(csv_dir):
1103
+ os.makedirs(csv_dir)
1104
+
1105
+ # Only empty csv dir when not processing single result
1106
+ if not single_result:
1107
+ for filename in os.listdir(csv_dir):
1108
+ file_path = os.path.join(csv_dir, filename)
1109
+ if os.path.isfile(file_path):
1110
+ os.remove(file_path)
1111
+
1112
+ # Accumulate Frictionless resources for the sidecar datapackage.json
1113
+ # (units + semantics per column; CSVs themselves stay byte-identical).
1114
+ dp_resources: list[dict] = []
1115
+
1116
+ # Different CSV writing logic depending on data source
1117
+ if read_parquet_dir:
1118
+ # Simplified CSV writing from parquet (no par,s,v,r available)
1119
+ rename_raw = settings.get('rename', {})
1120
+ for table_name, df in results.items():
1121
+ display_name, _ = _parse_rename_entry(rename_raw.get(table_name, table_name))
1122
+ csv_filename = display_name + '.csv'
1123
+ csv_path = os.path.join(csv_dir, csv_filename)
1124
+ dp_resources.append(datapackage_resource(
1125
+ table_name, csv_filename, *_frame_axes(df)))
1126
+ df_copy = df.reset_index()
1127
+ df_copy.columns.names = [None] * df_copy.columns.nlevels
1128
+ df_copy.to_csv(csv_path, index=False, float_format='%.8g')
1129
+ else:
1130
+ # Full CSV writing from output_raw (par,s,v,r available)
1131
+ write_summary_csv(par, s, v, r, csv_dir)
1132
+
1133
+ rename_raw = settings.get('rename', {})
1134
+ for table_name, df in results.items():
1135
+ if isinstance(df, (pd.MultiIndex, pd.Index)):
1136
+ df = df.to_frame(index=False)
1137
+ if 'solve' not in df.index.names and 'period' in df.index.names:
1138
+ if 'time' in df.index.names:
1139
+ # Use per-timestep mapping for dispatch data (correct per-roll solve names)
1140
+ # Build a dict from (period, time) → solve to avoid sort-merge reordering
1141
+ spt = s.solve_period_time
1142
+ if spt.droplevel('solve').duplicated().any():
1143
+ spt = spt[~spt.droplevel('solve').duplicated(keep='last')]
1144
+ pt_to_solve = dict(zip(spt.droplevel('solve'), spt.get_level_values('solve')))
1145
+ # Key off the named 'period'/'time' levels, not positional
1146
+ # index slots — entity-first indexes (e.g. (unit, period, time))
1147
+ # otherwise read the wrong levels and miss every lookup.
1148
+ period_level = df.index.get_level_values('period')
1149
+ time_level = df.index.get_level_values('time')
1150
+ solve_vals = [pt_to_solve.get((p, t), '') for p, t in zip(period_level, time_level)]
1151
+ df.index = pd.MultiIndex.from_arrays(
1152
+ [solve_vals] + [df.index.get_level_values(n) for n in df.index.names],
1153
+ names=['solve'] + list(df.index.names)
1154
+ )
1155
+ else:
1156
+ # For period-only data, use solve_period to add solve column
1157
+ # Use dict lookup to preserve index order (join reorders alphabetically)
1158
+ unique_sp = s.solve_period[~s.solve_period.droplevel('solve').duplicated(keep='last')]
1159
+ period_to_solve = dict(zip(unique_sp.get_level_values('period'), unique_sp.get_level_values('solve')))
1160
+ if df.index.nlevels == 1:
1161
+ solve_vals = df.index.map(lambda p: period_to_solve.get(p, ''))
1162
+ df.index = pd.MultiIndex.from_arrays(
1163
+ [solve_vals, df.index],
1164
+ names=['solve', df.index.name]
1165
+ )
1166
+ else:
1167
+ period_level = df.index.get_level_values('period')
1168
+ solve_vals = period_level.map(lambda p: period_to_solve.get(p, ''))
1169
+ df.index = pd.MultiIndex.from_arrays(
1170
+ [solve_vals] + [df.index.get_level_values(n) for n in df.index.names],
1171
+ names=['solve'] + list(df.index.names)
1172
+ )
1173
+ names = list(df.index.names)
1174
+ solve_pos = names.index('solve')
1175
+ period_pos = names.index('period')
1176
+ names.pop(solve_pos)
1177
+ if solve_pos < period_pos:
1178
+ period_pos -= 1
1179
+ names.insert(period_pos, 'solve')
1180
+ df.index = df.index.reorder_levels(order=names)
1181
+
1182
+ display_name, _ = _parse_rename_entry(rename_raw.get(table_name, table_name))
1183
+ csv_filename = display_name + '.csv'
1184
+ csv_path = os.path.join(csv_dir, csv_filename)
1185
+ dp_resources.append(datapackage_resource(
1186
+ table_name, csv_filename, *_frame_axes(df)))
1187
+ df = df.reset_index()
1188
+ df.columns.names = [None] * df.columns.nlevels
1189
+ df.to_csv(csv_path, index=False, float_format='%.8g')
1190
+
1191
+ # Sidecar metadata descriptor for the CSV bundle (units + semantics).
1192
+ if dp_resources:
1193
+ dp = {
1194
+ "name": "flextool-output-csv",
1195
+ "profile": "tabular-data-package",
1196
+ "title": "FlexTool processed outputs",
1197
+ "resources": sorted(dp_resources, key=lambda r: r["path"]),
1198
+ }
1199
+ with open(os.path.join(csv_dir, 'datapackage.json'),
1200
+ 'w', encoding='utf-8') as fh:
1201
+ json.dump(dp, fh, indent=2)
1202
+
1203
+ start = log_time('Wrote to csv', start, timing_recorder)
1204
+
1205
+ # Write to excel
1206
+ if 'excel' in write_methods:
1207
+ rename_raw = settings.get('rename', {})
1208
+ excel_dir = os.path.join(output_location, 'output_excel')
1209
+ os.makedirs(excel_dir, exist_ok=True)
1210
+ excel_path = os.path.join(excel_dir, 'output_' + scenario_name + '.xlsx')
1211
+ # Build list of (sheet_name, raw_key, df) sorted alphabetically. The
1212
+ # raw key is kept so unit/semantics metadata can be derived per sheet.
1213
+ sheets: list[tuple[str, str, pd.DataFrame]] = []
1214
+ used_names: set[str] = set()
1215
+ for name, df in results.items():
1216
+ display_name, export = _parse_rename_entry(
1217
+ rename_raw.get(name, name)
1218
+ )
1219
+ if not export:
1220
+ continue
1221
+ if isinstance(df, (pd.MultiIndex, pd.Index)):
1222
+ df = df.to_frame(index=False)
1223
+ if (not df.empty) & (len(df) > 0):
1224
+ sheet_name = display_name[:31]
1225
+ if sheet_name in used_names:
1226
+ suffix = 1
1227
+ while f"{sheet_name[:28]}_{suffix}" in used_names:
1228
+ suffix += 1
1229
+ sheet_name = f"{sheet_name[:28]}_{suffix}"
1230
+ used_names.add(sheet_name)
1231
+ sheets.append((sheet_name, name, df))
1232
+ sheets.sort(key=lambda x: x[0].lower())
1233
+
1234
+ write_excel_with_metadata(excel_path, sheets)
1235
+
1236
+ start = log_time('Wrote to Excel', start, timing_recorder)
1237
+
1238
+ # Write to SpineDB results database
1239
+ if 'spinedb' in write_methods:
1240
+ db_url = results_db_url or (
1241
+ 'sqlite:///' + os.path.join(output_location or '.', 'results.sqlite')
1242
+ )
1243
+ if read_parquet_dir:
1244
+ # Replay path: ``s`` / ``par`` are UNDEFINED here (assigned only
1245
+ # in the native ``else`` branch above). Rebuild the minimal
1246
+ # ``s`` shim the writer needs purely from the processed
1247
+ # ``results`` (no output_raw, no input DB). ``par`` is None, so
1248
+ # the two discount-factor params (#33/#34) are intentionally
1249
+ # dropped — the writer degrades gracefully and logs that.
1250
+ from flextool.process_outputs.spinedb_replay import build_replay_s
1251
+ s_shim = build_replay_s(results)
1252
+ # Prefer the real solve label recovered from node_prices_dt_e so
1253
+ # the ``collapse_solve`` frame (cost_discounted_solve) gets the
1254
+ # same single ``solve`` Map key the native path used; fall back
1255
+ # to the caller's ``solve_name`` / scenario name when no solve
1256
+ # axis is recoverable.
1257
+ replay_solve_name = (
1258
+ s_shim.replay_solve_name or solve_name or scenario_name
1259
+ )
1260
+ write_spinedb(
1261
+ results, s_shim, db_url, scenario_name,
1262
+ replay_solve_name, par=None,
1263
+ )
1264
+ else:
1265
+ write_spinedb(results, s, db_url, scenario_name, solve_name or scenario_name, par=par)
1266
+ start = log_time('Wrote to SpineDB', start, timing_recorder)
1267
+
1268
+ # Phase F — write manifest.json describing the output bundle.
1269
+ # Idempotent (safe to call multiple times); rooted at the work
1270
+ # folder (parent of output_raw/). Failure is non-fatal so it can't
1271
+ # block a successful cascade.
1272
+ #
1273
+ # Skip on the parquet-replay path: the manifest documents the raw
1274
+ # SOLVE bundle (output_raw/ LP-variable shards), which does not exist
1275
+ # when re-creating outputs from processed parquet — running it there
1276
+ # only emits a stray output_raw/ scaffold and warns about every
1277
+ # (legitimately absent) raw shard.
1278
+ if not read_parquet_dir:
1279
+ try:
1280
+ from flextool.engine_polars._parquet_bundle import write_manifest
1281
+ raw_dir = raw_output_dir or 'output_raw'
1282
+ bundle_root = os.path.dirname(raw_dir) or '.'
1283
+ write_manifest(bundle_root)
1284
+ start = log_time('Wrote manifest.json', start, timing_recorder)
1285
+ except Exception as exc: # noqa: BLE001
1286
+ logging.warning("Manifest write failed (non-fatal): %s", exc)