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,125 @@
1
+ """energy_margin_multiplier — invest-only, negative-inflow demand margin emitter.
2
+
3
+ Runs per-solve from ``_emit_solve_time.run`` immediately AFTER
4
+ ``emit_pdtNodeInflow`` (batch 54) and BEFORE the positive/negative inflow
5
+ split (batch 58), so that split inherits the margin.
6
+
7
+ FlexTool sign convention: DEMAND is NEGATIVE inflow (the exogenous
8
+ outflow / ``p_negative_inflow`` term); SUPPLY / generation is POSITIVE
9
+ inflow. The energy_margin feature inflates demand in the invest solve, so
10
+ it scales the negative (demand) rows.
11
+
12
+ What it does
13
+ ------------
14
+ For a node with ``energy_margin_method == "inflow_multiplier"`` and an effective
15
+ ``energy_margin_multiplier`` factor ``!= 1.0``, multiply that node's ``pdtNodeInflow``
16
+ rows by the factor — but ONLY:
17
+
18
+ * in the solve that carries investment periods
19
+ (``bool(state.solve.invest_periods.get(solve_name))``); and
20
+ * on NEGATIVE (demand) inflow rows (``value < 0``) — a ``> 1`` factor makes
21
+ the demand row MORE negative (larger demand) and must never scale a
22
+ positive/zero (supply / net-zero) row.
23
+
24
+ The dispatch solves see the true (un-margined) demand.
25
+
26
+ Early-return byte-parity contract
27
+ ----------------------------------
28
+ If the solve is NOT an invest solve, OR no node has
29
+ ``energy_margin_method == "inflow_multiplier"`` with an effective
30
+ ``energy_margin_multiplier`` factor ``!= 1.0``,
31
+ the emitter returns WITHOUT touching the provider — the batch-54
32
+ ``pdtNodeInflow`` frame stands untouched, so the default (nobody sets a
33
+ margin) is byte-identical to today.
34
+
35
+ Invariant #5 (byte-parity)
36
+ --------------------------
37
+ The re-emitted ``value`` column is rendered with the ``repr()``-based
38
+ :func:`_render_value_column`, NEVER ``.cast(Utf8)`` — a cast diverges from
39
+ ``repr`` on sci-notation padding and ``NaN``. Only the scaled rows are
40
+ re-rendered; every other row keeps its original ``value`` string verbatim,
41
+ so unchanged rows are byte-identical by construction.
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import polars as pl
46
+
47
+ from flextool.engine_polars._emit_inflow_scaling import (
48
+ _read_keyed_float,
49
+ _read_pairs,
50
+ )
51
+ from flextool.engine_polars._emit_provider_io import (
52
+ _emit,
53
+ _provider_key,
54
+ )
55
+ from flextool.engine_polars._vectorize import _render_value_column
56
+
57
+
58
+ def emit_energy_margin_inflow(
59
+ state,
60
+ solve_name,
61
+ input_dir,
62
+ solve_data_dir,
63
+ *,
64
+ provider,
65
+ ) -> None:
66
+ """Apply the invest-only, demand-only energy_margin_multiplier to pdtNodeInflow.
67
+
68
+ See the module docstring for the full contract. A missing/empty
69
+ method or value frame yields no manual nodes ⇒ early return.
70
+ """
71
+ # 1. node → method and node → margin factor.
72
+ method_pairs = _read_pairs(
73
+ input_dir / "node__energy_margin_method.csv", provider=provider,
74
+ )
75
+ method_for_node = {n: m for (n, m) in method_pairs}
76
+ margin_for_node = _read_keyed_float(
77
+ input_dir / "energy_margin_multiplier.csv", provider=provider,
78
+ )
79
+
80
+ # 2. manual = {node: margin}; margin = value if present else 1.0; drop
81
+ # entries whose margin == 1.0 (a no-op).
82
+ manual: dict[str, float] = {}
83
+ for node, method in method_for_node.items():
84
+ if method != "inflow_multiplier":
85
+ continue
86
+ margin = margin_for_node.get(node, 1.0)
87
+ if margin == 1.0:
88
+ continue
89
+ manual[node] = margin
90
+
91
+ # 3. Invest-only gate + no-manual-node gate → early return (byte-parity).
92
+ is_invest = bool(state.solve.invest_periods.get(solve_name))
93
+ if not is_invest or not manual:
94
+ return
95
+
96
+ # 4. Read the pdtNodeInflow frame from the SAME provider key
97
+ # emit_pdtNodeInflow wrote it under. Columns: node, period, time,
98
+ # value — value is Utf8 (repr-rendered floats).
99
+ key = _provider_key(solve_data_dir / "pdtNodeInflow.csv")
100
+ df = provider.get(key)
101
+ if df is None or df.height == 0:
102
+ return
103
+
104
+ # 5. Multiply matching nodes' NEGATIVE-value (demand) rows by their
105
+ # margin; leave every other row (other nodes, and positive/zero supply
106
+ # rows) verbatim. Parse value → float, join the per-node margin (1.0
107
+ # for non-manual), render the scaled float via repr, and select it only
108
+ # where the row is a manual node AND value < 0.
109
+ work = df.with_columns(
110
+ pl.col("value").cast(pl.Float64).alias("__vf"),
111
+ pl.col("node")
112
+ .replace_strict(manual, default=1.0, return_dtype=pl.Float64)
113
+ .alias("__margin"),
114
+ )
115
+ scaled_str = _render_value_column(work["__vf"] * work["__margin"])
116
+ work = work.with_columns(scaled_str.alias("__scaled"))
117
+ new_frame = work.with_columns(
118
+ pl.when((pl.col("__margin") != 1.0) & (pl.col("__vf") < 0.0))
119
+ .then(pl.col("__scaled"))
120
+ .otherwise(pl.col("value"))
121
+ .alias("value"),
122
+ ).select(df.columns)
123
+
124
+ # 6. Re-emit under the SAME key (repr-rendered value → invariant #5).
125
+ _emit(provider, "solve_data/pdtNodeInflow.csv", new_frame)
@@ -0,0 +1,290 @@
1
+ """energy_margin_adder — invest-only, additive negative-inflow demand margin.
2
+
3
+ Runs per-solve from ``_emit_solve_time.run`` immediately AFTER the
4
+ multiplier (``emit_energy_margin_inflow``, batch 54) and BEFORE the
5
+ positive/negative inflow split (batch 58), so that split inherits the
6
+ added demand. Ordering is multiply-then-add.
7
+
8
+ FlexTool sign convention: DEMAND is NEGATIVE inflow (the exogenous
9
+ outflow / ``p_negative_inflow`` term); SUPPLY / generation is POSITIVE
10
+ inflow. This feature adds ``energy_margin_adder`` MWh of EXTRA DEMAND at
11
+ a node in the invest solve, which — because demand is negative inflow —
12
+ means the node's inflow value becomes MORE NEGATIVE:
13
+
14
+ value_new = value_old - adder (adder > 0 ⇒ deeper demand)
15
+
16
+ Adding a positive number would REDUCE demand — the inverted-sign no-op.
17
+ The subtraction above is the ONLY correct direction.
18
+
19
+ What it does vs. the multiplier
20
+ -------------------------------
21
+ The multiplier (``_emit_energy_margin.py``) SCALES a node's existing
22
+ NEGATIVE inflow rows — a no-op on a node with zero native inflow. The
23
+ adder targets exactly those zero-inflow slack nodes: it must CREATE
24
+ negative demand rows for a node that has NO ``pdtNodeInflow`` row at all
25
+ (value ``-adder`` at every invest ``(d, t)``), as well as deepen existing
26
+ rows.
27
+
28
+ For a node with ``energy_margin_method == "inflow_adder"`` and an
29
+ effective ``energy_margin_adder`` value ``!= 0.0``, over the invest
30
+ solve's ``(d, t)`` grid (``steps_in_use.csv``):
31
+
32
+ * an EXISTING ``(node, d, t)`` inflow row → ``value - adder``;
33
+ * a MISSING ``(node, d, t)`` row → a new row with value ``-adder``.
34
+
35
+ Applied ONLY:
36
+
37
+ * in the solve that carries investment periods
38
+ (``bool(state.solve.invest_periods.get(solve_name))``) — dispatch
39
+ solves see the true, un-margined demand; and
40
+ * to nodes whose method is ``inflow_adder`` with a non-zero adder.
41
+
42
+ Shape support & the (d, t) broadcast
43
+ ------------------------------------
44
+ A SCALAR adder is a constant per-timestep addition broadcast across every
45
+ invest ``(d, t)`` (the "spread uniformly" the calibrator relies on); a
46
+ period (or period,time) Map places per-``(d, t)`` values. Both are
47
+ supported: the authored shape is read from the ingested frame's explicit
48
+ index columns (``period`` / ``time``) — safe here because the frame has
49
+ already been normalised by ingestion — and broadcast over the invest grid
50
+ through :func:`._param_shapes.promote_param_to_dt` (NEVER by a hand-rolled
51
+ column-name cross-join; invariant #2).
52
+
53
+ Early-return byte-parity contract
54
+ ----------------------------------
55
+ If the solve is NOT an invest solve, OR no node has
56
+ ``energy_margin_method == "inflow_adder"`` with an effective adder
57
+ ``!= 0.0``, the emitter returns WITHOUT touching the provider — the
58
+ batch-54 ``pdtNodeInflow`` frame stands untouched, so the default (nobody
59
+ sets an adder) is byte-identical to today.
60
+
61
+ RHS-only (protects warm-start)
62
+ ------------------------------
63
+ This emitter only mutates the ``pdtNodeInflow`` provider frame — a
64
+ nodeBalance RHS constant. It introduces NO new variable, constraint,
65
+ column or row into the LP: the node already carries its balance rows;
66
+ creating an inflow row for an existing node only changes the inflow
67
+ constant, never adds an LP row.
68
+
69
+ Invariant #5 (byte-parity)
70
+ --------------------------
71
+ Every touched (deepened) or created row's ``value`` is rendered with the
72
+ ``repr()``-based :func:`_render_value_column`, NEVER ``.cast(Utf8)``.
73
+ Untouched rows (other nodes, non-invest (d,t) — none here since the grid
74
+ IS the invest grid — and any row of a node that isn't an ``inflow_adder``
75
+ node) keep their original ``value`` string verbatim.
76
+
77
+ Known limitation — ``no_inflow`` nodes and group accounting
78
+ -----------------------------------------------------------
79
+ The core ``nodeBalance_eq`` reads ``p_inflow`` DIRECTLY from
80
+ ``pdtNodeInflow.csv`` (``model.py`` ~L1640), so the added demand IS
81
+ served for every node type, including one set ``inflow_method="no_inflow"``.
82
+ BUT the ``p_positive_inflow`` / ``p_negative_inflow`` split
83
+ (``_emit_period_params._derive_positive_negative_inflow``) forces
84
+ ``no_inflow`` nodes to ``0.0`` — and those two frames feed the group
85
+ energy-slack / capacity-margin / reserve auxiliary constraints
86
+ (``_group_slack.py``). So an adder on a node that is BOTH explicitly
87
+ ``no_inflow`` AND a member of such a group is served by the balance yet
88
+ invisible to that group's accounting. This does NOT affect the
89
+ calibrator (its targets are default ``use_original`` nodes, which flow
90
+ through both channels consistently); fix it when ``capacity_margin``
91
+ integration lands, where the split can be made adder-aware with full
92
+ context.
93
+ """
94
+ from __future__ import annotations
95
+
96
+ import polars as pl
97
+
98
+ from polar_high import Param
99
+
100
+ from flextool.engine_polars._emit_inflow_scaling import (
101
+ _read_pairs,
102
+ )
103
+ from flextool.engine_polars._emit_provider_io import (
104
+ _emit,
105
+ _provider_key,
106
+ )
107
+ from flextool.engine_polars._param_shapes import promote_param_to_dt
108
+ from flextool.engine_polars._vectorize import _render_value_column
109
+
110
+ # The pdtNodeInflow frame is all-Utf8: node, period, time, value.
111
+ _PDT_COLS = ("node", "period", "time", "value")
112
+
113
+ # The authored adder ingests through three specs (see _specs.py): a
114
+ # scalar float, a period Map (1d), and a period-time Map (2d). Each lands
115
+ # in its own CSV / Provider key; the emitter reads all three and unions
116
+ # the broadcast frames. A node has a scalar OR a map adder, never both.
117
+ _ADDER_FILES = (
118
+ "energy_margin_adder.csv", # scalar float → shape (node,)
119
+ "pd_energy_margin_adder.csv", # 1d Map → shape (node, d)
120
+ "pdt_energy_margin_adder.csv", # 2d Map → shape (node, d, t)
121
+ )
122
+
123
+
124
+ def _broadcast_authored_adder(adder_df, manual_nodes, dt_grid):
125
+ """Broadcast one authored adder frame over the invest (d, t) grid.
126
+
127
+ *adder_df* is an all-Utf8 ingested frame whose FIRST column is the
128
+ node and whose LAST column is the value; any middle columns are the
129
+ authored Map index axes (``period`` and/or ``time``). Returns a frame
130
+ with columns ``node, period, time, __adder`` (Float64 adder) restricted
131
+ to *manual_nodes*, or ``None`` when there is nothing to apply (empty
132
+ frame, an unrecognised index axis, or all-zero / null adders).
133
+ """
134
+ if adder_df is None or adder_df.height == 0:
135
+ return None
136
+ cols = adder_df.columns
137
+ if len(cols) < 2:
138
+ return None
139
+
140
+ # Resolve the authored shape from the frame's explicit index columns
141
+ # and build the rename → canonical (node, d, t) Param dims. Any index
142
+ # column that is neither ``period`` nor ``time`` is an unrecognised
143
+ # axis for this parameter — skip this frame rather than guessing a
144
+ # broadcast (byte-parity).
145
+ node_col, value_col = cols[0], cols[-1]
146
+ rename: dict[str, str] = {node_col: "node"}
147
+ dims: list[str] = ["node"]
148
+ for c in cols[1:-1]:
149
+ cl = c.strip().lower()
150
+ if cl == "period":
151
+ rename[c] = "d"
152
+ dims.append("d")
153
+ elif cl in ("time", "t"):
154
+ rename[c] = "t"
155
+ dims.append("t")
156
+ else:
157
+ return None
158
+
159
+ # Restrict to the manual (inflow_adder) nodes, cast the value to
160
+ # Float64, and drop null / zero adders (a zero adder is a no-op).
161
+ work = (
162
+ adder_df.rename(rename)
163
+ .with_columns(
164
+ pl.col(value_col).cast(pl.Float64, strict=False).alias("value"),
165
+ )
166
+ .filter(pl.col("node").is_in(list(manual_nodes)))
167
+ .filter(pl.col("value").is_not_null() & (pl.col("value") != 0.0))
168
+ .select([*dims, "value"])
169
+ )
170
+ if work.height == 0:
171
+ return None
172
+
173
+ # Broadcast the authored shape over the invest solve's (d, t) grid.
174
+ # Route through _param_shapes' promote_param_to_dt (invariant #2 —
175
+ # never a hand-rolled column-name cross-join): a SCALAR (node,) Param
176
+ # cross-joins the whole grid; a (node, d) / (node, d, t) Param joins on
177
+ # its authored axis.
178
+ adder_dt = (
179
+ promote_param_to_dt(Param(tuple(dims), work), dt_grid)
180
+ .select("node", "d", "t", "value")
181
+ .collect()
182
+ .rename({"d": "period", "t": "time", "value": "__adder"})
183
+ )
184
+ if adder_dt.height == 0:
185
+ return None
186
+ return adder_dt
187
+
188
+
189
+ def emit_energy_margin_adder(
190
+ state,
191
+ solve_name,
192
+ input_dir,
193
+ solve_data_dir,
194
+ *,
195
+ provider,
196
+ ) -> None:
197
+ """Apply the invest-only additive energy_margin_adder to pdtNodeInflow.
198
+
199
+ See the module docstring for the full contract. A non-invest solve,
200
+ no ``inflow_adder`` node, or an all-zero / missing adder frame yields
201
+ an early return (byte-parity).
202
+ """
203
+ # 1. node → method; manual = the inflow_adder nodes.
204
+ method_pairs = _read_pairs(
205
+ input_dir / "node__energy_margin_method.csv", provider=provider,
206
+ )
207
+ manual_nodes = {n for (n, m) in method_pairs if m == "inflow_adder"}
208
+
209
+ # 2. Invest-only gate + no-manual-node gate → early return (byte-parity).
210
+ is_invest = bool(state.solve.invest_periods.get(solve_name))
211
+ if not is_invest or not manual_nodes:
212
+ return
213
+
214
+ # 3. Build the invest solve's (d, t) grid once. ``steps_in_use.csv``
215
+ # is the current (invest) solve's active (period, time) grid — the
216
+ # same grid every pdtX emitter expands over.
217
+ dt_pairs = _read_pairs(
218
+ solve_data_dir / "steps_in_use.csv", provider=provider,
219
+ )
220
+ if not dt_pairs:
221
+ return
222
+ dt_grid = pl.DataFrame(
223
+ {"d": [d for d, _t in dt_pairs], "t": [t for _d, t in dt_pairs]},
224
+ schema={"d": pl.Utf8, "t": pl.Utf8},
225
+ )
226
+
227
+ # 4. Read every authored adder file (scalar float, period Map, and
228
+ # period-time Map — each in its own Provider key) and broadcast each
229
+ # over the (d, t) grid via _broadcast_authored_adder. A node carries
230
+ # a scalar OR a map adder, never both, so the per-file frames are
231
+ # disjoint by node; union them into one (node, period, time, __adder)
232
+ # frame. No authored value anywhere → early return (byte-parity).
233
+ parts = []
234
+ for fname in _ADDER_FILES:
235
+ adder_df = provider.get(_provider_key(input_dir / fname))
236
+ part = _broadcast_authored_adder(adder_df, manual_nodes, dt_grid)
237
+ if part is not None:
238
+ parts.append(part)
239
+ if not parts:
240
+ return
241
+ adder_dt = pl.concat(parts) if len(parts) > 1 else parts[0]
242
+ if adder_dt.height == 0:
243
+ return
244
+
245
+ # 7. Merge into pdtNodeInflow. Existing rows of an inflow_adder node
246
+ # are deepened (value - adder); grid cells with no existing row are
247
+ # CREATED with value -adder. Every other row is verbatim.
248
+ df = provider.get(_provider_key(solve_data_dir / "pdtNodeInflow.csv"))
249
+ if df is None:
250
+ df = pl.DataFrame(schema={c: pl.Utf8 for c in _PDT_COLS})
251
+ out_cols = df.columns
252
+
253
+ # 7a. Deepen existing rows. Left-join the per-(node, d, t) adder; where
254
+ # present, re-render (value - adder) via repr, else keep the original
255
+ # value string verbatim (byte-parity for untouched rows).
256
+ existing = df.with_columns(
257
+ pl.col("value").cast(pl.Float64).alias("__vf"),
258
+ ).join(adder_dt, on=["node", "period", "time"], how="left")
259
+ deepened = _render_value_column(
260
+ existing["__vf"] - existing["__adder"].fill_null(0.0),
261
+ )
262
+ df_mod = (
263
+ existing.with_columns(deepened.alias("__deep"))
264
+ .with_columns(
265
+ pl.when(pl.col("__adder").is_not_null())
266
+ .then(pl.col("__deep"))
267
+ .otherwise(pl.col("value"))
268
+ .alias("value"),
269
+ )
270
+ .select(out_cols)
271
+ )
272
+
273
+ # 7b. Create rows for grid cells the node had no inflow for. value =
274
+ # -adder (adding demand ⇒ more negative). Deterministic order.
275
+ created = adder_dt.join(
276
+ df.select("node", "period", "time"),
277
+ on=["node", "period", "time"],
278
+ how="anti",
279
+ ).sort(["node", "period", "time"])
280
+ created_frame = (
281
+ created.with_columns(
282
+ _render_value_column(-created["__adder"]).alias("value"),
283
+ )
284
+ .select(out_cols)
285
+ )
286
+
287
+ new_frame = pl.concat([df_mod, created_frame])
288
+
289
+ # 8. Re-emit under the SAME key (repr-rendered value → invariant #5).
290
+ _emit(provider, "solve_data/pdtNodeInflow.csv", new_frame)