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,223 @@
1
+ """Pure post-solve readers for the adequacy calibrator.
2
+
3
+ Each function takes the directory that holds a solve's result parquets
4
+ (``<out_root>/output_parquet/<scenario>/``) and returns plain Python
5
+ dicts. Nothing here solves, mutates the DB, or touches the network — they
6
+ are pure readers, so the loop can call them once per iteration and the
7
+ tests can point them straight at real parquets.
8
+
9
+ Why :func:`flextool.lean_parquet.read_lean_parquet` and not
10
+ ``pd.read_parquet``
11
+ -------------------------------------------------------------------
12
+ FlexTool writes these tables with a compact custom footer that records the
13
+ MultiIndex level names; a raw ``pd.read_parquet`` returns flat
14
+ string-tuple column names and loses the level structure the group-bys here
15
+ rely on. :func:`read_lean_parquet` reconstructs the real row/column
16
+ MultiIndex, so ``groupby(level=...)`` works.
17
+
18
+ Signals
19
+ -------
20
+ * ``node_slack_up_d_e`` — per-node unserved-energy up-slack (annual MWh,
21
+ ≥0). Dense and always present on a valid solve → the robust adequacy
22
+ signal the loop steers on.
23
+ * ``unit_curtailment_outputNode_d_ee`` — per-(unit, sink) curtailment
24
+ (MWh); OPTIONAL (absent when nothing curtails). Provided now for the
25
+ over-build guard (C1c) to consume.
26
+ * ``cost_node_discounted_d_ec`` — discounted per-(node, category) node
27
+ cost (M€); its ``"upward slack penalty"`` category is the monetised
28
+ slack. This table is legitimately ABSENT on a zero-slack solve
29
+ (out_costs.py skips an empty category) → treated as zero penalty, never
30
+ an error.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from pathlib import Path
36
+
37
+ from flextool.lean_parquet import read_lean_parquet
38
+
39
+ # Registry keys of the parquet tables read here. Resolved to on-disk
40
+ # basenames through P2's registry helper so a schema rename breaks loudly
41
+ # here rather than silently missing a file.
42
+ _SLACK_KEY = "node_slack_up_d_e"
43
+ _SLACK_KEY_DT = "node_slack_up_dt_e"
44
+ _CURTAILMENT_KEY = "unit_curtailment_outputNode_d_ee"
45
+ _COST_KEY = "cost_node_discounted_d_ec"
46
+ _UNIT_CAPACITY_KEY = "unit_capacity_ed_p"
47
+ _TOTAL_COST_KEY = "costs_discounted_p_"
48
+
49
+ _SLACK_PENALTY_CATEGORY = "upward slack penalty"
50
+
51
+
52
+ def _resolve_filename(key: str) -> str:
53
+ """Resolve an output *key* to its ``<key>.parquet`` basename.
54
+
55
+ Prefers P2's registry helper
56
+ (``flextool.calibrate._solve_status._registry_filename``) so an
57
+ OUTPUT_TRANSFORM rename surfaces as a clear error; falls back to the
58
+ ``<key>.parquet`` basename (which matches the OUTPUT_TRANSFORM keys) if
59
+ that private helper is ever removed.
60
+ """
61
+ try:
62
+ from flextool.calibrate._solve_status import _registry_filename
63
+
64
+ return _registry_filename(key)
65
+ except (ImportError, AttributeError):
66
+ return f"{key}.parquet"
67
+
68
+
69
+ def read_residual_unserved(assess_dir: Path) -> dict[str, float]:
70
+ """Return ``{node: total_unserved_MWh}`` from ``node_slack_up_d_e``.
71
+
72
+ Sums each node's up-slack over all periods and drops the ``scenario``
73
+ column level, so the result is keyed by node. This is the robust,
74
+ always-present adequacy signal the loop converges on.
75
+ """
76
+ path = Path(assess_dir) / _resolve_filename(_SLACK_KEY)
77
+ df = read_lean_parquet(path)
78
+ # columns: (scenario, node); index: period. Sum over periods, then
79
+ # collapse any scenario level so the key is the node name.
80
+ totals = df.sum(axis=0).groupby(level="node").sum()
81
+ return {str(node): float(val) for node, val in totals.items()}
82
+
83
+
84
+ def read_residual_unserved_dt(assess_dir: Path) -> dict[str, "object"]:
85
+ """Return ``{node: DataFrame[period, time, value]}`` from ``node_slack_up_dt_e``.
86
+
87
+ The per-``(period, time)`` companion to :func:`read_residual_unserved`:
88
+ where that reader collapses the up-slack to one annual MWh per node, this
89
+ one keeps the FULL per-timestep profile UNFOLDED onto the base timeline —
90
+ the stress SHAPE the ``timed`` sizer redistributes the additive margin
91
+ over. ``node_slack_up_dt_e`` has row index ``(period, time)`` and column
92
+ levels ``(scenario, node)``; per node the scenario level is collapsed
93
+ (summed) and the result returned as a tidy pandas frame with columns
94
+ ``period``, ``time``, ``value`` (one row per non-null base cell).
95
+
96
+ Read with :func:`read_lean_parquet` (never ``pd.read_parquet``) so the
97
+ ``(period, time)`` / ``(scenario, node)`` MultiIndex is reconstructed.
98
+ A node absent from the table (no slack rows) simply has no key.
99
+ """
100
+ path = Path(assess_dir) / _resolve_filename(_SLACK_KEY_DT)
101
+ df = read_lean_parquet(path)
102
+ out: dict[str, object] = {}
103
+ node_level = df.columns.get_level_values("node")
104
+ for node in dict.fromkeys(node_level): # ordered-unique node names
105
+ sub = df.loc[:, node_level == node]
106
+ # Collapse any scenario level → one value per (period, time) row.
107
+ series = sub.sum(axis=1)
108
+ frame = series.reset_index()
109
+ frame.columns = ["period", "time", "value"]
110
+ out[str(node)] = frame
111
+ return out
112
+
113
+
114
+ def read_curtailment_by_sink(assess_dir: Path) -> dict[str, float]:
115
+ """Return ``{sink_node: total_curtailment_MWh}`` or ``{}`` if absent.
116
+
117
+ Reads ``unit_curtailment_outputNode_d_ee`` (columns
118
+ ``(scenario, unit, sink)``), sums each column over periods and groups
119
+ the totals by ``sink`` node. The file is OPTIONAL (no curtailment ⇒ no
120
+ table); a missing file yields ``{}``. Consumed by the over-build guard
121
+ (C1c).
122
+ """
123
+ path = Path(assess_dir) / _resolve_filename(_CURTAILMENT_KEY)
124
+ if not path.is_file():
125
+ return {}
126
+ df = read_lean_parquet(path)
127
+ totals = df.sum(axis=0).groupby(level="sink").sum()
128
+ return {str(sink): float(val) for sink, val in totals.items()}
129
+
130
+
131
+ def read_slack_penalty(assess_dir: Path) -> tuple[float, dict[str, float]]:
132
+ """Return ``(total_Meur, {node: penalty_Meur})`` from the node-cost table.
133
+
134
+ Reads ``cost_node_discounted_d_ec`` (row index ``(period, node)``,
135
+ columns ``(scenario, category)``), selects the
136
+ ``"upward slack penalty"`` category and sums it per node (over periods
137
+ and scenarios). This table is legitimately ABSENT on a zero-slack
138
+ solve (out_costs.py skips an empty cost category) → returns
139
+ ``(0.0, {})`` rather than raising.
140
+ """
141
+ path = Path(assess_dir) / _resolve_filename(_COST_KEY)
142
+ if not path.is_file():
143
+ return 0.0, {}
144
+ df = read_lean_parquet(path)
145
+ is_penalty = df.columns.get_level_values("category") == _SLACK_PENALTY_CATEGORY
146
+ penalty = df.loc[:, is_penalty]
147
+ if penalty.shape[1] == 0:
148
+ return 0.0, {}
149
+ # One value per (period, node) row across the selected penalty
150
+ # column(s); collapse periods (and any scenario level) per node.
151
+ per_row = penalty.sum(axis=1)
152
+ per_node = per_row.groupby(level="node").sum()
153
+ by_node = {str(node): float(val) for node, val in per_node.items()}
154
+ total = float(per_row.sum())
155
+ return total, by_node
156
+
157
+
158
+ def read_unit_capacity_total(assess_dir: Path) -> dict[str, float]:
159
+ """Return ``{unit: total_capacity_MW}`` from ``unit_capacity_ed_p``.
160
+
161
+ Reads the post-invest fleet the solve actually committed to: the ``total``
162
+ column of ``unit_capacity_ed_p`` (row index ``(unit, period)``, column
163
+ levels ``(scenario, parameter)`` with ``parameter`` in
164
+ ``{existing, invested, divested, total}`` — see
165
+ ``flextool.process_outputs.out_capacity.unit_capacity``). ``total`` is the
166
+ CUMULATIVE ``entity_all_capacity`` (existing + carried-forward invest), so
167
+ it is non-decreasing across periods.
168
+
169
+ Per unit the capacity is taken as the MAX over periods — the *mature*
170
+ (fully-built) fleet the net-load signal must be sized against; for a
171
+ single-invest-period solve this is simply that period's total, and for a
172
+ multi-period invest solve it is the final built-out capacity. Any
173
+ ``scenario`` column level is collapsed (summed; a single-scenario solve
174
+ dir has exactly one) before the per-unit reduction. Units whose ``total``
175
+ is entirely null (never realized) are dropped, so a unit absent from the
176
+ result cleanly falls back to its existing cap in
177
+ :func:`flextool.representative_periods.netload.build_group_capacities`.
178
+ Sorted by unit name for determinism.
179
+
180
+ This is a plain reader (no solving / DB / network), used by the net-load
181
+ solve-iteration driver to feed each iteration's invested caps back into the
182
+ representative-period selection.
183
+ """
184
+ path = Path(assess_dir) / _resolve_filename(_UNIT_CAPACITY_KEY)
185
+ df = read_lean_parquet(path)
186
+ # Select the ``total`` parameter column, collapsing any scenario level so
187
+ # the result is a Series indexed by (unit, period).
188
+ total = df.xs("total", level="parameter", axis=1).sum(axis=1)
189
+ per_unit = total.groupby(level="unit").max().dropna()
190
+ return {str(unit): float(val) for unit, val in sorted(per_unit.items())}
191
+
192
+
193
+ def read_total_system_cost(assess_dir: Path) -> float:
194
+ """Return the discounted total system cost (M€) from ``costs_discounted_p_``.
195
+
196
+ ``costs_discounted_p_`` is the authoritative full-horizon cost summary
197
+ (``flextool.process_outputs.out_costs.cost_summaries``): every investment
198
+ AND dispatch cost category summed over all realized periods, discounted and
199
+ years-represented-weighted exactly as the LP objective is. It INCLUDES the
200
+ ``upward slack penalty`` / ``downward slack penalty`` categories (the
201
+ monetised unserved-energy penalties) and the negative ``commodity_sales``
202
+ revenue term, so summing every category cell yields the same signed total
203
+ the objective minimises — the correct comparability metric for the
204
+ keep-best selection (same full timeline + same penalty prices each
205
+ iteration).
206
+
207
+ On disk the table is the per-scenario cost Series tagged with a single
208
+ ``scenario`` column level (row index ``category``); every numeric cell is
209
+ summed to the scalar total.
210
+ """
211
+ path = Path(assess_dir) / _resolve_filename(_TOTAL_COST_KEY)
212
+ df = read_lean_parquet(path)
213
+ return float(df.to_numpy().sum())
214
+
215
+
216
+ __all__ = [
217
+ "read_curtailment_by_sink",
218
+ "read_residual_unserved",
219
+ "read_residual_unserved_dt",
220
+ "read_slack_penalty",
221
+ "read_total_system_cost",
222
+ "read_unit_capacity_total",
223
+ ]
@@ -0,0 +1,263 @@
1
+ """Pure rendering of a calibration run's outcome (C2).
2
+
3
+ This module turns an in-memory :class:`~flextool.calibrate._loop.CalibResult`
4
+ into (a) machine-readable CSV artifacts and (b) a compact human-readable
5
+ stdout summary. It does **no** solving and re-reads **no** parquet — it
6
+ operates purely on the trajectory already carried by ``CalibResult`` — so it
7
+ is cheap, deterministic, and safe to call anywhere.
8
+
9
+ Two CSVs are written by :func:`write_report`:
10
+
11
+ * a per-``(iteration, node)`` **long** table — one row per node observed in
12
+ each iteration, carrying that iteration's residual unserved energy, the
13
+ adder snapshot that was solved, the per-sink curtailment, the monetised
14
+ per-node slack, and whether the node ended the run flagged
15
+ resource-capped;
16
+ * a per-iteration **summary** table — total unserved energy, total slack
17
+ penalty, the count of shedding nodes, the run's flagged count, and the
18
+ convergence flag.
19
+
20
+ Both are emitted in a fully deterministic order: iterations ascending,
21
+ nodes sorted lexicographically.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import csv
27
+ from pathlib import Path
28
+
29
+ from flextool.calibrate._loop import CalibResult, IterRecord
30
+
31
+ # CSV column orders — pinned here so both the writer and any downstream
32
+ # consumer (and the tests) agree on the exact schema.
33
+ LONG_COLUMNS = [
34
+ "iteration",
35
+ "node",
36
+ "residual_mwh",
37
+ "adder_mwh",
38
+ "curtailment_mwh",
39
+ "penalty_meur",
40
+ "flagged",
41
+ ]
42
+ SUMMARY_COLUMNS = [
43
+ "iteration",
44
+ "total_unserved_mwh",
45
+ "total_penalty_meur",
46
+ "n_shedding",
47
+ "n_flagged",
48
+ "solve_seconds",
49
+ "converged",
50
+ ]
51
+
52
+ LONG_FILENAME = "calibration_by_iteration_node.csv"
53
+ SUMMARY_FILENAME = "calibration_summary.csv"
54
+
55
+
56
+ def _record_nodes(record: IterRecord) -> list[str]:
57
+ """Sorted union of every node observed in one iteration's signals."""
58
+ nodes: set[str] = set()
59
+ nodes.update(record.residual)
60
+ nodes.update(record.adders)
61
+ nodes.update(record.curtailment)
62
+ nodes.update(record.penalty_by_node)
63
+ return sorted(nodes)
64
+
65
+
66
+ def _n_shedding(record: IterRecord) -> int:
67
+ """Count of nodes with strictly-positive residual unserved energy."""
68
+ return sum(1 for v in record.residual.values() if v > 0.0)
69
+
70
+
71
+ def _adder_scalar(adder: "float | dict") -> float:
72
+ """Collapse a node's adder to one scalar MWh for reporting.
73
+
74
+ A ``uniform`` adder is already the scalar per-timestep value; a ``timed``
75
+ adder is a ``{(period, time): float}`` map — reported as the SUM of its
76
+ per-cell values (its total per-timestep adder mass), so the CSV/summary
77
+ stay single-valued and sortable across both modes.
78
+ """
79
+ if isinstance(adder, dict):
80
+ return float(sum(adder.values()))
81
+ return float(adder)
82
+
83
+
84
+ def write_report(result: CalibResult, *, out_dir: Path) -> list[Path]:
85
+ """Write the two machine-readable CSV artifacts for *result*.
86
+
87
+ Emits :data:`LONG_FILENAME` (per-``(iteration, node)``) and
88
+ :data:`SUMMARY_FILENAME` (per-iteration) into *out_dir*, creating it if
89
+ needed. Rows are ordered iterations ascending then nodes sorted, so the
90
+ output is byte-deterministic for a given result.
91
+
92
+ Returns the two written paths in ``[long, summary]`` order.
93
+ """
94
+ out_dir = Path(out_dir)
95
+ out_dir.mkdir(parents=True, exist_ok=True)
96
+ flagged = set(result.guard_flagged_nodes)
97
+
98
+ long_path = out_dir / LONG_FILENAME
99
+ with long_path.open("w", newline="", encoding="utf-8") as fh:
100
+ writer = csv.writer(fh)
101
+ writer.writerow(LONG_COLUMNS)
102
+ for record in sorted(result.trajectory, key=lambda r: r.iteration):
103
+ for node in _record_nodes(record):
104
+ writer.writerow(
105
+ [
106
+ record.iteration,
107
+ node,
108
+ record.residual.get(node, 0.0),
109
+ _adder_scalar(record.adders.get(node, 0.0)),
110
+ record.curtailment.get(node, 0.0),
111
+ record.penalty_by_node.get(node, 0.0),
112
+ node in flagged,
113
+ ]
114
+ )
115
+
116
+ summary_path = out_dir / SUMMARY_FILENAME
117
+ with summary_path.open("w", newline="", encoding="utf-8") as fh:
118
+ writer = csv.writer(fh)
119
+ writer.writerow(SUMMARY_COLUMNS)
120
+ ordered = sorted(result.trajectory, key=lambda r: r.iteration)
121
+ last_iteration = ordered[-1].iteration if ordered else None
122
+ for record in ordered:
123
+ # ``converged`` is a run-level outcome; it is true only on the
124
+ # final iteration of a converged run (the one that met the
125
+ # threshold), false on every earlier row.
126
+ row_converged = result.converged and record.iteration == last_iteration
127
+ writer.writerow(
128
+ [
129
+ record.iteration,
130
+ record.total_unserved,
131
+ record.penalty_total,
132
+ _n_shedding(record),
133
+ len(result.guard_flagged_nodes),
134
+ record.solve_seconds,
135
+ row_converged,
136
+ ]
137
+ )
138
+
139
+ return [long_path, summary_path]
140
+
141
+
142
+ def format_summary(result: CalibResult) -> str:
143
+ """Render a compact human-readable summary of *result* for stdout.
144
+
145
+ Covers: convergence status and iterations run; initial→final total
146
+ unserved energy (and slack penalty, when non-zero); the final per-node
147
+ adders sorted descending with each node's final residual; and a clearly
148
+ marked resource-capped section listing the guard-flagged nodes (the ones
149
+ that need firm capacity / imports / storage rather than more demand
150
+ margin). Pure — reads only the in-memory *result*.
151
+ """
152
+ lines: list[str] = []
153
+ lines.append("=" * 60)
154
+ lines.append("Adequacy-margin calibration summary")
155
+ lines.append("=" * 60)
156
+
157
+ n_adjust = result.iterations_run - 1
158
+ if result.stop_reason == "converged":
159
+ lines.append(
160
+ f"Status: CONVERGED after {result.iterations_run} solve(s) "
161
+ f"({n_adjust} adjustment iteration(s))."
162
+ )
163
+ elif result.stop_reason == "stalled":
164
+ lines.append(
165
+ f"Status: CONVERGED MODULO RESOURCE-CAPPED NODES after "
166
+ f"{result.iterations_run} solve(s) ({n_adjust} adjustment "
167
+ f"iteration(s))."
168
+ )
169
+ lines.append(
170
+ "The energy-margin lever is exhausted: every remaining shedding "
171
+ "node is resource-capped, so no further demand margin can be "
172
+ "added and another solve would only reproduce this one."
173
+ )
174
+ else: # "budget_exhausted"
175
+ lines.append(
176
+ f"Status: NOT CONVERGED — ran the full budget of "
177
+ f"{result.iterations_run} solve(s) without reaching the slack "
178
+ f"threshold."
179
+ )
180
+
181
+ trajectory = sorted(result.trajectory, key=lambda r: r.iteration)
182
+ if trajectory:
183
+ first = trajectory[0]
184
+ last = trajectory[-1]
185
+ lines.append("")
186
+ lines.append(
187
+ f"Total unserved energy: {first.total_unserved:,.1f} MWh "
188
+ f"(baseline) -> {last.total_unserved:,.1f} MWh (final)."
189
+ )
190
+ if first.penalty_total or last.penalty_total:
191
+ lines.append(
192
+ f"Slack penalty: {first.penalty_total:,.3f} M-EUR "
193
+ f"(baseline) -> {last.penalty_total:,.3f} M-EUR (final)."
194
+ )
195
+ if any(r.solve_seconds for r in trajectory):
196
+ per_iter = ", ".join(
197
+ f"#{r.iteration}:{r.solve_seconds:,.1f}s" for r in trajectory
198
+ )
199
+ total_s = sum(r.solve_seconds for r in trajectory)
200
+ lines.append(
201
+ f"Solve time: {total_s:,.1f} s total "
202
+ f"({per_iter})."
203
+ )
204
+
205
+ # Final per-node adders, largest first; pair each with its final residual.
206
+ final_residual = trajectory[-1].residual if trajectory else {}
207
+ lines.append("")
208
+ if result.final_adders:
209
+ lines.append("Final energy-margin adders (MWh/timestep), largest first:")
210
+ for node, adder in sorted(
211
+ result.final_adders.items(),
212
+ key=lambda kv: (-_adder_scalar(kv[1]), kv[0]),
213
+ ):
214
+ resid = final_residual.get(node, 0.0)
215
+ lines.append(
216
+ f" {node:<24} adder={_adder_scalar(adder):>14,.4f} "
217
+ f"final residual={resid:>14,.1f} MWh"
218
+ )
219
+ else:
220
+ lines.append("Final energy-margin adders: none (no node was bumped).")
221
+
222
+ # Resource-capped section — nodes the over-build guard froze.
223
+ lines.append("")
224
+ lines.append(
225
+ "Resource-capped nodes (need firm capacity / imports / storage, "
226
+ "not more demand margin):"
227
+ )
228
+ if result.guard_flagged_nodes:
229
+ for node in result.guard_flagged_nodes:
230
+ resid = final_residual.get(node, 0.0)
231
+ lines.append(f" {node:<24} final residual={resid:>14,.1f} MWh")
232
+ else:
233
+ lines.append(" none")
234
+
235
+ if result.stop_reason == "stalled" and trajectory:
236
+ lines.append("")
237
+ lines.append(
238
+ f"Residual unserved energy still {trajectory[-1].total_unserved:,.1f} "
239
+ f"MWh at exit, but the calibration is CONVERGED MODULO the "
240
+ f"resource-capped nodes above: that residual sits on the "
241
+ f"guard-flagged nodes, which need firm capacity / imports / "
242
+ f"storage — not more demand margin. Raising --iterations will not "
243
+ f"help; the demand-margin lever is exhausted."
244
+ )
245
+ elif result.stop_reason == "budget_exhausted" and trajectory:
246
+ lines.append("")
247
+ lines.append(
248
+ f"Residual unserved energy still {trajectory[-1].total_unserved:,.1f} "
249
+ f"MWh at exit — raise --iterations or investigate the flagged nodes."
250
+ )
251
+
252
+ lines.append("=" * 60)
253
+ return "\n".join(lines)
254
+
255
+
256
+ __all__ = [
257
+ "LONG_COLUMNS",
258
+ "LONG_FILENAME",
259
+ "SUMMARY_COLUMNS",
260
+ "SUMMARY_FILENAME",
261
+ "format_summary",
262
+ "write_report",
263
+ ]