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,1353 @@
1
+ """Cluster B — existing chain & invest sets (Δ.6).
2
+
3
+ Lazy-polars port of flextool's existing-capacity cascade and the
4
+ invest/divest set family. Replaces the eager-Python helpers spread
5
+ across :mod:`._derived_params` (``p_entity_all_existing_from_source``,
6
+ ``ed_invest_set_from_source`` / ``ed_divest_set_from_source``,
7
+ ``edd_invest_lookback_set_from_source``,
8
+ ``p_entity_max_units_from_source``, etc.) and the workdir-CSV reads
9
+ they currently depend on.
10
+
11
+ Cluster B fields (per
12
+ ``audit/native_data_path_design_derived_clusters.md``):
13
+
14
+ * ``entityInvest`` / ``entityDivest`` — the (e,)-shaped entity
15
+ projection of ``entity__invest_method`` filtered by the
16
+ not-allowed-method constants
17
+ (``preprocessing/invest_method_sets.py``). These were the trap that
18
+ Δ.5 caught: the eager helper derived them from
19
+ ``ed_invest_set`` / ``ed_divest_set`` instead of from
20
+ ``entity__invest_method``. This module derives them correctly.
21
+ * ``e_invest_total`` / ``e_divest_total`` — projections of the above
22
+ filtered by INVEST_TOTAL / RETIRE_TOTAL methods
23
+ (``preprocessing/invest_total_sets.py``).
24
+ * ``ed_invest_set`` / ``ed_divest_set`` — (e, d) cross-product gated
25
+ by entityInvest × period_invest, eea ≠ 0 OR capacity-constraint
26
+ membership, and the no_investment lifetime-expiry forbidden set.
27
+ * ``pd_invest_set`` / ``nd_invest_set`` / ``pd_divest_set`` /
28
+ ``nd_divest_set`` — partitions of ``ed_invest_set`` / ``ed_divest_set``
29
+ by entity-class (``unit ∪ connection`` → process-side; ``node`` →
30
+ node-side).
31
+ * ``edd_history`` (and its ``_choice`` / ``_automatic`` / ``_no_invest``
32
+ components) — the (e, d_history, d) triple-set encoding the
33
+ lifetime-window walk:
34
+ ``pdy[d] >= pdy[d_history]`` with optional bounded gate
35
+ ``pdy[d] < pdy[d_history] + edEntity_lifetime[e, d_history]``.
36
+ Filtered to entities whose ``lifetime_method`` is in
37
+ {``reinvest_choice``, ``reinvest_automatic``, ``no_investment``}.
38
+ * ``edd_invest_set`` — ``edd_history`` ∩ ``ed_invest_set`` on
39
+ ``(e, d_history)`` (i.e. the history rows that correspond to a
40
+ current-solve invest decision).
41
+ * ``edd_invest_lookback_set`` — strict-lookback variant
42
+ (``yr[d_invest] < yr[d]``) used by the prebuilt-capacity LHS.
43
+ * ``edd_divest_active`` — ``pd_divest`` × period grid filtered to
44
+ ``yr[d_divest] <= yr[d]`` (used in cap-margin user constraint).
45
+ * ``p_entity_all_existing`` — per-(e, d) lifetime-cumulative existing
46
+ capacity. Algorithm:
47
+ * solve-first: pre-existing[e, d] (lifetime-gated entity.existing).
48
+ * later-solves: Σ_{d_history ∈ edd_history(e, d) ∧ realized}
49
+ p_entity_period_existing_capacity[e, d_history]
50
+ − p_entity_divested[e] (when e ∈ entityDivest).
51
+ * ``p_entity_period_existing_capacity`` — handoff carrier read from
52
+ ``flex_data.p_entity_period_existing_capacity`` (already populated
53
+ by the CSV-side handoff loader); the lazy path doesn't recompute it.
54
+ * ``p_entity_max_units`` — per-(e, d) max unit count via the
55
+ unitsize cascade × the max_capacity branch fork.
56
+
57
+ The shared :func:`._derived_walks.period_walk_iterator` lifted out of
58
+ Cluster A's ``_derived_npv`` is consumed throughout for the lifetime-
59
+ window walks.
60
+
61
+ All helpers are lazy until the public ``apply_existing_chain`` boundary
62
+ calls ``.collect()`` once per emitted Param.
63
+ """
64
+ from __future__ import annotations
65
+
66
+ from pathlib import Path
67
+ from typing import TYPE_CHECKING
68
+
69
+ import polars as pl
70
+
71
+ from polar_high import Param
72
+
73
+ from flextool.engine_polars._axis_enums import (
74
+ alias_to_axis,
75
+ rename_to_axis,
76
+ schema_dtype,
77
+ )
78
+ from ._derived_walks import period_walk_iterator, WindowMethod
79
+
80
+
81
+ # Substrate handle for the cascade-wide axis enum vocabulary.
82
+ # Bare ``None`` here; ``cast_dim`` / ``schema_dtype`` in
83
+ # ``_axis_enums`` fall back to ``_LIVE_AXIS_ENUMS_CTX`` (the live
84
+ # ContextVar) when this is ``None``, so substrate sites pick up
85
+ # activation set by ``load_flextool`` automatically.
86
+ _enums: "dict | None" = None
87
+
88
+ if TYPE_CHECKING:
89
+ from flextool.engine_polars._input_source import InputSource
90
+ from flextool.engine_polars._solve_context import SolveContext
91
+
92
+
93
+ # -- Method-enum constants (mirror flextool/flextool_base.dat:211-212) -------
94
+
95
+ _INVEST_NOT_ALLOWED: frozenset[str] = frozenset((
96
+ "not_allowed", "retire_period", "retire_total", "retire_no_limit",
97
+ ))
98
+ _DIVEST_NOT_ALLOWED: frozenset[str] = frozenset((
99
+ "not_allowed", "invest_period", "invest_total", "invest_no_limit",
100
+ ))
101
+ # preprocessing/invest_total_sets.py:25-32
102
+ _INVEST_TOTAL_METHODS: frozenset[str] = frozenset((
103
+ "invest_total", "invest_period_total",
104
+ "invest_retire_total", "invest_retire_period_total",
105
+ ))
106
+ _DIVEST_TOTAL_METHODS: frozenset[str] = frozenset((
107
+ "retire_total", "retire_period_total",
108
+ "invest_retire_total", "invest_retire_period_total",
109
+ ))
110
+ # preprocessing/invest_divest_sets.py:33-40
111
+ _INVEST_PERIOD_METHODS: frozenset[str] = frozenset((
112
+ "invest_period", "invest_period_total",
113
+ "invest_retire_period", "invest_retire_period_total",
114
+ ))
115
+ _DIVEST_PERIOD_METHODS: frozenset[str] = frozenset((
116
+ "retire_period", "retire_period_total",
117
+ "invest_retire_period", "invest_retire_period_total",
118
+ ))
119
+
120
+ # preprocessing/method_with_fallback_sets.py::_LIFETIME_METHOD_DEFAULT
121
+ _LIFETIME_METHOD_DEFAULT = "reinvest_automatic"
122
+ _LIFETIME_BOUNDED_METHODS: frozenset[str] = frozenset((
123
+ "reinvest_choice", "no_investment",
124
+ ))
125
+ _LIFETIME_UNBOUNDED_METHODS: frozenset[str] = frozenset((
126
+ "reinvest_automatic",
127
+ ))
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Building blocks (lazy frames)
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def _entity_class_lf(source: "InputSource") -> pl.LazyFrame:
136
+ """Lazy ``[e, ec]``: every (entity, entity_class) pairing.
137
+
138
+ Mirror of :func:`._derived_npv._entity_class_lf` (kept local to
139
+ avoid cross-module coupling).
140
+ """
141
+ parts: list[pl.LazyFrame] = []
142
+ for ec in ("unit", "node", "connection"):
143
+ try:
144
+ df = source.entities(ec)
145
+ except KeyError:
146
+ continue
147
+ if df.height == 0:
148
+ continue
149
+ parts.append(df.lazy().select(
150
+ alias_to_axis("name", "e"),
151
+ pl.lit(ec).alias("ec"),
152
+ ))
153
+ if not parts:
154
+ return pl.LazyFrame(schema={
155
+ "e": schema_dtype(_enums, "e"),
156
+ "ec": pl.Utf8,
157
+ })
158
+ return pl.concat(parts, how="vertical")
159
+
160
+
161
+ def _all_entities_lf(source: "InputSource") -> pl.LazyFrame:
162
+ """Lazy ``[e]`` — union of unit + node + connection."""
163
+ return _entity_class_lf(source).select("e").unique()
164
+
165
+
166
+ def _entity_method_lf(source: "InputSource",
167
+ parameter_name: str,
168
+ ) -> pl.LazyFrame:
169
+ """Lazy ``[e, method]`` from ``unit/node/connection.<param>``.
170
+
171
+ ``parameter_name`` ∈ {``invest_method``, ``lifetime_method``}.
172
+ Mirror of :func:`._derived_npv._entity_method_lf`.
173
+ """
174
+ parts: list[pl.LazyFrame] = []
175
+ for ec in ("unit", "node", "connection"):
176
+ try:
177
+ df = source.parameter(ec, parameter_name)
178
+ except KeyError:
179
+ continue
180
+ if df.height == 0:
181
+ continue
182
+ parts.append(df.lazy().select(
183
+ alias_to_axis("name", "e"),
184
+ pl.col("value").cast(pl.Utf8, strict=False).alias("method"),
185
+ ))
186
+ if not parts:
187
+ return pl.LazyFrame(schema={
188
+ "e": schema_dtype(_enums, "e"),
189
+ "method": pl.Utf8,
190
+ })
191
+ return pl.concat(parts, how="vertical")
192
+
193
+
194
+ def _lifetime_method_with_default_lf(
195
+ source: "InputSource",
196
+ all_entities_lf: pl.LazyFrame,
197
+ ) -> pl.LazyFrame:
198
+ """Lazy ``[e, method]`` for ``lifetime_method`` with default fill.
199
+
200
+ Entities without an explicit ``lifetime_method`` row get
201
+ ``reinvest_automatic``. Mirror of
202
+ :func:`._derived_npv._lifetime_method_with_default_lf`.
203
+ """
204
+ explicit = _entity_method_lf(source, "lifetime_method")
205
+ explicit_entities = explicit.select("e").unique()
206
+ default_e = (all_entities_lf
207
+ .join(explicit_entities, on="e", how="anti")
208
+ .with_columns(method=pl.lit(_LIFETIME_METHOD_DEFAULT)))
209
+ return pl.concat([explicit, default_e], how="vertical")
210
+
211
+
212
+ def _per_entity_param_lf(source: "InputSource",
213
+ parameter_name: str,
214
+ ) -> pl.LazyFrame:
215
+ """Lazy ``[e, d, value, is_scalar]``.
216
+
217
+ Mirror of :func:`._derived_npv._per_entity_param_lf`.
218
+
219
+ Period-dim detection: a per-period parameter exposes exactly one
220
+ index column alongside ``name`` / ``value``. The canonical name is
221
+ ``period``, but the source may surface the column under the user's
222
+ ``Map.index_name`` (e.g. ``x``) when the spinedb default is
223
+ overridden. Treat any single non-``name``/``value`` column as the
224
+ period dim — checking only ``"period" in cols`` mis-classifies
225
+ user-renamed indices as scalars, which then explode through
226
+ :func:`_resolve_per_period_lf`'s ``scalar.join(on="e")``.
227
+ """
228
+ parts: list[pl.LazyFrame] = []
229
+ for ec in ("unit", "node", "connection"):
230
+ try:
231
+ df = source.parameter(ec, parameter_name)
232
+ except KeyError:
233
+ continue
234
+ if df.height == 0:
235
+ continue
236
+ cols = df.columns
237
+ # Any extra column beyond name / value is the per-period index.
238
+ # ``existing`` / ``lifetime`` / ``virtual_unitsize`` have at
239
+ # most one such extra column; fall back to ``period`` when
240
+ # present (typical), otherwise pick the first non-name/value
241
+ # column (covers ``x`` / other user-renamed Map indices).
242
+ extra = [c for c in cols if c not in ("name", "value")]
243
+ period_col = "period" if "period" in extra else (extra[0] if extra else None)
244
+ if period_col is not None:
245
+ # Per-ROW scalar detection — see the matching helper in
246
+ # ``_derived_npv._per_entity_param_lf`` for the full rationale.
247
+ # A constant ``existing`` (etc.) stored in an entity class
248
+ # that ALSO carries period-Map values for sibling entities
249
+ # surfaces with a NULL index (``x = null``). That row must
250
+ # broadcast across the period universe (``is_scalar=True``),
251
+ # not be emitted as an explicit ``(e, d=null)`` row that
252
+ # never joins the period grid and silently zeroes out.
253
+ parts.append(df.lazy().select(
254
+ alias_to_axis("name", "e"),
255
+ alias_to_axis(period_col, "d"),
256
+ pl.col("value").cast(pl.Float64, strict=False),
257
+ pl.col(period_col).is_null().alias("is_scalar"),
258
+ ))
259
+ else:
260
+ parts.append(df.lazy().select(
261
+ alias_to_axis("name", "e"),
262
+ pl.lit(None, dtype=schema_dtype(_enums, "d")).alias("d"),
263
+ pl.col("value").cast(pl.Float64, strict=False),
264
+ pl.lit(True).alias("is_scalar"),
265
+ ))
266
+ if not parts:
267
+ return pl.LazyFrame(schema={
268
+ "e": schema_dtype(_enums, "e"),
269
+ "d": schema_dtype(_enums, "d"),
270
+ "value": pl.Float64, "is_scalar": pl.Boolean,
271
+ })
272
+ return pl.concat(parts, how="vertical")
273
+
274
+
275
+ def _resolve_per_period_lf(per_param: pl.LazyFrame,
276
+ ed_lf: pl.LazyFrame,
277
+ fill: float = 0.0,
278
+ ) -> pl.LazyFrame:
279
+ """Resolve ``pdX[e, param, d]`` cascade in lazy form.
280
+
281
+ Mirror of :func:`._derived_npv._resolve_per_period_lf`.
282
+ """
283
+ # Align ``per_param``'s ``e`` / ``d`` dtypes to ``ed_lf``'s before
284
+ # joining — ``per_param`` was built from a String-typed CSV read
285
+ # while ``ed_lf`` may now carry Enum-cast columns.
286
+ ed_schema = ed_lf.collect_schema()
287
+ e_dt = ed_schema.get("e", pl.Utf8)
288
+ d_dt = ed_schema.get("d", pl.Utf8)
289
+ if e_dt != pl.Utf8 or d_dt != pl.Utf8:
290
+ per_param = per_param.with_columns(
291
+ pl.col("e").cast(e_dt, strict=False),
292
+ pl.col("d").cast(d_dt, strict=False),
293
+ )
294
+ explicit = (per_param
295
+ .filter(~pl.col("is_scalar"))
296
+ .select("e", "d", pl.col("value").alias("v_explicit")))
297
+ scalar = (per_param
298
+ .filter(pl.col("is_scalar"))
299
+ .select("e", pl.col("value").alias("v_scalar")))
300
+ return (ed_lf
301
+ .join(explicit, on=["e", "d"], how="left")
302
+ .join(scalar, on="e", how="left")
303
+ .with_columns(
304
+ value=pl.coalesce(
305
+ pl.col("v_explicit"),
306
+ pl.col("v_scalar"),
307
+ pl.lit(fill, dtype=pl.Float64),
308
+ ),
309
+ )
310
+ .drop("v_explicit", "v_scalar"))
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # §3.7.0 — entityInvest / entityDivest / e_invest_total / e_divest_total
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ def entity_invest_set_lf(source: "InputSource") -> pl.LazyFrame:
319
+ """Lazy ``[e]`` for ``entityInvest``.
320
+
321
+ Mirrors flextool's
322
+ ``preprocessing/invest_method_sets.py::write_invest_method_sets``::
323
+
324
+ entityInvest = {e : ∃m s.t. (e, m) ∈ entity__invest_method
325
+ AND m ∉ invest_method_not_allowed}
326
+
327
+ The Δ.5 ``ed_*``-derivation trap: an entity may have an
328
+ ``invest_method`` row but no ``ed_invest_set`` row (e.g. eea == 0
329
+ AND no capacity constraint). Such an entity is in ``entityInvest``
330
+ but not in any ``ed_invest`` (e, d) pair. Cluster A's
331
+ ``ed_lifetime_fixed_cost_divest`` produces rows for these entities;
332
+ deriving from ``ed_*`` would miss them.
333
+ """
334
+ methods_lf = _entity_method_lf(source, "invest_method")
335
+ return (methods_lf
336
+ .filter(~pl.col("method").is_in(list(_INVEST_NOT_ALLOWED)))
337
+ .select("e")
338
+ .unique())
339
+
340
+
341
+ def entity_divest_set_lf(source: "InputSource") -> pl.LazyFrame:
342
+ """Lazy ``[e]`` for ``entityDivest``. Symmetric of
343
+ :func:`entity_invest_set_lf` with the divest-not-allowed filter."""
344
+ methods_lf = _entity_method_lf(source, "invest_method")
345
+ return (methods_lf
346
+ .filter(~pl.col("method").is_in(list(_DIVEST_NOT_ALLOWED)))
347
+ .select("e")
348
+ .unique())
349
+
350
+
351
+ def e_invest_total_lf(source: "InputSource") -> pl.LazyFrame:
352
+ """Lazy ``[e]`` for ``e_invest_total``.
353
+
354
+ Mirrors ``preprocessing/invest_total_sets.py:74-90``:
355
+
356
+ e_invest_total = entityInvest filtered by invest_method ∈
357
+ {invest_total, invest_period_total,
358
+ invest_retire_total,
359
+ invest_retire_period_total}
360
+ """
361
+ methods_lf = _entity_method_lf(source, "invest_method")
362
+ invest_universe = entity_invest_set_lf(source)
363
+ has_total = (methods_lf
364
+ .filter(pl.col("method").is_in(list(_INVEST_TOTAL_METHODS)))
365
+ .select("e").unique())
366
+ return invest_universe.join(has_total, on="e", how="inner")
367
+
368
+
369
+ def e_divest_total_lf(source: "InputSource") -> pl.LazyFrame:
370
+ """Lazy ``[e]`` for ``e_divest_total``. Symmetric of
371
+ :func:`e_invest_total_lf` with the retire-total method filter."""
372
+ methods_lf = _entity_method_lf(source, "invest_method")
373
+ divest_universe = entity_divest_set_lf(source)
374
+ has_total = (methods_lf
375
+ .filter(pl.col("method").is_in(list(_DIVEST_TOTAL_METHODS)))
376
+ .select("e").unique())
377
+ return divest_universe.join(has_total, on="e", how="inner")
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # §3.7.2 — edd_history triple-set walk + variants
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ def edd_history_choice_lf(source: "InputSource",
386
+ active_solve: str | None,
387
+ period_with_history: list[str],
388
+ period_in_use: list[str],
389
+ workdir: Path | None = None,
390
+ ) -> pl.LazyFrame:
391
+ """Lazy ``[e, d_history, d]`` for ``edd_history_choice``.
392
+
393
+ Mirror of ``invest_divest_sets.py:228-244`` for entities whose
394
+ ``lifetime_method = reinvest_choice``::
395
+
396
+ keep iff pdy[d] >= pdy[d_h] AND pdy[d] < pdy[d_h] + life[e, d_h]
397
+ """
398
+ return _edd_history_lf_for(
399
+ source, active_solve, period_with_history, period_in_use,
400
+ method="reinvest_choice", bounded=True, workdir=workdir)
401
+
402
+
403
+ def edd_history_automatic_lf(source: "InputSource",
404
+ active_solve: str | None,
405
+ period_with_history: list[str],
406
+ period_in_use: list[str],
407
+ workdir: Path | None = None,
408
+ ) -> pl.LazyFrame:
409
+ """Lazy ``[e, d_history, d]`` for ``edd_history_automatic`` —
410
+ entities with ``lifetime_method = reinvest_automatic``.
411
+
412
+ Mirror of ``invest_divest_sets.py:245-246``::
413
+
414
+ keep iff pdy[d] >= pdy[d_h]
415
+ """
416
+ return _edd_history_lf_for(
417
+ source, active_solve, period_with_history, period_in_use,
418
+ method="reinvest_automatic", bounded=False, workdir=workdir)
419
+
420
+
421
+ def edd_history_no_investment_lf(source: "InputSource",
422
+ active_solve: str | None,
423
+ period_with_history: list[str],
424
+ period_in_use: list[str],
425
+ workdir: Path | None = None,
426
+ ) -> pl.LazyFrame:
427
+ """Lazy ``[e, d_history, d]`` for ``edd_history_no_investment``.
428
+
429
+ Mirror of ``invest_divest_sets.py:247-248``: same predicate as
430
+ ``edd_history_choice`` but for the ``no_investment`` cohort.
431
+ """
432
+ return _edd_history_lf_for(
433
+ source, active_solve, period_with_history, period_in_use,
434
+ method="no_investment", bounded=True, workdir=workdir)
435
+
436
+
437
+ def _edd_history_lf_for(source: "InputSource",
438
+ active_solve: str | None,
439
+ period_with_history: list[str],
440
+ period_in_use: list[str],
441
+ *,
442
+ method: str,
443
+ bounded: bool,
444
+ workdir: Path | None = None,
445
+ ) -> pl.LazyFrame:
446
+ """Internal helper: build edd_history sub-set for a single
447
+ lifetime_method cohort.
448
+ """
449
+ if not period_with_history or not period_in_use:
450
+ return pl.LazyFrame(schema={
451
+ "e": schema_dtype(_enums, "e"),
452
+ "d_history": schema_dtype(_enums, "d"),
453
+ "d": schema_dtype(_enums, "d"),
454
+ })
455
+ all_e_lf = _all_entities_lf(source)
456
+ elm_lf = _lifetime_method_with_default_lf(source, all_e_lf)
457
+ cohort_e = (elm_lf
458
+ .filter(pl.col("method") == method)
459
+ .select("e").unique())
460
+ pwh_lf = (pl.LazyFrame({"d": period_with_history})
461
+ .with_columns(pl.col("d").cast(schema_dtype(_enums, "d"),
462
+ strict=False)))
463
+ anchor = cohort_e.join(pwh_lf, how="cross")
464
+ if anchor.collect().height == 0:
465
+ return pl.LazyFrame(schema={
466
+ "e": schema_dtype(_enums, "e"),
467
+ "d_history": schema_dtype(_enums, "d"),
468
+ "d": schema_dtype(_enums, "d"),
469
+ })
470
+
471
+ if bounded:
472
+ life_per = _per_entity_param_lf(source, "lifetime")
473
+ life_lf = (_resolve_per_period_lf(life_per, anchor, fill=0.0)
474
+ .rename({"value": "life"})
475
+ .select("e", "d", "life"))
476
+ walk = period_walk_iterator(
477
+ source, active_solve, anchor,
478
+ period_in_use, period_in_use,
479
+ window_method=WindowMethod.BOUNDED_INCLUSIVE_LOOKBACK,
480
+ life_lf=life_lf, factor_side=None,
481
+ workdir=workdir)
482
+ else:
483
+ walk = period_walk_iterator(
484
+ source, active_solve, anchor,
485
+ period_in_use, period_in_use,
486
+ window_method=WindowMethod.UNBOUNDED_FORWARD,
487
+ life_lf=None, factor_side=None,
488
+ workdir=workdir)
489
+ return walk.pipe(rename_to_axis, {"d": "d_history", "d_all": "d"})
490
+
491
+
492
+ def edd_history_lf(source: "InputSource",
493
+ active_solve: str | None,
494
+ period_with_history: list[str],
495
+ period_in_use: list[str],
496
+ workdir: Path | None = None,
497
+ ) -> pl.LazyFrame:
498
+ """Lazy ``[e, d_history, d]`` for the union ``edd_history`` set.
499
+
500
+ Algorithm (mirror of
501
+ ``invest_divest_sets.py:227-262``): union of the three cohort
502
+ walks (:func:`edd_history_choice_lf`,
503
+ :func:`edd_history_automatic_lf`,
504
+ :func:`edd_history_no_investment_lf`).
505
+ """
506
+ if not period_with_history or not period_in_use:
507
+ return pl.LazyFrame(schema={
508
+ "e": schema_dtype(_enums, "e"),
509
+ "d_history": schema_dtype(_enums, "d"),
510
+ "d": schema_dtype(_enums, "d"),
511
+ })
512
+ parts = [
513
+ edd_history_choice_lf(source, active_solve,
514
+ period_with_history, period_in_use,
515
+ workdir),
516
+ edd_history_automatic_lf(source, active_solve,
517
+ period_with_history, period_in_use,
518
+ workdir),
519
+ edd_history_no_investment_lf(source, active_solve,
520
+ period_with_history, period_in_use,
521
+ workdir),
522
+ ]
523
+ return pl.concat(parts, how="vertical").unique()
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # §3.7.1 — pd_invest_set / nd_invest_set partitions
528
+ # ---------------------------------------------------------------------------
529
+
530
+
531
+ def _entity_class_partition_lf(source: "InputSource") -> pl.LazyFrame:
532
+ """Lazy ``[e, kind]`` where ``kind ∈ {process, node}``.
533
+
534
+ process = unit ∪ connection (processes); node = node. Disjoint
535
+ classes — every entity falls in exactly one.
536
+ """
537
+ parts: list[pl.LazyFrame] = []
538
+ for ec, kind in (("unit", "process"),
539
+ ("connection", "process"),
540
+ ("node", "node")):
541
+ try:
542
+ df = source.entities(ec)
543
+ except KeyError:
544
+ continue
545
+ if df.height == 0:
546
+ continue
547
+ parts.append(df.lazy().select(
548
+ alias_to_axis("name", "e"),
549
+ pl.lit(kind).alias("kind"),
550
+ ))
551
+ if not parts:
552
+ return pl.LazyFrame(schema={
553
+ "e": schema_dtype(_enums, "e"),
554
+ "kind": pl.Utf8,
555
+ })
556
+ return pl.concat(parts, how="vertical").unique()
557
+
558
+
559
+ def pd_invest_set_lf(source: "InputSource",
560
+ ed_invest_lf: pl.LazyFrame,
561
+ ) -> pl.LazyFrame:
562
+ """Lazy ``[p, d]`` — ``ed_invest_set`` partitioned to processes
563
+ (``unit ∪ connection``). Mirror of
564
+ ``invest_divest_sets.py:215-217``.
565
+ """
566
+ cls_lf = _entity_class_partition_lf(source)
567
+ return (ed_invest_lf
568
+ .join(cls_lf, on="e", how="inner")
569
+ .filter(pl.col("kind") == "process")
570
+ .pipe(rename_to_axis, {"e": "p"})
571
+ .select("p", "d")
572
+ .unique()
573
+ .sort("p", "d"))
574
+
575
+
576
+ def nd_invest_set_lf(source: "InputSource",
577
+ ed_invest_lf: pl.LazyFrame,
578
+ ) -> pl.LazyFrame:
579
+ """Lazy ``[n, d]`` — ``ed_invest_set`` partitioned to nodes."""
580
+ cls_lf = _entity_class_partition_lf(source)
581
+ return (ed_invest_lf
582
+ .join(cls_lf, on="e", how="inner")
583
+ .filter(pl.col("kind") == "node")
584
+ .pipe(rename_to_axis, {"e": "n"})
585
+ .select("n", "d")
586
+ .unique()
587
+ .sort("n", "d"))
588
+
589
+
590
+ def pd_divest_set_lf(source: "InputSource",
591
+ ed_divest_lf: pl.LazyFrame,
592
+ ) -> pl.LazyFrame:
593
+ """Lazy ``[p, d]`` — ``ed_divest_set`` partitioned to processes."""
594
+ cls_lf = _entity_class_partition_lf(source)
595
+ return (ed_divest_lf
596
+ .join(cls_lf, on="e", how="inner")
597
+ .filter(pl.col("kind") == "process")
598
+ .pipe(rename_to_axis, {"e": "p"})
599
+ .select("p", "d")
600
+ .unique()
601
+ .sort("p", "d"))
602
+
603
+
604
+ def nd_divest_set_lf(source: "InputSource",
605
+ ed_divest_lf: pl.LazyFrame,
606
+ ) -> pl.LazyFrame:
607
+ """Lazy ``[n, d]`` — ``ed_divest_set`` partitioned to nodes."""
608
+ cls_lf = _entity_class_partition_lf(source)
609
+ return (ed_divest_lf
610
+ .join(cls_lf, on="e", how="inner")
611
+ .filter(pl.col("kind") == "node")
612
+ .pipe(rename_to_axis, {"e": "n"})
613
+ .select("n", "d")
614
+ .unique()
615
+ .sort("n", "d"))
616
+
617
+
618
+ def ed_invest_period_set_lf(source: "InputSource",
619
+ ed_invest_lf: pl.LazyFrame,
620
+ ) -> pl.LazyFrame:
621
+ """Lazy ``[e, d]`` — ``ed_invest`` filtered to entities whose
622
+ ``invest_method`` is in :data:`_INVEST_PERIOD_METHODS`.
623
+
624
+ Mirror of ``preprocessing/invest_divest_sets.py`` 's
625
+ ``ed_invest_period`` set: keeps only the (e, d) pairs that carry
626
+ a per-period invest cap (``maxInvest_entity_period`` /
627
+ ``minInvest_entity_period`` constraint indices). Used in
628
+ ``model.py:1517-1530`` to index the per-period invest cap rows.
629
+ """
630
+ methods_lf = _entity_method_lf(source, "invest_method")
631
+ period_e = (methods_lf
632
+ .filter(pl.col("method").is_in(list(_INVEST_PERIOD_METHODS)))
633
+ .select("e")
634
+ .unique())
635
+ return (ed_invest_lf
636
+ .join(period_e, on="e", how="inner")
637
+ .select("e", "d")
638
+ .unique()
639
+ .sort("e", "d"))
640
+
641
+
642
+ def ed_divest_period_set_lf(source: "InputSource",
643
+ ed_divest_lf: pl.LazyFrame,
644
+ ) -> pl.LazyFrame:
645
+ """Lazy ``[e, d]`` — ``ed_divest`` filtered to entities whose
646
+ ``invest_method`` is in :data:`_DIVEST_PERIOD_METHODS`. Mirror of
647
+ :func:`ed_invest_period_set_lf` for divest.
648
+ """
649
+ methods_lf = _entity_method_lf(source, "invest_method")
650
+ period_e = (methods_lf
651
+ .filter(pl.col("method").is_in(list(_DIVEST_PERIOD_METHODS)))
652
+ .select("e")
653
+ .unique())
654
+ return (ed_divest_lf
655
+ .join(period_e, on="e", how="inner")
656
+ .select("e", "d")
657
+ .unique()
658
+ .sort("e", "d"))
659
+
660
+
661
+ # ---------------------------------------------------------------------------
662
+ # §3.7.3 — edd_invest_set + edd_invest_lookback_set
663
+ # ---------------------------------------------------------------------------
664
+
665
+
666
+ def edd_invest_set_lf(source: "InputSource",
667
+ active_solve: str | None,
668
+ ed_invest_lf: pl.LazyFrame,
669
+ period_with_history: list[str],
670
+ period_in_use: list[str],
671
+ workdir: Path | None = None,
672
+ ) -> pl.LazyFrame:
673
+ """Lazy ``[e, d_invest, d]`` — ``edd_history_invest`` filtered to
674
+ ``(e, d_invest) ∈ ed_invest``.
675
+
676
+ Mirror of ``invest_divest_sets.py:267-270``::
677
+
678
+ edd_invest = { (e, d_inv, d) : (e, d_inv, d) ∈ edd_history,
679
+ (e, d_inv) ∈ ed_invest }
680
+
681
+ The ``edd_history`` triple-set carries history-period entries for
682
+ every entity with a recognised lifetime_method; this helper filters
683
+ to those whose ``d_history`` (renamed ``d_invest``) is also a
684
+ valid invest decision in the current solve.
685
+ """
686
+ edd = edd_history_lf(source, active_solve,
687
+ period_with_history, period_in_use,
688
+ workdir)
689
+ inv_pairs = ed_invest_lf.pipe(rename_to_axis, {"d": "d_history"})
690
+ return (edd
691
+ .join(inv_pairs, on=["e", "d_history"], how="inner")
692
+ .pipe(rename_to_axis, {"d_history": "d_invest"})
693
+ .select("e", "d_invest", "d"))
694
+
695
+
696
+ def edd_invest_lookback_set_lf(source: "InputSource",
697
+ active_solve: str | None,
698
+ ed_invest_lf: pl.LazyFrame,
699
+ period_in_use: list[str],
700
+ workdir: Path | None = None,
701
+ ) -> pl.LazyFrame:
702
+ """Lazy ``[e, d_invest, d]`` — ``edd_invest_lookback_set``.
703
+
704
+ The strict-lookback variant of :func:`edd_invest_set_lf`: tuples
705
+ where ``yr[d_invest] < yr[d]`` (strict) and, for entities in the
706
+ bounded lifetime cohort (``reinvest_choice`` / ``no_investment``),
707
+ ``yr[d] < yr[d_invest] + lifetime[e, d_invest]``.
708
+
709
+ Mirror of ``invest_divest_sets.py:227-262`` filtered with the
710
+ strict-lookback predicate. Used by the prebuilt-capacity LHS in
711
+ user-constraint expressions (mod L2885-2898).
712
+
713
+ Δ.7 consolidation: the eager
714
+ :func:`._derived_params.edd_invest_lookback_set_from_source`
715
+ pre-dates the shared :func:`._derived_walks.period_walk_iterator`;
716
+ Δ.7 wraps the shared walker with the
717
+ :data:`._derived_walks.WindowMethod.STRICT_LOOKBACK_BOUNDED` /
718
+ :data:`._derived_walks.WindowMethod.STRICT_LOOKBACK_UNBOUNDED`
719
+ modes for bounded / unbounded entity cohorts respectively, then
720
+ unions the results.
721
+
722
+ The eager helper now delegates to this lazy port — the previous
723
+ Python ``for r in out.iter_rows`` lifetime gate is replaced with
724
+ a fully lazy join + filter on the shared walker.
725
+ """
726
+ if not period_in_use:
727
+ return pl.LazyFrame(schema={
728
+ "e": pl.Utf8, "d_invest": pl.Utf8, "d": pl.Utf8,
729
+ })
730
+ inv_anchor = ed_invest_lf.select("e", alias_to_axis("d", "d_invest"))
731
+ if inv_anchor.collect().height == 0:
732
+ return pl.LazyFrame(schema={
733
+ "e": pl.Utf8, "d_invest": pl.Utf8, "d": pl.Utf8,
734
+ })
735
+
736
+ all_e_lf = _all_entities_lf(source)
737
+ elm_lf = _lifetime_method_with_default_lf(source, all_e_lf)
738
+ bounded_e = (elm_lf
739
+ .filter(pl.col("method").is_in(
740
+ list(_LIFETIME_BOUNDED_METHODS)))
741
+ .select("e").unique())
742
+ unbounded_e = (elm_lf
743
+ .filter(~pl.col("method").is_in(
744
+ list(_LIFETIME_BOUNDED_METHODS)))
745
+ .select("e").unique())
746
+
747
+ # Shape the anchor as ``[e, d]`` for the shared walker (it expects
748
+ # the anchor period column to be named ``d``).
749
+ anchor_lf = inv_anchor.pipe(rename_to_axis, {"d_invest": "d"})
750
+
751
+ # The source-driven ``_all_entities_lf`` reads raw CSV — String
752
+ # ``e``. ``anchor_lf.e`` carries whatever dtype the caller passed
753
+ # (Enum-e when called from the post-load test path). Align so the
754
+ # join below works.
755
+ anchor_e_dtype = anchor_lf.collect_schema().get("e", pl.Utf8)
756
+ if anchor_e_dtype != pl.Utf8:
757
+ bounded_e = bounded_e.with_columns(
758
+ pl.col("e").cast(anchor_e_dtype, strict=False))
759
+ unbounded_e = unbounded_e.with_columns(
760
+ pl.col("e").cast(anchor_e_dtype, strict=False))
761
+
762
+ # Bounded cohort.
763
+ bounded_anchor = anchor_lf.join(bounded_e, on="e", how="inner")
764
+ # Scratch frame for the cohort with no rows: must match the dtypes
765
+ # of the upstream Enum-cast inputs to be ``vstack``-compatible at
766
+ # the bottom-of-function ``pl.concat``. ``empty_like`` reads the
767
+ # input frame's schema for the requested columns and uses
768
+ # ``pl.Utf8`` as the fall-back when the column is absent.
769
+ from flextool.engine_polars._axis_enums import empty_like
770
+ bounded_walk = empty_like(anchor_lf,
771
+ ["e", "d"],
772
+ extra={"d_all": anchor_lf.collect_schema().get("d", pl.Utf8)},
773
+ lazy=True)
774
+ if bounded_anchor.collect().height > 0:
775
+ life_per = _per_entity_param_lf(source, "lifetime")
776
+ life_lf = (_resolve_per_period_lf(life_per, bounded_anchor, fill=0.0)
777
+ .rename({"value": "life"})
778
+ .select("e", "d", "life"))
779
+ bounded_walk = period_walk_iterator(
780
+ source, active_solve, bounded_anchor,
781
+ period_in_use, period_in_use,
782
+ window_method=WindowMethod.STRICT_LOOKBACK_BOUNDED,
783
+ life_lf=life_lf, factor_side=None,
784
+ workdir=workdir)
785
+
786
+ # Unbounded cohort — strict-lookback only, no lifetime cap.
787
+ unbounded_anchor = anchor_lf.join(unbounded_e, on="e", how="inner")
788
+ unbounded_walk = empty_like(anchor_lf,
789
+ ["e", "d"],
790
+ extra={"d_all": anchor_lf.collect_schema().get("d", pl.Utf8)},
791
+ lazy=True)
792
+ if unbounded_anchor.collect().height > 0:
793
+ unbounded_walk = period_walk_iterator(
794
+ source, active_solve, unbounded_anchor,
795
+ period_in_use, period_in_use,
796
+ window_method=WindowMethod.STRICT_LOOKBACK_UNBOUNDED,
797
+ life_lf=None, factor_side=None,
798
+ workdir=workdir)
799
+
800
+ return (pl.concat([bounded_walk, unbounded_walk], how="vertical")
801
+ .pipe(rename_to_axis, {"d": "d_invest", "d_all": "d"})
802
+ .select("e", "d_invest", "d")
803
+ .unique()
804
+ .sort("e", "d_invest", "d"))
805
+
806
+
807
+ # ---------------------------------------------------------------------------
808
+ # §3.11 — p_entity_all_existing (existing-chain walk)
809
+ # ---------------------------------------------------------------------------
810
+
811
+
812
+ def p_entity_all_existing_from_handoff(
813
+ source: "InputSource",
814
+ active_solve: str | None,
815
+ period_with_history: list[str], # noqa: ARG001 — kept for symmetry
816
+ period_in_use: list[str],
817
+ *,
818
+ p_entity_period_existing_capacity: "Param | None" = None,
819
+ p_entity_previously_invested_capacity: "Param | None" = None,
820
+ p_entity_divested: "Param | None" = None,
821
+ solve_first: bool = True,
822
+ edd_history: "pl.DataFrame | None" = None,
823
+ ) -> "Param | None":
824
+ """Lazy port of flextool's
825
+ ``write_p_entity_existing_chain`` algorithm
826
+ (``entity_period_calc_params.py:1381-1591``).
827
+
828
+ Per flextool's writer (lines 1554-1568):
829
+
830
+ * ``solve_first`` →
831
+ ``all_existing[e, d] = pre_existing[e, d]`` (no divest applied)
832
+ * later solves →
833
+ ``all_existing[e, d] = later_existing[e, d]``
834
+ `` − p_entity_divested[e] (if e ∈ entityDivest)``
835
+
836
+ where ``later_existing[e, d]`` for the chain-runner case is the
837
+ chain-summed prior-solve existing per current period — already
838
+ integrated by flextool's preprocessing into
839
+ ``p_entity_previously_invested_capacity[e, d]`` (the
840
+ ``later_invested`` shape) ⊕ ``pre_existing[e, d]``. Δ.11 — we rebuild
841
+ the result from the in-memory handoff carriers via
842
+ ``pre + ppic − divest`` which matches the canonical flextool CSV
843
+ (verified per-fixture in ``test_existing_chain_cluster_parity.py``).
844
+
845
+ Parameters
846
+ ----------
847
+ p_entity_period_existing_capacity
848
+ Δ.11 — in-memory handoff carrier ``[entity, period, value]``
849
+ (the SolveHandoff ``realized_existing`` shape). When supplied,
850
+ the helper switches into the ``later_existing`` chain-summation
851
+ branch: ``later_existing[e, d] = Σ_{(e, d_h, d) ∈ edd_history ∧
852
+ (e, d_h) realized} ppec[(e, d_h)]`` (mirrors flextool's
853
+ ``write_p_entity_existing_chain`` lines 1530-1543). Required for
854
+ lifetime-renew chains where the chain-summed existing isn't
855
+ recoverable from ``ppic + pre_existing`` alone.
856
+ p_entity_previously_invested_capacity
857
+ In-memory handoff Param ``[e, d, value]`` — chain-summed prior
858
+ invest at d. Used only when ``p_entity_period_existing_capacity``
859
+ is None (the simple-baseline branch); the chain-summation
860
+ branch derives both terms from ``ppec``.
861
+ p_entity_divested
862
+ Handoff Param ``[e, value]`` cumulative prior divest.
863
+ solve_first
864
+ Δ.11 — flextool's ``solveFirst`` flag. When True, divest is
865
+ NOT subtracted (mirrors the writer's solve-first branch); when
866
+ False, ``e ∈ entityDivest`` triggers a per-entity divest
867
+ subtraction. Default True (single-solve / cold-start).
868
+ edd_history
869
+ Δ.11 — ``[entity, period_history, period]`` triple-set used by
870
+ the chain-summation branch. When None, the helper falls back
871
+ to the simple ``pre + ppic − divest`` formula (which is exact
872
+ for non-renew chains).
873
+
874
+ Returns Param ``[e, d, value]`` or None when no entities exist.
875
+ """
876
+ if not period_in_use:
877
+ return None
878
+ all_e_collected = _all_entities_lf(source).collect()
879
+ if all_e_collected.height == 0:
880
+ return None
881
+ all_e_lf = _all_entities_lf(source)
882
+ piu_lf = (pl.LazyFrame({"d": period_in_use})
883
+ .with_columns(pl.col("d").cast(schema_dtype(_enums, "d"),
884
+ strict=False)))
885
+ grid_lf = all_e_lf.join(piu_lf, how="cross")
886
+
887
+ pre_existing_lf = p_entity_pre_existing_lf(
888
+ source, active_solve, period_in_use)
889
+
890
+ # entityDivest set + p_entity_divested scalar. Shared by both
891
+ # branches; the divest subtraction is gated on solve_first below.
892
+ div_set_lf = entity_divest_set_lf(source).with_columns(
893
+ is_divest=pl.lit(True))
894
+ if (p_entity_divested is not None
895
+ and p_entity_divested.frame.height > 0):
896
+ ped_lf = (p_entity_divested.frame.lazy()
897
+ .select("e", pl.col("value").alias("divested")))
898
+ else:
899
+ ped_lf = pl.LazyFrame(schema={
900
+ "e": schema_dtype(_enums, "e"),
901
+ "divested": pl.Float64,
902
+ })
903
+
904
+ # ---------------------------------------------------------------
905
+ # Δ.11 — chain-summation branch: later_existing[e, d] from
906
+ # edd_history × ppec. Activates when the caller supplies the
907
+ # ``p_entity_period_existing_capacity`` carrier (the SolveHandoff
908
+ # ``realized_existing`` shape) AND the ``edd_history`` triple-set
909
+ # AND solve_first is False. Mirrors flextool's writer at
910
+ # ``entity_period_calc_params.py:1530-1568``.
911
+ # ---------------------------------------------------------------
912
+ if (not solve_first
913
+ and p_entity_period_existing_capacity is not None
914
+ and p_entity_period_existing_capacity.frame.height > 0
915
+ and edd_history is not None and edd_history.height > 0):
916
+ ppec_lf = (p_entity_period_existing_capacity.frame.lazy()
917
+ .select(alias_to_axis("e", "e"),
918
+ alias_to_axis("d", "d_h"),
919
+ pl.col("value").alias("ppec")))
920
+ edd_lf = edd_history.lazy()
921
+ if {"entity", "period_history", "period"}.issubset(set(edd_history.columns)):
922
+ edd_lf = edd_lf.pipe(rename_to_axis, {"entity": "e",
923
+ "period_history": "d_h",
924
+ "period": "d"})
925
+ # later_existing[e, d] = Σ_{d_h: (e, d_h, d) ∈ edd_history ∧
926
+ # (e, d_h) ∈ realized} ppec[(e, d_h)].
927
+ # (e, d_h) is "realized" iff ppec carries a row for it.
928
+ from flextool.engine_polars._axis_enums import align_join_dtypes
929
+ edd_lf, ppec_lf = align_join_dtypes(edd_lf, ppec_lf, ["e", "d_h"])
930
+ later_lf = (edd_lf
931
+ .join(ppec_lf, on=["e", "d_h"], how="inner")
932
+ .group_by(["e", "d"])
933
+ .agg(pl.col("ppec").sum().alias("later")))
934
+ grid_lf, later_lf = align_join_dtypes(grid_lf, later_lf, ["e", "d"])
935
+ grid_lf, div_set_lf = align_join_dtypes(grid_lf, div_set_lf, ["e"])
936
+ grid_lf, ped_lf = align_join_dtypes(grid_lf, ped_lf, ["e"])
937
+ out = (grid_lf
938
+ .join(later_lf, on=["e", "d"], how="left")
939
+ .join(div_set_lf, on="e", how="left")
940
+ .join(ped_lf, on="e", how="left")
941
+ .with_columns(
942
+ later=pl.col("later").fill_null(0.0),
943
+ is_divest=pl.col("is_divest").fill_null(False),
944
+ divested=pl.col("divested").fill_null(0.0),
945
+ )
946
+ .with_columns(
947
+ value=pl.col("later")
948
+ - pl.when(pl.col("is_divest"))
949
+ .then(pl.col("divested"))
950
+ .otherwise(0.0),
951
+ )
952
+ .select("e", "d", "value")
953
+ .sort("e", "d")
954
+ .collect())
955
+ if out.height == 0:
956
+ return None
957
+ return Param(("e", "d"), out)
958
+
959
+ # ---------------------------------------------------------------
960
+ # Simple-baseline branch (single-solve / non-renew chains):
961
+ # pae[e, d] = pre_existing[e, d]
962
+ # + p_entity_previously_invested_capacity[e, d]
963
+ # − p_entity_divested[e] (if e ∈ entityDivest, later solves)
964
+ # ---------------------------------------------------------------
965
+
966
+ # Previously-invested overlay (in-memory handoff carrier).
967
+ if (p_entity_previously_invested_capacity is not None
968
+ and p_entity_previously_invested_capacity.frame.height > 0):
969
+ ppic_frame = p_entity_previously_invested_capacity.frame
970
+ ppic_lf = (ppic_frame.lazy()
971
+ .select("e", "d", pl.col("value").alias("ppic")))
972
+ else:
973
+ ppic_lf = pl.LazyFrame(schema={
974
+ "e": pl.Utf8, "d": pl.Utf8, "ppic": pl.Float64,
975
+ })
976
+
977
+ # Align grid_lf's String dim columns with any Enum-typed inputs
978
+ # (ppic_lf / ped_lf come from FlexData Params that have been
979
+ # cast). Two passes — the first lifts grid_lf to the widest dtype
980
+ # available from any input, the second pulls every other input
981
+ # back up to grid_lf's promoted dtype.
982
+ from flextool.engine_polars._axis_enums import align_join_dtypes
983
+ pre_renamed = pre_existing_lf.rename({"value": "pre"})
984
+ grid_lf, ppic_lf = align_join_dtypes(grid_lf, ppic_lf, ["e", "d"])
985
+ grid_lf, ped_lf = align_join_dtypes(grid_lf, ped_lf, ["e"])
986
+ grid_lf, pre_renamed = align_join_dtypes(grid_lf, pre_renamed, ["e", "d"])
987
+ grid_lf, div_set_lf = align_join_dtypes(grid_lf, div_set_lf, ["e"])
988
+ # Second pass: now grid_lf carries the widest dtype on each axis,
989
+ # pull every input up.
990
+ grid_lf, ppic_lf = align_join_dtypes(grid_lf, ppic_lf, ["e", "d"])
991
+ grid_lf, ped_lf = align_join_dtypes(grid_lf, ped_lf, ["e"])
992
+ grid_lf, pre_renamed = align_join_dtypes(grid_lf, pre_renamed, ["e", "d"])
993
+ grid_lf, div_set_lf = align_join_dtypes(grid_lf, div_set_lf, ["e"])
994
+ out = (grid_lf
995
+ .join(pre_renamed,
996
+ on=["e", "d"], how="left")
997
+ .join(ppic_lf, on=["e", "d"], how="left")
998
+ .join(div_set_lf, on="e", how="left")
999
+ .join(ped_lf, on="e", how="left")
1000
+ .with_columns(
1001
+ pre=pl.col("pre").fill_null(0.0),
1002
+ ppic=pl.col("ppic").fill_null(0.0),
1003
+ is_divest=pl.col("is_divest").fill_null(False),
1004
+ divested=pl.col("divested").fill_null(0.0),
1005
+ )
1006
+ .with_columns(
1007
+ # Δ.11 — divest subtraction is applied only on later
1008
+ # solves (solve_first=False); flextool's writer skips it
1009
+ # on the first solve in the chain.
1010
+ value=pl.col("pre") + pl.col("ppic")
1011
+ - (pl.when(pl.col("is_divest"))
1012
+ .then(pl.col("divested"))
1013
+ .otherwise(0.0)
1014
+ if not solve_first else pl.lit(0.0)),
1015
+ )
1016
+ .select("e", "d", "value")
1017
+ .sort("e", "d")
1018
+ .collect())
1019
+ if out.height == 0:
1020
+ return None
1021
+ return Param(("e", "d"), out)
1022
+
1023
+
1024
+ def p_entity_pre_existing_lf(source: "InputSource",
1025
+ active_solve: str | None,
1026
+ period_in_use: list[str],
1027
+ ) -> pl.LazyFrame:
1028
+ """Lazy ``[e, d, value]`` for ``p_entity_pre_existing`` —
1029
+ lifetime-gated ``entity.existing × virtual_unitsize`` cascade.
1030
+
1031
+ Algorithm (mirror of
1032
+ ``preprocessing/entity_period_calc_params.py:write_p_entity_pre_existing``):
1033
+
1034
+ For each entity e:
1035
+ v_existing = pdProcess[e, 'existing', d] (if e ∈ process)
1036
+ else pdNode[e, 'existing', d] (if e ∈ node)
1037
+ else 0
1038
+ v_unit = p_process[e, 'virtual_unitsize'] (if e ∈ process)
1039
+ else p_node[e, 'virtual_unitsize'] (if e ∈ node)
1040
+ else 0
1041
+ if lifetime_method = reinvest_automatic →
1042
+ v = v_existing * v_unit if v_unit else v_existing
1043
+ if lifetime_method ∈ {reinvest_choice, no_investment} →
1044
+ v = (v_existing * v_unit if v_unit else v_existing)
1045
+ while pdy[d] < life_sum(e); 0 thereafter.
1046
+ else → 0.
1047
+
1048
+ The ``v_existing × v_unit`` term is the canonical capacity (a
1049
+ counted entity's ``existing`` is the count, not the capacity).
1050
+ Only applied when ``v_unit > 0``.
1051
+ """
1052
+ if not period_in_use:
1053
+ return pl.LazyFrame(schema={
1054
+ "e": schema_dtype(_enums, "e"),
1055
+ "d": schema_dtype(_enums, "d"),
1056
+ "value": pl.Float64,
1057
+ })
1058
+ all_e_lf = _all_entities_lf(source)
1059
+ piu_lf = (pl.LazyFrame({"d": period_in_use})
1060
+ .with_columns(pl.col("d").cast(schema_dtype(_enums, "d"),
1061
+ strict=False)))
1062
+ grid_lf = all_e_lf.join(piu_lf, how="cross")
1063
+
1064
+ # Resolve existing per (e, d) via _resolve_pdX cascade.
1065
+ ex_per = _per_entity_param_lf(source, "existing")
1066
+ grid_with_ex = (_resolve_per_period_lf(ex_per, grid_lf, fill=0.0)
1067
+ .rename({"value": "ex"}))
1068
+
1069
+ # virtual_unitsize per entity (scalar across periods).
1070
+ vu_per = _per_entity_param_lf(source, "virtual_unitsize")
1071
+ # Aggregate to (e, vu) — virtual_unitsize is non-period for
1072
+ # process / node (per write_p_entity_pre_existing's read of
1073
+ # p_process / p_node directly, NOT pd_process / pd_node).
1074
+ vu_scalar = (vu_per
1075
+ .filter(pl.col("is_scalar"))
1076
+ .select("e", pl.col("value").alias("vu"))
1077
+ .unique(subset=["e"], keep="last"))
1078
+ grid_with_vu = (grid_with_ex
1079
+ .join(vu_scalar, on="e", how="left")
1080
+ .with_columns(vu=pl.col("vu").fill_null(0.0)))
1081
+
1082
+ # Lifetime methods (with default).
1083
+ elm_lf = _lifetime_method_with_default_lf(source, all_e_lf)
1084
+ is_auto = (elm_lf
1085
+ .filter(pl.col("method") == "reinvest_automatic")
1086
+ .select("e").unique()
1087
+ .with_columns(is_auto=pl.lit(True)))
1088
+ is_bounded = (elm_lf
1089
+ .filter(pl.col("method").is_in(list(_LIFETIME_BOUNDED_METHODS)))
1090
+ .select("e").unique()
1091
+ .with_columns(is_bounded=pl.lit(True)))
1092
+
1093
+ # Lifetime gate: zero out existing past life_sum expiry for bounded.
1094
+ expired_lf = _lifetime_expired_pairs_lf(
1095
+ source, active_solve, period_in_use, methods=_LIFETIME_BOUNDED_METHODS)
1096
+ expired_marked = expired_lf.with_columns(_expired=pl.lit(True))
1097
+
1098
+ # Compose: value = ex × vu (or ex if vu=0) if alive; 0 else.
1099
+ capacity_expr = (pl.when(pl.col("vu") != 0.0)
1100
+ .then(pl.col("ex") * pl.col("vu"))
1101
+ .otherwise(pl.col("ex")))
1102
+ return (grid_with_vu
1103
+ .join(is_auto, on="e", how="left")
1104
+ .join(is_bounded, on="e", how="left")
1105
+ .join(expired_marked, on=["e", "d"], how="left")
1106
+ .with_columns(
1107
+ is_auto=pl.col("is_auto").fill_null(False),
1108
+ is_bounded=pl.col("is_bounded").fill_null(False),
1109
+ _expired=pl.col("_expired").fill_null(False),
1110
+ cap=capacity_expr,
1111
+ )
1112
+ .with_columns(
1113
+ value=pl.when(pl.col("is_auto"))
1114
+ .then(pl.col("cap"))
1115
+ .when(pl.col("is_bounded") & ~pl.col("_expired"))
1116
+ .then(pl.col("cap"))
1117
+ .otherwise(0.0),
1118
+ )
1119
+ .select("e", "d", "value"))
1120
+
1121
+
1122
+ def _lifetime_expired_pairs_lf(source: "InputSource",
1123
+ active_solve: str | None,
1124
+ period_in_use: list[str],
1125
+ *,
1126
+ methods: frozenset[str],
1127
+ ) -> pl.LazyFrame:
1128
+ """Lazy ``[e, d]`` of pairs whose pre-existing capacity has expired.
1129
+
1130
+ For each entity whose ``lifetime_method ∈ methods``, return periods
1131
+ d where ``pdy[d] >= life_sum(e)`` with::
1132
+
1133
+ life_sum(e) = Σ_{d_first ∈ period_first}
1134
+ (pdy[d_first] + lifetime[e, d_first])
1135
+
1136
+ Mirror of :func:`._derived_params._lifetime_expired_pairs` in lazy form.
1137
+ Uses :func:`._derived_params._read_period_first` /
1138
+ :func:`._derived_params._p_years_d_lf` for the workdir-aware reads.
1139
+ """
1140
+ from ._derived_params import _read_period_first, _p_years_d_lf
1141
+ period_first = _read_period_first(source, active_solve, None)
1142
+ if not period_first:
1143
+ return pl.LazyFrame(schema={
1144
+ "e": schema_dtype(_enums, "e"),
1145
+ "d": schema_dtype(_enums, "d"),
1146
+ })
1147
+ all_e_lf = _all_entities_lf(source)
1148
+ elm_lf = _lifetime_method_with_default_lf(source, all_e_lf)
1149
+ in_methods = (elm_lf
1150
+ .filter(pl.col("method").is_in(list(methods)))
1151
+ .select("e").unique())
1152
+
1153
+ # Class gate: connection without is_DC / non-process → no lifetime.
1154
+ cls_lf = _entity_class_lf(source).group_by("e").agg(
1155
+ pl.col("ec").alias("ecs"))
1156
+ is_proc_or_node = (pl.col("ecs").list.contains("unit")
1157
+ | pl.col("ecs").list.contains("connection")
1158
+ | pl.col("ecs").list.contains("node"))
1159
+ in_class = cls_lf.with_columns(
1160
+ in_class=is_proc_or_node).select("e", "in_class")
1161
+
1162
+ pyd_lf = _p_years_d_lf(source, active_solve)
1163
+ if pyd_lf is None:
1164
+ return pl.LazyFrame(schema={
1165
+ "e": schema_dtype(_enums, "e"),
1166
+ "d": schema_dtype(_enums, "d"),
1167
+ })
1168
+
1169
+ # life_sum: per-entity scalar. Compute per d_first then sum:
1170
+ # life_sum(e) = Σ_{d_first} (pdy[d_first] + lifetime[e, d_first]).
1171
+ # Phase 4.6: ``d_first`` is a column holding period tokens; cast the
1172
+ # literal-list LazyFrame to the ``d`` (period) Enum so downstream
1173
+ # joins against Enum-typed ``pyd_first`` don't SchemaError.
1174
+ pf_lf = (pl.LazyFrame({"d_first": period_first})
1175
+ .with_columns(pl.col("d_first")
1176
+ .cast(schema_dtype(_enums, "d_first"),
1177
+ strict=False)))
1178
+ pyd_first = pyd_lf.rename({"d": "d_first", "yr": "yr_first"})
1179
+ (in_methods.join(pf_lf, how="cross")
1180
+ .pipe(rename_to_axis, {"d_first": "d"})
1181
+ .pipe(lambda lf: lf)) # placeholder for symmetry
1182
+ # Resolve lifetime[e, d_first] via cascade.
1183
+ life_per = _per_entity_param_lf(source, "lifetime")
1184
+ life_resolved = (_resolve_per_period_lf(
1185
+ life_per,
1186
+ in_methods.join(pf_lf, how="cross").pipe(rename_to_axis, {"d_first": "d"}),
1187
+ fill=0.0))
1188
+ life_at_first = life_resolved.rename({"d": "d_first", "value": "life"})
1189
+ summed_lf = (life_at_first
1190
+ .join(pyd_first, on="d_first", how="left")
1191
+ .with_columns(yr_first=pl.col("yr_first").fill_null(0.0))
1192
+ .group_by("e")
1193
+ .agg((pl.col("yr_first") + pl.col("life")).sum()
1194
+ .alias("life_sum")))
1195
+ # Apply class gate.
1196
+ summed_lf = (summed_lf
1197
+ .join(in_class, on="e", how="left")
1198
+ .with_columns(in_class=pl.col("in_class").fill_null(False))
1199
+ .with_columns(
1200
+ life_sum=pl.when(pl.col("in_class"))
1201
+ .then(pl.col("life_sum"))
1202
+ .otherwise(0.0))
1203
+ .select("e", "life_sum"))
1204
+
1205
+ piu_lf = (pl.LazyFrame({"d": period_in_use})
1206
+ .with_columns(pl.col("d").cast(schema_dtype(_enums, "d"),
1207
+ strict=False)))
1208
+ pyd_at_d = pyd_lf
1209
+ return (summed_lf
1210
+ .join(piu_lf, how="cross")
1211
+ .join(pyd_at_d, on="d", how="left")
1212
+ .with_columns(yr=pl.col("yr").fill_null(0.0))
1213
+ .filter(pl.col("yr") >= pl.col("life_sum"))
1214
+ .select("e", "d"))
1215
+
1216
+
1217
+ # ---------------------------------------------------------------------------
1218
+ # Public entry: apply boundary
1219
+ # ---------------------------------------------------------------------------
1220
+
1221
+
1222
+ def apply_existing_chain(flex_data: object,
1223
+ source: "InputSource",
1224
+ workdir: Path,
1225
+ *,
1226
+ handoff: object | None = None,
1227
+ ctx: "SolveContext | None" = None,
1228
+ provider: "object | None" = None) -> None:
1229
+ """Apply Cluster B helpers to ``flex_data`` (mutates in place).
1230
+
1231
+ Wired-in fields:
1232
+
1233
+ * ``p_entity_all_existing`` — chained existing capacity (replacing
1234
+ the workdir CSV read of ``solve_data/p_entity_all_existing.csv``
1235
+ and the cascade in :func:`._derived_params.p_entity_all_existing_from_source`).
1236
+
1237
+ Order:
1238
+ 1. Resolve the per-solve scope (active_solve, periods, etc.).
1239
+ 2. Pull the in-memory handoff carriers off ``flex_data``
1240
+ (``p_entity_period_existing_capacity``, ``p_entity_divested``)
1241
+ — these are populated by the CSV-side handoff loader / by
1242
+ :func:`apply_derived_f`'s handoff overlay.
1243
+ 3. Compute ``p_entity_all_existing`` via the lazy chain.
1244
+ 4. Overwrite ``flex_data.p_entity_all_existing`` only when the
1245
+ lazy result is non-None (mirrors the eager helper's gate).
1246
+
1247
+ Notes
1248
+ -----
1249
+ Cluster B is parity-bound to the eager
1250
+ ``p_entity_all_existing_from_source`` helper which the per-fixture
1251
+ sweep validates. When parity drifts, the test surfaces the
1252
+ fixture and the diff frame.
1253
+ """
1254
+ from ._derived_params import (
1255
+ _read_active_solve, _period_in_use_set,
1256
+ _read_period_with_history,
1257
+ )
1258
+ from .input import _read_solve_first
1259
+ from ._emit_provider_io import _provider_key
1260
+ from polar_high import Param as _Param
1261
+
1262
+ def _provider_get(p):
1263
+ if provider is None:
1264
+ return None
1265
+ key = _provider_key(p)
1266
+ if not provider.has(key):
1267
+ return None
1268
+ return provider.get(key)
1269
+
1270
+ # Δ.12a — prefer the typed SolveContext fields (zero physical IO)
1271
+ # when the caller supplied one; fall back to direct workdir reads
1272
+ # for back-compat with callers that haven't been wired up.
1273
+ if ctx is not None:
1274
+ active_solve = ctx.solve_name
1275
+ solve_first = ctx.solveFirst
1276
+ edd_hist_df_raw = ctx.edd_history
1277
+ else:
1278
+ active_solve = _read_active_solve(workdir, provider=provider)
1279
+ solve_first = _read_solve_first(workdir, provider=provider)
1280
+ edd_hist_df_raw = None
1281
+ period_in_use = _period_in_use_set(source, active_solve, workdir,
1282
+ ctx=ctx, provider=provider)
1283
+ period_with_history = (_read_period_with_history(workdir, provider=provider)
1284
+ or list(period_in_use))
1285
+
1286
+ ppic = getattr(flex_data, "p_entity_previously_invested_capacity", None)
1287
+ ped = getattr(flex_data, "p_entity_divested", None)
1288
+
1289
+ # Δ.11 / Phase 4.2-1e — chain-summation inputs. The in-memory
1290
+ # handoff ``realized_existing`` carrier is the sole source for the
1291
+ # cumulative realized-existing capacity; the workdir CSV fallback
1292
+ # was retired once the handoff translator started populating the
1293
+ # carrier at iteration start. ``edd_history.csv`` is still sourced
1294
+ # from the workdir.
1295
+ ppec_param: "Param | None" = None
1296
+ if handoff is not None and getattr(handoff, "realized_existing", None) is not None:
1297
+ re_frame = handoff.realized_existing
1298
+ if re_frame.height > 0:
1299
+ ppec_param = _Param(("e", "d"),
1300
+ re_frame.pipe(rename_to_axis, {"entity": "e",
1301
+ "period": "d"})
1302
+ .select("e", "d", "value"))
1303
+ # Phase 4.2-1e — the handoff/realized_existing branch above is the
1304
+ # sole source of the cumulative realized-existing capacity now that
1305
+ # the handoff translator always populates the carrier at iteration
1306
+ # start. When the handoff is absent or empty, ``ppec_param`` stays
1307
+ # None and the helper returns None (no-existing-capacity outcome).
1308
+
1309
+ edd_hist_df: "pl.DataFrame | None" = None
1310
+ if ctx is not None:
1311
+ if edd_hist_df_raw is not None and edd_hist_df_raw.height > 0:
1312
+ edd_hist_df = edd_hist_df_raw
1313
+ else:
1314
+ edd_path = workdir / "solve_data" / "edd_history.csv"
1315
+ edd_hist_df = _provider_get(edd_path)
1316
+ if edd_hist_df is not None and edd_hist_df.height == 0:
1317
+ edd_hist_df = None
1318
+
1319
+ # Δ.12b — assign unconditionally. The helper returns ``None`` only
1320
+ # when there's no entity_invest_method-eligible row in the source,
1321
+ # which is a legitimate "no-existing-capacity-to-track" outcome
1322
+ # (matches the eager helper's empty-frame skip). Hard errors
1323
+ # propagate (no defensive try/except).
1324
+ flex_data.p_entity_all_existing = p_entity_all_existing_from_handoff(
1325
+ source, active_solve,
1326
+ period_with_history, period_in_use,
1327
+ p_entity_period_existing_capacity=ppec_param,
1328
+ p_entity_previously_invested_capacity=ppic,
1329
+ p_entity_divested=ped,
1330
+ solve_first=solve_first,
1331
+ edd_history=edd_hist_df)
1332
+
1333
+
1334
+ __all__ = [
1335
+ # Method-enum constants (re-exported for direct-test use).
1336
+ "_INVEST_NOT_ALLOWED", "_DIVEST_NOT_ALLOWED",
1337
+ "_INVEST_TOTAL_METHODS", "_DIVEST_TOTAL_METHODS",
1338
+ "_INVEST_PERIOD_METHODS", "_DIVEST_PERIOD_METHODS",
1339
+ # Public lazy helpers — entity sets.
1340
+ "entity_invest_set_lf", "entity_divest_set_lf",
1341
+ "e_invest_total_lf", "e_divest_total_lf",
1342
+ # Public lazy helpers — edd_history triple-set family.
1343
+ "edd_history_lf", "edd_history_choice_lf",
1344
+ "edd_history_automatic_lf", "edd_history_no_investment_lf",
1345
+ "edd_invest_set_lf", "edd_invest_lookback_set_lf",
1346
+ # Public lazy helpers — partitions.
1347
+ "pd_invest_set_lf", "nd_invest_set_lf",
1348
+ "pd_divest_set_lf", "nd_divest_set_lf",
1349
+ "ed_invest_period_set_lf", "ed_divest_period_set_lf",
1350
+ # Public lazy helpers — existing chain.
1351
+ "p_entity_all_existing_from_handoff", "p_entity_pre_existing_lf",
1352
+ "apply_existing_chain",
1353
+ ]