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,1297 @@
1
+ """Cluster A — annual integration / NPV (Δ.5).
2
+
3
+ Lazy-polars port of flextool's NPV / lifetime-fixed-cost / inflation
4
+ cascade. Replaces the eager-Python implementation that previously lived
5
+ in :mod:`._derived_params` (``ed_entity_annual_family_from_source`` plus
6
+ its supporting ``_per_method_annuity`` / ``_resolve_pdX`` helpers).
7
+
8
+ The cluster covers six FlexData fields:
9
+
10
+ * ``p_inflation_op`` — per-period operations
11
+ inflation factor. Replaces the workdir read of
12
+ ``solve_data/p_inflation_factor_operations_yearly.csv``.
13
+ * ``p_ed_fixed_cost`` — per-entity per-period
14
+ raw fixed cost (× 1000 for nodes/processes). Replaces the workdir
15
+ read of ``solve_data/ed_fixed_cost.csv``.
16
+ * ``ed_entity_annual_discounted`` — invest-side NPV.
17
+ Replaces ``solve_data/ed_entity_annual_discounted.csv``.
18
+ * ``ed_entity_annual_divest_discounted`` — divest-side NPV.
19
+ Replaces ``solve_data/ed_entity_annual_divest_discounted.csv``.
20
+ * ``ed_lifetime_fixed_cost`` — invest-side lifetime
21
+ cumulative fixed cost. Replaces ``solve_data/ed_lifetime_fixed_cost.csv``.
22
+ * ``ed_lifetime_fixed_cost_divest`` — divest-side lifetime
23
+ cumulative fixed cost. Replaces ``solve_data/ed_lifetime_fixed_cost_divest.csv``.
24
+
25
+ Architecture
26
+ ------------
27
+
28
+ Per the user-locked decisions for derived clusters (audit handoff at
29
+ Δ.5):
30
+
31
+ 1. **Lazy polars throughout.** Helpers return ``pl.LazyFrame`` chains;
32
+ the public entry points materialise via ``Param(...)`` at the
33
+ ``apply_derived_f`` boundary.
34
+ 2. **Convention over typed BuildPipeline.** The dependency graph is
35
+ shallow: ``period_walk_iterator`` → 4 NPV helpers; ``p_ed_fixed_cost``
36
+ feeds the 2 lifetime-fixed-cost helpers. Plain function calls.
37
+ 3. **4 NPV variants split into 4 helpers** sharing
38
+ :func:`period_walk_iterator` for the per-period accumulation walk.
39
+
40
+ Algorithm:
41
+
42
+ For each (entity e, period d) in the relevant invest/divest/all-entity
43
+ domain, the NPV value is::
44
+
45
+ annuity[e, d] × Σ_{d_all ∈ period_in_use, window(d, d_all, e)}
46
+ inflation_factor[d_all]
47
+
48
+ where:
49
+
50
+ * ``annuity`` = sum-over-allowed-methods of
51
+ ``invest_value × 1000 × r / (1 - (1/(1+r))^n)`` with
52
+ ``r ≤ 0 → 0.05`` and ``n ≤ 0 → 20`` fallbacks.
53
+ * ``window(d, d_all, e)``:
54
+ - ``reinvest_choice`` / ``no_investment``:
55
+ ``pdy[d_all] ∈ [pdy[d], pdy[d] + lifetime[e, d])``.
56
+ - ``reinvest_automatic``: ``pdy[d_all] ≥ pdy[d]``.
57
+ - divest side: always the bounded form using raw ``lifetime``.
58
+ * ``inflation_factor[d_all]``:
59
+ - investment side → ``p_inflation_factor_investment_yearly[d_all]``.
60
+ - operations side (``ed_lifetime_fixed_cost`` only) →
61
+ ``p_inflation_factor_operations_yearly[d_all]``.
62
+ - ``ed_lifetime_fixed_cost_divest`` is asymmetric and uses the
63
+ investment factor (mirrors flextool.mod L1651).
64
+
65
+ The annuity formula and the discount-window walk are 1:1 ports of the
66
+ flextool source; deviations are documented inline.
67
+
68
+ Cross-references
69
+ ----------------
70
+
71
+ * :mod:`._derived_params._inflation_yearly_from_source` — the yearly
72
+ inflation cascade computation used to be eager-Python; we keep that
73
+ helper's algorithm but expose a lazy-frame variant
74
+ (:func:`_inflation_factors_lf`) for use here.
75
+ * :func:`._derived_params._p_years_d_lf` — already lazy; reused as-is.
76
+ * :func:`._derived_params._period_in_use_set` and
77
+ :func:`._derived_params._solve_periods` — list builders used to
78
+ bound the period domain.
79
+ """
80
+ from __future__ import annotations
81
+
82
+ from pathlib import Path
83
+ from typing import TYPE_CHECKING
84
+
85
+ import polars as pl
86
+
87
+ from polar_high import Param
88
+
89
+ from ._axis_enums import (
90
+ alias_to_axis,
91
+ schema_dtype,
92
+ )
93
+
94
+ # Substrate handle for the cascade-wide axis enum vocabulary.
95
+ # Bare ``None`` here; ``cast_dim`` / ``schema_dtype`` in
96
+ # ``_axis_enums`` fall back to ``_LIVE_AXIS_ENUMS_CTX`` (the live
97
+ # ContextVar) when this is ``None``, so substrate sites pick up
98
+ # activation set by ``load_flextool`` automatically.
99
+ _enums: "dict | None" = None
100
+
101
+ if TYPE_CHECKING:
102
+ from flextool.engine_polars._input_source import InputSource
103
+
104
+
105
+ # Mirror flextool/flextool_base.dat:211-212 (cf. entity_annual_calc_params.py).
106
+ _INVEST_NOT_ALLOWED: frozenset[str] = frozenset((
107
+ "not_allowed", "retire_period", "retire_total", "retire_no_limit",
108
+ ))
109
+ _DIVEST_NOT_ALLOWED: frozenset[str] = frozenset((
110
+ "not_allowed", "invest_period", "invest_total", "invest_no_limit",
111
+ ))
112
+
113
+ # Default lifetime_method when an entity has no explicit row
114
+ # (preprocessing/method_with_fallback_sets.py::_LIFETIME_METHOD_DEFAULT).
115
+ _LIFETIME_METHOD_DEFAULT = "reinvest_automatic"
116
+
117
+ # Inflation defaults — mirrors period_calculated_params.py:221-227 and
118
+ # _solve_inflation_inputs in _derived_params.py.
119
+ _INFL_RATE_DEFAULT = 0.0
120
+ _INFL_OFFSET_INV_DEFAULT = 0.0
121
+ _INFL_OFFSET_OPS_DEFAULT = 0.5
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Building blocks (lazy frames)
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def _entity_class_lf(source: "InputSource") -> pl.LazyFrame:
130
+ """Lazy ``(e, ec)`` frame: every (entity, entity_class) pairing.
131
+
132
+ Entity classes: ``unit``, ``node``, ``connection``. ``unit ∪
133
+ connection`` forms flextool's ``process_set``; ``node`` is its own
134
+ class. The frame supports the per-entity-class branching that
135
+ ``_resolve_pdX`` does in eager Python.
136
+ """
137
+ parts: list[pl.LazyFrame] = []
138
+ for ec in ("unit", "node", "connection"):
139
+ try:
140
+ df = source.entities(ec)
141
+ except KeyError:
142
+ continue
143
+ if df.height == 0:
144
+ continue
145
+ parts.append(df.lazy().select(
146
+ alias_to_axis("name", "e"),
147
+ pl.lit(ec).alias("ec"),
148
+ ))
149
+ if not parts:
150
+ return pl.LazyFrame(schema={"e": schema_dtype(_enums, "e"),
151
+ "ec": pl.Utf8})
152
+ return pl.concat(parts, how="vertical")
153
+
154
+
155
+ def _entity_class_lookup_lf(entity_class_lf: pl.LazyFrame) -> pl.LazyFrame:
156
+ """Lazy ``(e, is_process, is_node)`` frame: bool flags per entity.
157
+
158
+ ``is_process = ec ∈ {unit, connection}``, ``is_node = ec == 'node'``.
159
+ """
160
+ return (entity_class_lf
161
+ .with_columns(
162
+ is_process=pl.col("ec").is_in(["unit", "connection"]),
163
+ is_node=pl.col("ec") == "node",
164
+ )
165
+ .group_by("e")
166
+ .agg(
167
+ pl.col("is_process").any().alias("is_process"),
168
+ pl.col("is_node").any().alias("is_node"),
169
+ ))
170
+
171
+
172
+ def _per_entity_param_lf(source: "InputSource",
173
+ parameter_name: str,
174
+ ) -> pl.LazyFrame:
175
+ """Lazy ``(e, d, value)`` frame from ``unit/node/connection.<param>``.
176
+
177
+ Mirrors :func:`._derived_params._resolve_pdX` semantics in lazy form:
178
+
179
+ * Per-period (Map) rows produce ``(e, d, value)`` rows directly.
180
+ * Scalar rows produce ``(e, value)`` rows with ``d`` null;
181
+ consumers cross-join with the period universe.
182
+
183
+ Returns columns ``[e, d, value, is_scalar]`` — ``is_scalar`` flags
184
+ rows that need broadcasting. ``d`` is null on scalar rows.
185
+ """
186
+ parts: list[pl.LazyFrame] = []
187
+ for ec in ("unit", "node", "connection"):
188
+ try:
189
+ df = source.parameter(ec, parameter_name)
190
+ except KeyError:
191
+ continue
192
+ if df.height == 0:
193
+ continue
194
+ cols = df.columns
195
+ # Period-dim detection — see the matching helper in
196
+ # ``_derived_existing._per_entity_param_lf``. When the source
197
+ # exposes the period axis under a user-renamed ``Map.index_name``
198
+ # (e.g. ``x``), checking only ``"period" in cols`` misses it and
199
+ # the scalar-broadcast branch explodes per-period rows through
200
+ # ``_resolve_per_period_lf``'s ``scalar.join(on="e")``.
201
+ extra = [c for c in cols if c not in ("name", "value")]
202
+ period_col = "period" if "period" in extra else (extra[0] if extra else None)
203
+ if period_col is not None:
204
+ # Per-ROW scalar detection. A scalar value stored alongside
205
+ # Map values in the same entity class surfaces with a NULL
206
+ # index ((e.g. ``x = null``) — Spine emits one ``x`` column
207
+ # for the whole class, so a unit with a constant ``existing``
208
+ # while a sibling unit carries a period-Map shows up as a
209
+ # null-period row. Such rows must broadcast across the
210
+ # period universe (``is_scalar=True``), NOT be treated as an
211
+ # explicit ``(e, d=null)`` row that fails to join the period
212
+ # grid and silently fills 0. Classifying by ``period_col``'s
213
+ # null-ness per row (rather than per frame) covers the mixed
214
+ # scalar+Map class.
215
+ parts.append(df.lazy().select(
216
+ alias_to_axis("name", "e"),
217
+ alias_to_axis(pl.col(period_col).cast(pl.Utf8, strict=False),
218
+ "d"),
219
+ pl.col("value").cast(pl.Float64, strict=False),
220
+ pl.col(period_col).is_null().alias("is_scalar"),
221
+ ))
222
+ else:
223
+ parts.append(df.lazy().select(
224
+ alias_to_axis("name", "e"),
225
+ pl.lit(None).cast(schema_dtype(_enums, "d"), strict=False).alias("d"),
226
+ pl.col("value").cast(pl.Float64, strict=False),
227
+ pl.lit(True).alias("is_scalar"),
228
+ ))
229
+ if not parts:
230
+ return pl.LazyFrame(schema={
231
+ "e": schema_dtype(_enums, "e"),
232
+ "d": schema_dtype(_enums, "d"),
233
+ "value": pl.Float64, "is_scalar": pl.Boolean,
234
+ })
235
+ return pl.concat(parts, how="vertical")
236
+
237
+
238
+ def _resolve_per_period_lf(per_param: pl.LazyFrame,
239
+ ed_lf: pl.LazyFrame,
240
+ fill: float = 0.0,
241
+ ) -> pl.LazyFrame:
242
+ """Resolve ``pdX[e, param, d]`` cascade in lazy form.
243
+
244
+ Order (mirrors :func:`._derived_params._resolve_pdX`):
245
+ 1. Explicit ``(e, d)`` row → take that ``value``.
246
+ 2. Else scalar ``(e, ·)`` row → broadcast across ``ed_lf``.
247
+ 3. Else → ``fill``.
248
+
249
+ Returns ``ed_lf`` with an extra ``value`` column (Float64).
250
+ ``ed_lf`` must carry columns ``[e, d, ...]``.
251
+ """
252
+ explicit = (per_param
253
+ .filter(~pl.col("is_scalar"))
254
+ .select("e", "d", pl.col("value").alias("v_explicit")))
255
+ scalar = (per_param
256
+ .filter(pl.col("is_scalar"))
257
+ .select("e", pl.col("value").alias("v_scalar")))
258
+ return (ed_lf
259
+ .join(explicit, on=["e", "d"], how="left")
260
+ .join(scalar, on="e", how="left")
261
+ .with_columns(
262
+ value=pl.coalesce(
263
+ pl.col("v_explicit"),
264
+ pl.col("v_scalar"),
265
+ pl.lit(fill, dtype=pl.Float64),
266
+ ),
267
+ )
268
+ .drop("v_explicit", "v_scalar"))
269
+
270
+
271
+ def _entity_method_lf(source: "InputSource",
272
+ parameter_name: str,
273
+ ) -> pl.LazyFrame:
274
+ """Lazy ``(e, method)`` frame from ``unit/node/connection.<param>``.
275
+
276
+ ``parameter_name`` is one of ``invest_method`` or ``lifetime_method``.
277
+ Each entity may carry multiple method rows (Spine schema allows multi-
278
+ valued methods); we preserve all rows. The ``invest_method`` default
279
+ is ``not_allowed``; the ``lifetime_method`` default is
280
+ ``reinvest_automatic`` — the latter is applied at the consumer site
281
+ (:func:`_lifetime_method_with_default_lf`).
282
+ """
283
+ parts: list[pl.LazyFrame] = []
284
+ for ec in ("unit", "node", "connection"):
285
+ try:
286
+ df = source.parameter(ec, parameter_name)
287
+ except KeyError:
288
+ continue
289
+ if df.height == 0:
290
+ continue
291
+ parts.append(df.lazy().select(
292
+ alias_to_axis("name", "e"),
293
+ pl.col("value").cast(pl.Utf8, strict=False).alias("method"),
294
+ ))
295
+ if not parts:
296
+ return pl.LazyFrame(schema={"e": schema_dtype(_enums, "e"),
297
+ "method": pl.Utf8})
298
+ return pl.concat(parts, how="vertical")
299
+
300
+
301
+ def _lifetime_method_with_default_lf(
302
+ source: "InputSource",
303
+ all_entities_lf: pl.LazyFrame,
304
+ ) -> pl.LazyFrame:
305
+ """Lazy ``(e, method)`` frame for ``lifetime_method`` with default fill.
306
+
307
+ Entities without an explicit ``lifetime_method`` row get
308
+ ``reinvest_automatic`` (mirrors
309
+ ``preprocessing/method_with_fallback_sets.py::_LIFETIME_METHOD_DEFAULT``).
310
+ ``all_entities_lf`` carries ``[e]``.
311
+ """
312
+ explicit = _entity_method_lf(source, "lifetime_method")
313
+ explicit_entities = explicit.select("e").unique()
314
+ default_e = (all_entities_lf
315
+ .join(explicit_entities, on="e", how="anti")
316
+ .with_columns(method=pl.lit(_LIFETIME_METHOD_DEFAULT)))
317
+ return pl.concat([explicit, default_e], how="vertical")
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Inflation factors (lazy)
322
+ # ---------------------------------------------------------------------------
323
+
324
+
325
+ def _solve_inflation_scalars(source: "InputSource"
326
+ ) -> tuple[float, float, float]:
327
+ """Read ``model.inflation_rate`` / ``model.inflation_offset_invest`` /
328
+ ``model.inflation_offset_operations`` scalars, falling back to
329
+ flextool's CSV-side defaults when the Spine reader returned
330
+ only-default rows.
331
+
332
+ Mirrors :func:`._derived_params._solve_inflation_inputs` semantics.
333
+ """
334
+ def _explicit_max(par: str, default: float) -> float:
335
+ try:
336
+ df = source.parameter("model", par)
337
+ except KeyError:
338
+ return default
339
+ if df.height == 0:
340
+ return default
341
+ try:
342
+ spine_default = source.parameter_default("model", par)
343
+ except KeyError:
344
+ spine_default = None
345
+ if spine_default is not None:
346
+ try:
347
+ spine_default_f = float(spine_default)
348
+ except (ValueError, TypeError):
349
+ spine_default_f = None
350
+ if spine_default_f is not None:
351
+ vals = df["value"].cast(pl.Float64, strict=False).to_list()
352
+ if vals and all(
353
+ v is not None and abs(v - spine_default_f) < 1e-12
354
+ for v in vals):
355
+ return default
356
+ try:
357
+ return float(df["value"].cast(pl.Float64).max())
358
+ except Exception:
359
+ return default
360
+
361
+ return (
362
+ _explicit_max("inflation_rate", _INFL_RATE_DEFAULT),
363
+ _explicit_max("inflation_offset_investment", _INFL_OFFSET_INV_DEFAULT),
364
+ _explicit_max("inflation_offset_operations", _INFL_OFFSET_OPS_DEFAULT),
365
+ )
366
+
367
+
368
+ def _years_for_period_lf(source: "InputSource",
369
+ active_solve: str | None,
370
+ period_universe: list[str],
371
+ ) -> pl.LazyFrame:
372
+ """Build the per-(d, year_label, width) frame from
373
+ ``solve.years_represented`` mirroring
374
+ :func:`._derived_params._years_for_period_from_source` in lazy form.
375
+
376
+ Returns columns ``[d, y, width]`` where ``y`` is a string label.
377
+ Empty if no rows.
378
+ """
379
+ # Materialise scalars eagerly — they're solve-level, tiny.
380
+ from ._derived_params import _years_for_period_from_source
381
+ yfp = _years_for_period_from_source(source, active_solve, period_universe)
382
+ rows: list[tuple[str, str, float]] = []
383
+ for d, years in yfp.items():
384
+ for y, w in years:
385
+ rows.append((d, y, w))
386
+ if not rows:
387
+ return pl.LazyFrame(schema={
388
+ "d": schema_dtype(_enums, "d"),
389
+ "y": pl.Utf8, "width": pl.Float64,
390
+ })
391
+ return pl.LazyFrame(rows, schema=["d", "y", "width"], orient="row").with_columns(
392
+ alias_to_axis("d", "d"))
393
+
394
+
395
+ def _inflation_factors_lf(source: "InputSource",
396
+ active_solve: str | None,
397
+ period_universe: list[str],
398
+ ) -> pl.LazyFrame:
399
+ """Lazy ``(d, inv_factor, ops_factor)`` frame.
400
+
401
+ Algorithm — port of ``preprocessing/period_calculated_params.py:230-322``:
402
+
403
+ 1. Scalar inputs: ``rate``, ``offset_invest``, ``offset_operations``.
404
+ 2. For each (d, y) ∈ ``years_for_period``:
405
+ ``base[d, y] = Σ_{y' ∈ global, y' < y} pyr[d, y']`` (default 1)
406
+ ``until_inv[d, y] = base + pyr × offset_invest``
407
+ ``until_ops[d, y] = base + pyr × offset_operations``
408
+ 3. ``inv_factor[d] = Σ_y pyr[d, y] × (1+rate)^(-until_inv[d, y])``
409
+ ``ops_factor[d] = Σ_y pyr[d, y] × (1+rate)^(-until_ops[d, y])``.
410
+ When ``Σ_y pyr[d, y] == 0`` → 1.0.
411
+
412
+ The cumulative-base computation (step 2) is per-period over the
413
+ GLOBAL year set, so it's shaped naturally as a polars window:
414
+
415
+ base[d, y] = Σ_{y'<y in global order} pyr[d, y']
416
+ = (cumulative_sum of pyr[d, y'] over y'<y, partitioned by d)
417
+
418
+ where unbound (d, y) pairs contribute 1 (default ``years_represented``).
419
+ """
420
+ if not period_universe:
421
+ return pl.LazyFrame(schema={
422
+ "d": schema_dtype(_enums, "d"),
423
+ "inv_factor": pl.Float64, "ops_factor": pl.Float64,
424
+ })
425
+ rate, off_inv, off_ops = _solve_inflation_scalars(source)
426
+ one_plus_inv = (1.0 / (1.0 + rate)) if rate != -1.0 else 1.0
427
+ yfp_lf = _years_for_period_lf(source, active_solve, period_universe)
428
+
429
+ # Materialise per-period × per-y. At LP scale this is small (typically
430
+ # ≤ 100 (d, y) rows even for 50-year horizon × 20 representative years),
431
+ # so collecting once is cheap; the rest stays lazy.
432
+ yfp_eager = yfp_lf.collect()
433
+ period_lf = pl.LazyFrame({"d": period_universe}).with_columns(
434
+ alias_to_axis("d", "d"))
435
+
436
+ if yfp_eager.height == 0:
437
+ # No years_represented data — every period gets the trivial 1.0
438
+ # factor (mirrors period_calculated_params.py:300).
439
+ return (period_lf
440
+ .with_columns(
441
+ inv_factor=pl.lit(1.0, dtype=pl.Float64),
442
+ ops_factor=pl.lit(1.0, dtype=pl.Float64),
443
+ ))
444
+
445
+ # Build the global year set with numerical sort when possible.
446
+ try:
447
+ global_years = sorted(
448
+ yfp_eager["y"].unique().to_list(), key=lambda y: float(y)
449
+ )
450
+ except ValueError:
451
+ global_years = sorted(yfp_eager["y"].unique().to_list())
452
+
453
+ # Cross-join (d, y_global) so missing rows default to width=1.0
454
+ # (mirrors flextool's default of p_years_represented).
455
+ global_y_lf = pl.LazyFrame({"y": global_years}).with_columns(
456
+ y_idx=pl.int_range(0, pl.len(), dtype=pl.Int64)
457
+ )
458
+ full = (period_lf
459
+ .join(global_y_lf, how="cross")
460
+ .join(yfp_eager.lazy(), on=["d", "y"], how="left")
461
+ .with_columns(
462
+ pyr=pl.col("width").fill_null(1.0),
463
+ ))
464
+
465
+ # Cumulative base[d, y] = Σ pyr[d, y'] over y' < y (within d).
466
+ full = (full
467
+ .sort(["d", "y_idx"])
468
+ .with_columns(
469
+ base=pl.col("pyr").cum_sum().over("d") - pl.col("pyr"),
470
+ ))
471
+
472
+ # Restrict to (d, y) actually bound to this period.
473
+ bound = (yfp_eager.lazy()
474
+ .select("d", "y")
475
+ .join(full, on=["d", "y"], how="inner"))
476
+
477
+ # Per-(d, y) factor contributions.
478
+ bound = bound.with_columns(
479
+ until_inv=pl.col("base") + pl.col("pyr") * off_inv,
480
+ until_ops=pl.col("base") + pl.col("pyr") * off_ops,
481
+ ).with_columns(
482
+ inv_contrib=pl.col("pyr") * (one_plus_inv ** pl.col("until_inv")),
483
+ ops_contrib=pl.col("pyr") * (one_plus_inv ** pl.col("until_ops")),
484
+ )
485
+
486
+ factor_per_d = (bound
487
+ .group_by("d")
488
+ .agg(
489
+ pl.col("inv_contrib").sum().alias("inv_factor"),
490
+ pl.col("ops_contrib").sum().alias("ops_factor"),
491
+ pl.col("pyr").sum().alias("sum_pyr"),
492
+ ))
493
+
494
+ # Sum-pyr == 0 → factor = 1.0 (period_calculated_params.py:299-301).
495
+ factor_per_d = factor_per_d.with_columns(
496
+ inv_factor=pl.when(pl.col("sum_pyr") > 0)
497
+ .then(pl.col("inv_factor"))
498
+ .otherwise(1.0),
499
+ ops_factor=pl.when(pl.col("sum_pyr") > 0)
500
+ .then(pl.col("ops_factor"))
501
+ .otherwise(1.0),
502
+ ).select("d", "inv_factor", "ops_factor")
503
+
504
+ # Periods absent from years_represented (or with R=0) get 1.0
505
+ # (mirrors flextool's writer default of 1.0).
506
+ return (period_lf
507
+ .join(factor_per_d, on="d", how="left")
508
+ .with_columns(
509
+ inv_factor=pl.col("inv_factor").fill_null(1.0),
510
+ ops_factor=pl.col("ops_factor").fill_null(1.0),
511
+ ))
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # period_walk_iterator — the shared NPV walk
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ def period_walk_iterator(
520
+ source: "InputSource",
521
+ active_solve: str | None,
522
+ ed_lf: pl.LazyFrame,
523
+ period_in_use: list[str],
524
+ period_universe: list[str],
525
+ *,
526
+ bounded: bool,
527
+ life_lf: pl.LazyFrame,
528
+ factor_side: str,
529
+ ) -> pl.LazyFrame:
530
+ """Δ.5-era public alias delegating to :mod:`._derived_walks`.
531
+
532
+ Δ.6 lifted the canonical implementation into ``_derived_walks`` so
533
+ Cluster B's invest-history walk could re-use it. This wrapper
534
+ preserves the boolean ``bounded`` signature for the existing NPV
535
+ callers while internally dispatching the new
536
+ :class:`._derived_walks.WindowMethod` enum.
537
+
538
+ See :func:`._derived_walks.period_walk_iterator` for the canonical
539
+ semantics.
540
+ """
541
+ from ._derived_walks import (
542
+ period_walk_iterator as _walk,
543
+ WindowMethod,
544
+ )
545
+ method = WindowMethod.BOUNDED if bounded else WindowMethod.UNBOUNDED_FORWARD
546
+ return _walk(
547
+ source, active_solve, ed_lf,
548
+ period_in_use, period_universe,
549
+ window_method=method, life_lf=life_lf,
550
+ factor_side=factor_side)
551
+
552
+
553
+ # ---------------------------------------------------------------------------
554
+ # Helper: per-(e, d) lifetime
555
+ # ---------------------------------------------------------------------------
556
+
557
+
558
+ def _ed_lifetime_lf(source: "InputSource",
559
+ all_entities_lf: pl.LazyFrame,
560
+ periods_lf: pl.LazyFrame,
561
+ ) -> pl.LazyFrame:
562
+ """Lazy ``(e, d, life)`` mirroring ``edEntity_lifetime`` semantics.
563
+
564
+ Mirrors :func:`._derived_params._ed_lifetime_mapping`: for each
565
+ (e, d) ∈ entities × periods, emit ``unit/node/connection.lifetime``
566
+ cascaded via ``_resolve_pdX``. Non-process / non-node entities
567
+ get 0 (mirrors flextool's class-gated branch).
568
+ """
569
+ ed_lf = all_entities_lf.join(periods_lf, how="cross")
570
+ cls_lf = _entity_class_lookup_lf(_entity_class_lf(source))
571
+ # Restrict to processes + nodes — other classes get life=0.
572
+ ed_lf = ed_lf.join(cls_lf, on="e", how="left")
573
+ in_class = pl.col("is_process").fill_null(False) | pl.col("is_node").fill_null(False)
574
+ life_per = _per_entity_param_lf(source, "lifetime")
575
+ resolved = _resolve_per_period_lf(life_per, ed_lf, fill=0.0)
576
+ return (resolved
577
+ .with_columns(
578
+ life=pl.when(in_class).then(pl.col("value")).otherwise(0.0),
579
+ )
580
+ .select("e", "d", "life"))
581
+
582
+
583
+ # ---------------------------------------------------------------------------
584
+ # Per-method annuity (lazy)
585
+ # ---------------------------------------------------------------------------
586
+
587
+
588
+ def _per_method_annuity_lf(
589
+ source: "InputSource",
590
+ ed_lf: pl.LazyFrame,
591
+ cost_param_name: str,
592
+ disallowed_methods: frozenset[str],
593
+ ) -> pl.LazyFrame:
594
+ """Lazy ``(e, d, ann)`` frame computing the per-method annuity sum.
595
+
596
+ Algorithm (port of
597
+ ``entity_annual_calc_params.py:177-217``):
598
+
599
+ For each (e, d) ∈ ed_lf:
600
+ ann[e, d] = Σ_{m ∈ invest_methods[e] \\ disallowed}
601
+ _annuity(cost[e, d], discount_rate[e, d], lifetime[e, d])
602
+ (when e ∈ unit ∪ connection ∪ node)
603
+
604
+ The inner computation does NOT depend on ``m`` — every allowed
605
+ method contributes the same ``_annuity(...)`` value. So we collapse:
606
+ ``ann[e, d] = method_count[e] × annuity[e, d]`` where
607
+ ``method_count[e] = |{m : (e, m) ∈ entity__invest_method,
608
+ m ∉ disallowed}|``.
609
+ """
610
+ # Per-entity allowed-method count.
611
+ methods_lf = _entity_method_lf(source, "invest_method")
612
+ method_count_lf = (methods_lf
613
+ .filter(~pl.col("method").is_in(list(disallowed_methods)))
614
+ .group_by("e")
615
+ .agg(pl.col("method").count().alias("method_count")))
616
+
617
+ # Class membership gate: e ∈ unit ∪ connection ∪ node (mirrors the
618
+ # if/elif blocks in entity_annual_calc_params._per_method_annuity_*).
619
+ cls_lf = _entity_class_lookup_lf(_entity_class_lf(source))
620
+ in_class = (pl.col("is_process").fill_null(False)
621
+ | pl.col("is_node").fill_null(False))
622
+
623
+ # Per-(e, d) cost / discount_rate / lifetime via _resolve_pdX.
624
+ cost_per = _per_entity_param_lf(source, cost_param_name)
625
+ disc_per = _per_entity_param_lf(source, "discount_rate")
626
+ life_per = _per_entity_param_lf(source, "lifetime")
627
+
628
+ ed_with_class = ed_lf.join(cls_lf, on="e", how="left")
629
+ ed_with_cost = (_resolve_per_period_lf(cost_per, ed_with_class, fill=0.0)
630
+ .rename({"value": "cost"}))
631
+ ed_with_disc = (_resolve_per_period_lf(disc_per, ed_with_cost, fill=0.0)
632
+ .rename({"value": "disc"}))
633
+ ed_with_life = (_resolve_per_period_lf(life_per, ed_with_disc, fill=0.0)
634
+ .rename({"value": "life"}))
635
+
636
+ # Annuity formula: invest_value × 1000 × r / (1 - (1/(1+r))^n)
637
+ # with r ≤ 0 → 0.05, n ≤ 0 → 20.
638
+ r_eff = (pl.when(pl.col("disc") > 0).then(pl.col("disc")).otherwise(0.05))
639
+ n_eff = (pl.when(pl.col("life") > 0).then(pl.col("life")).otherwise(20.0))
640
+ annuity_expr = (pl.when(r_eff == 0)
641
+ .then(0.0)
642
+ .otherwise(
643
+ pl.col("cost") * 1000.0 * r_eff
644
+ / (1.0 - (1.0 / (1.0 + r_eff)) ** n_eff)))
645
+ out = (ed_with_life
646
+ .join(method_count_lf, on="e", how="left")
647
+ .with_columns(
648
+ method_count=pl.col("method_count").fill_null(0),
649
+ ann_per_method=annuity_expr,
650
+ )
651
+ .with_columns(
652
+ ann=pl.when(in_class)
653
+ .then(pl.col("ann_per_method")
654
+ * pl.col("method_count").cast(pl.Float64))
655
+ .otherwise(0.0),
656
+ )
657
+ .select("e", "d", "ann"))
658
+ return out
659
+
660
+
661
+ # ---------------------------------------------------------------------------
662
+ # 4 NPV variant helpers (each producing one lazy frame).
663
+ # ---------------------------------------------------------------------------
664
+
665
+
666
+ def _entity_invest_set_lf(source: "InputSource",
667
+ disallowed_methods: frozenset[str],
668
+ ) -> pl.LazyFrame:
669
+ """Lazy ``[e]`` for the canonical entityInvest / entityDivest set.
670
+
671
+ Mirrors flextool's
672
+ ``preprocessing/invest_method_sets.py::write_invest_method_sets``:
673
+
674
+ entityInvest = {e : ∃m s.t. (e, m) ∈ entity__invest_method
675
+ AND m ∉ invest_method_not_allowed}
676
+
677
+ ``disallowed_methods`` selects between the invest variant
678
+ (:data:`_INVEST_NOT_ALLOWED`) and the divest variant
679
+ (:data:`_DIVEST_NOT_ALLOWED`). An entity with no explicit
680
+ ``invest_method`` row is **excluded** (default is ``not_allowed``).
681
+ """
682
+ methods_lf = _entity_method_lf(source, "invest_method")
683
+ return (methods_lf
684
+ .filter(~pl.col("method").is_in(list(disallowed_methods)))
685
+ .select("e")
686
+ .unique())
687
+
688
+
689
+ def npv_invest_discounted_lf(
690
+ source: "InputSource",
691
+ active_solve: str | None,
692
+ period_invest: list[str],
693
+ period_in_use: list[str],
694
+ period_universe: list[str],
695
+ ) -> pl.LazyFrame:
696
+ """Variant 1: ``ed_entity_annual_discounted`` (invest-side NPV).
697
+
698
+ Per (e ∈ entityInvest, d ∈ period_invest)::
699
+
700
+ ann[e, d] × Σ_{d_all ∈ period_in_use, window(d, d_all)} inv_factor[d_all]
701
+
702
+ where ``window`` switches between bounded / unbounded based on
703
+ ``e``'s lifetime_method (``reinvest_choice`` / ``no_investment`` →
704
+ bounded; ``reinvest_automatic`` → unbounded). An entity may have
705
+ multiple methods; each contributes a separate sum. See
706
+ ``entity_annual_calc_params.py:222-251``.
707
+
708
+ Returns lazy ``[e, d, value]``.
709
+ """
710
+ if not period_invest:
711
+ return pl.LazyFrame(schema={
712
+ "e": schema_dtype(_enums, "e"),
713
+ "d": schema_dtype(_enums, "d"),
714
+ "value": pl.Float64,
715
+ })
716
+ # entityInvest = projection of entity__invest_method via the
717
+ # method-allowed gate (mirror of preprocessing/invest_method_sets).
718
+ entity_invest_set = _entity_invest_set_lf(source, _INVEST_NOT_ALLOWED)
719
+ pi_lf = pl.LazyFrame({"d": period_invest}).with_columns(
720
+ alias_to_axis("d", "d"))
721
+ ed_anchor_lf = entity_invest_set.join(pi_lf, how="cross")
722
+
723
+ # Annuity (invest cost).
724
+ ann_lf = _per_method_annuity_lf(
725
+ source, ed_anchor_lf, "invest_cost", _INVEST_NOT_ALLOWED)
726
+
727
+ # Lifetime methods: per-entity dispatch.
728
+ all_e_lf = ed_anchor_lf.select("e").unique()
729
+ elm_lf = _lifetime_method_with_default_lf(source, all_e_lf)
730
+ # Choice/no_investment → bounded; automatic → unbounded. An entity
731
+ # may have multiple methods; each contributes additively (mirrors
732
+ # the if/if structure at L237/244).
733
+ has_choice_or_no = (elm_lf
734
+ .filter(pl.col("method").is_in(
735
+ ["reinvest_choice", "no_investment"]))
736
+ .select("e").unique()
737
+ .with_columns(has_choice=pl.lit(True)))
738
+ has_automatic = (elm_lf
739
+ .filter(pl.col("method") == "reinvest_automatic")
740
+ .select("e").unique()
741
+ .with_columns(has_automatic=pl.lit(True)))
742
+
743
+ # life_lf for the bounded walk (uses edEntity_lifetime per L236).
744
+ pwh_lf = pl.LazyFrame({"d": list({d for d in period_invest})}).with_columns(
745
+ alias_to_axis("d", "d"))
746
+ life_lf = _ed_lifetime_lf(source, all_e_lf, pwh_lf)
747
+
748
+ bounded = period_walk_iterator(
749
+ source, active_solve, ed_anchor_lf,
750
+ period_in_use, period_universe,
751
+ bounded=True, life_lf=life_lf, factor_side="inv")
752
+ unbounded = period_walk_iterator(
753
+ source, active_solve, ed_anchor_lf,
754
+ period_in_use, period_universe,
755
+ bounded=False, life_lf=life_lf, factor_side="inv")
756
+
757
+ # Combine.
758
+ bounded = bounded.rename({"factor": "factor_b"})
759
+ unbounded = unbounded.rename({"factor": "factor_u"})
760
+ combined = (ed_anchor_lf
761
+ .join(ann_lf, on=["e", "d"], how="left")
762
+ .join(bounded, on=["e", "d"], how="left")
763
+ .join(unbounded, on=["e", "d"], how="left")
764
+ .join(has_choice_or_no, on="e", how="left")
765
+ .join(has_automatic, on="e", how="left")
766
+ .with_columns(
767
+ ann=pl.col("ann").fill_null(0.0),
768
+ factor_b=pl.col("factor_b").fill_null(0.0),
769
+ factor_u=pl.col("factor_u").fill_null(0.0),
770
+ has_choice=pl.col("has_choice").fill_null(False),
771
+ has_automatic=pl.col("has_automatic").fill_null(False),
772
+ )
773
+ .with_columns(
774
+ value=(
775
+ pl.when(pl.col("has_choice"))
776
+ .then(pl.col("ann") * pl.col("factor_b"))
777
+ .otherwise(0.0)
778
+ + pl.when(pl.col("has_automatic"))
779
+ .then(pl.col("ann") * pl.col("factor_u"))
780
+ .otherwise(0.0)
781
+ ),
782
+ )
783
+ .select("e", "d", "value"))
784
+ return combined
785
+
786
+
787
+ def npv_divest_discounted_lf(
788
+ source: "InputSource",
789
+ active_solve: str | None,
790
+ period_invest: list[str],
791
+ period_in_use: list[str],
792
+ period_universe: list[str],
793
+ ) -> pl.LazyFrame:
794
+ """Variant 2: ``ed_entity_annual_divest_discounted`` (divest-side NPV).
795
+
796
+ Per (e ∈ entityDivest, d ∈ period_invest)::
797
+
798
+ ann[e, d] × Σ_{d_all : pdy ∈ [pdy_d, pdy_d + life)} inv_factor[d_all]
799
+
800
+ where ``life`` is the **raw** ``lifetime`` value (not edEntity_lifetime),
801
+ and the gate is restricted to e ∈ node ∪ process (mirrors
802
+ L266-285 — no fallback for unclassified entities).
803
+
804
+ Returns lazy ``[e, d, value]``.
805
+ """
806
+ if not period_invest:
807
+ return pl.LazyFrame(schema={
808
+ "e": schema_dtype(_enums, "e"),
809
+ "d": schema_dtype(_enums, "d"),
810
+ "value": pl.Float64,
811
+ })
812
+ entity_divest_set = _entity_invest_set_lf(source, _DIVEST_NOT_ALLOWED)
813
+ pi_lf = pl.LazyFrame({"d": period_invest}).with_columns(
814
+ alias_to_axis("d", "d"))
815
+ ed_anchor_lf = entity_divest_set.join(pi_lf, how="cross")
816
+
817
+ ann_lf = _per_method_annuity_lf(
818
+ source, ed_anchor_lf, "salvage_value", _DIVEST_NOT_ALLOWED)
819
+
820
+ # Use raw lifetime (per L271-284), not edEntity_lifetime. Resolve
821
+ # via _resolve_pdX with class gate (only nodes/processes).
822
+ cls_lf = _entity_class_lookup_lf(_entity_class_lf(source))
823
+ in_class = (pl.col("is_process").fill_null(False)
824
+ | pl.col("is_node").fill_null(False))
825
+ life_per = _per_entity_param_lf(source, "lifetime")
826
+ ed_with_class = ed_anchor_lf.join(cls_lf, on="e", how="left")
827
+ life_lf = (_resolve_per_period_lf(life_per, ed_with_class, fill=0.0)
828
+ .with_columns(
829
+ life=pl.when(in_class).then(pl.col("value")).otherwise(0.0),
830
+ )
831
+ .select("e", "d", "life"))
832
+
833
+ walk = period_walk_iterator(
834
+ source, active_solve, ed_anchor_lf,
835
+ period_in_use, period_universe,
836
+ bounded=True, life_lf=life_lf, factor_side="inv")
837
+
838
+ return (ed_anchor_lf
839
+ .join(ann_lf, on=["e", "d"], how="left")
840
+ .join(walk, on=["e", "d"], how="left")
841
+ .with_columns(
842
+ ann=pl.col("ann").fill_null(0.0),
843
+ factor=pl.col("factor").fill_null(0.0),
844
+ value=pl.col("ann") * pl.col("factor"),
845
+ )
846
+ .select("e", "d", "value"))
847
+
848
+
849
+ def _ed_fixed_cost_raw_lf(source: "InputSource",
850
+ ed_lf: pl.LazyFrame,
851
+ ) -> pl.LazyFrame:
852
+ """Lazy ``(e, d, fc)`` — per-entity per-period fixed cost × 1000.
853
+
854
+ Mirrors ``preprocessing/entity_period_calc_params.py:149-156``::
855
+
856
+ ed_fixed_cost[e, d] = (1000 if e ∈ node else 0) × pdNode[e, fixed_cost, d]
857
+ + (1000 if e ∈ process else 0) × pdProcess[e, fixed_cost, d]
858
+
859
+ Since unit/connection/node are disjoint, the formula simplifies to:
860
+ fc = 1000 × resolve_pdX(fixed_cost, e, d) when e ∈ node ∪ process,
861
+ else 0.
862
+ """
863
+ cls_lf = _entity_class_lookup_lf(_entity_class_lf(source))
864
+ in_class = (pl.col("is_process").fill_null(False)
865
+ | pl.col("is_node").fill_null(False))
866
+ fc_per = _per_entity_param_lf(source, "fixed_cost")
867
+ ed_with_class = ed_lf.join(cls_lf, on="e", how="left")
868
+ return (_resolve_per_period_lf(fc_per, ed_with_class, fill=0.0)
869
+ .with_columns(
870
+ fc=pl.when(in_class)
871
+ .then(pl.col("value") * 1000.0)
872
+ .otherwise(0.0),
873
+ )
874
+ .select("e", "d", "fc"))
875
+
876
+
877
+ def lifetime_fixed_cost_invest_lf(
878
+ source: "InputSource",
879
+ active_solve: str | None,
880
+ period_with_history: list[str],
881
+ period_in_use: list[str],
882
+ period_universe: list[str],
883
+ ) -> pl.LazyFrame:
884
+ """Variant 3: ``ed_lifetime_fixed_cost`` (invest-side lifetime FC).
885
+
886
+ Per (e ∈ entity, d ∈ period_with_history)::
887
+
888
+ fc[e, d] × Σ_{d_all ∈ period_in_use, window(d, d_all)} ops_factor[d_all]
889
+
890
+ Same window dispatch as :func:`npv_invest_discounted_lf`
891
+ (choice/no_invest → bounded; automatic → unbounded), with
892
+ ops_factor (not inv_factor) and edEntity_lifetime as the bound.
893
+ See L292-321.
894
+ """
895
+ if not period_with_history:
896
+ return pl.LazyFrame(schema={
897
+ "e": schema_dtype(_enums, "e"),
898
+ "d": schema_dtype(_enums, "d"),
899
+ "value": pl.Float64,
900
+ })
901
+
902
+ # Build (e, d) over all_entities × period_with_history.
903
+ all_e_lf = _all_entities_lf(source)
904
+ pwh_lf = pl.LazyFrame({"d": period_with_history}).with_columns(
905
+ alias_to_axis("d", "d"))
906
+ ed_anchor_lf = all_e_lf.join(pwh_lf, how="cross")
907
+
908
+ fc_lf = _ed_fixed_cost_raw_lf(source, ed_anchor_lf)
909
+
910
+ elm_lf = _lifetime_method_with_default_lf(source, all_e_lf)
911
+ has_choice_or_no = (elm_lf
912
+ .filter(pl.col("method").is_in(
913
+ ["reinvest_choice", "no_investment"]))
914
+ .select("e").unique()
915
+ .with_columns(has_choice=pl.lit(True)))
916
+ has_automatic = (elm_lf
917
+ .filter(pl.col("method") == "reinvest_automatic")
918
+ .select("e").unique()
919
+ .with_columns(has_automatic=pl.lit(True)))
920
+
921
+ # life_lf via edEntity_lifetime (per L306).
922
+ life_lf = _ed_lifetime_lf(source, all_e_lf, pwh_lf)
923
+
924
+ bounded = period_walk_iterator(
925
+ source, active_solve, ed_anchor_lf,
926
+ period_in_use, period_universe,
927
+ bounded=True, life_lf=life_lf, factor_side="ops")
928
+ unbounded = period_walk_iterator(
929
+ source, active_solve, ed_anchor_lf,
930
+ period_in_use, period_universe,
931
+ bounded=False, life_lf=life_lf, factor_side="ops")
932
+
933
+ bounded = bounded.rename({"factor": "factor_b"})
934
+ unbounded = unbounded.rename({"factor": "factor_u"})
935
+
936
+ return (ed_anchor_lf
937
+ .join(fc_lf, on=["e", "d"], how="left")
938
+ .join(bounded, on=["e", "d"], how="left")
939
+ .join(unbounded, on=["e", "d"], how="left")
940
+ .join(has_choice_or_no, on="e", how="left")
941
+ .join(has_automatic, on="e", how="left")
942
+ .with_columns(
943
+ fc=pl.col("fc").fill_null(0.0),
944
+ factor_b=pl.col("factor_b").fill_null(0.0),
945
+ factor_u=pl.col("factor_u").fill_null(0.0),
946
+ has_choice=pl.col("has_choice").fill_null(False),
947
+ has_automatic=pl.col("has_automatic").fill_null(False),
948
+ )
949
+ .with_columns(
950
+ value=(
951
+ pl.when(pl.col("has_choice"))
952
+ .then(pl.col("fc") * pl.col("factor_b"))
953
+ .otherwise(0.0)
954
+ + pl.when(pl.col("has_automatic"))
955
+ .then(pl.col("fc") * pl.col("factor_u"))
956
+ .otherwise(0.0)
957
+ ),
958
+ )
959
+ .select("e", "d", "value"))
960
+
961
+
962
+ def lifetime_fixed_cost_divest_lf(
963
+ source: "InputSource",
964
+ active_solve: str | None,
965
+ period_invest: list[str],
966
+ period_in_use: list[str],
967
+ period_universe: list[str],
968
+ ) -> pl.LazyFrame:
969
+ """Variant 4: ``ed_lifetime_fixed_cost_divest`` (divest-side FC).
970
+
971
+ Per (e ∈ entityDivest, d ∈ period_invest)::
972
+
973
+ fc[e, d] × Σ_{d_all : pdy ∈ [pdy_d, pdy_d + life)} inv_factor[d_all]
974
+
975
+ where ``life`` is raw lifetime and ``inv_factor`` (NOT ops_factor)
976
+ is used — mod L1651 asymmetry. See L325-348.
977
+ """
978
+ if not period_invest:
979
+ return pl.LazyFrame(schema={
980
+ "e": schema_dtype(_enums, "e"),
981
+ "d": schema_dtype(_enums, "d"),
982
+ "value": pl.Float64,
983
+ })
984
+ entity_divest_set = _entity_invest_set_lf(source, _DIVEST_NOT_ALLOWED)
985
+ pi_lf = pl.LazyFrame({"d": period_invest}).with_columns(
986
+ alias_to_axis("d", "d"))
987
+ ed_anchor_lf = entity_divest_set.join(pi_lf, how="cross")
988
+
989
+ fc_lf = _ed_fixed_cost_raw_lf(source, ed_anchor_lf)
990
+
991
+ # Raw lifetime (gated on class).
992
+ cls_lf = _entity_class_lookup_lf(_entity_class_lf(source))
993
+ in_class = (pl.col("is_process").fill_null(False)
994
+ | pl.col("is_node").fill_null(False))
995
+ life_per = _per_entity_param_lf(source, "lifetime")
996
+ ed_with_class = ed_anchor_lf.join(cls_lf, on="e", how="left")
997
+ life_lf = (_resolve_per_period_lf(life_per, ed_with_class, fill=0.0)
998
+ .with_columns(
999
+ life=pl.when(in_class).then(pl.col("value")).otherwise(0.0),
1000
+ )
1001
+ .select("e", "d", "life"))
1002
+
1003
+ walk = period_walk_iterator(
1004
+ source, active_solve, ed_anchor_lf,
1005
+ period_in_use, period_universe,
1006
+ bounded=True, life_lf=life_lf, factor_side="inv")
1007
+
1008
+ return (ed_anchor_lf
1009
+ .join(fc_lf, on=["e", "d"], how="left")
1010
+ .join(walk, on=["e", "d"], how="left")
1011
+ .with_columns(
1012
+ fc=pl.col("fc").fill_null(0.0),
1013
+ factor=pl.col("factor").fill_null(0.0),
1014
+ value=pl.col("fc") * pl.col("factor"),
1015
+ )
1016
+ .select("e", "d", "value"))
1017
+
1018
+
1019
+ # ---------------------------------------------------------------------------
1020
+ # Per-entity-class helpers (lazy)
1021
+ # ---------------------------------------------------------------------------
1022
+
1023
+
1024
+ def _all_entities_lf(source: "InputSource") -> pl.LazyFrame:
1025
+ """Lazy ``[e]`` frame: union of unit + node + connection."""
1026
+ return _entity_class_lf(source).select("e").unique()
1027
+
1028
+
1029
+ # ---------------------------------------------------------------------------
1030
+ # Public Param-producing entry points
1031
+ # ---------------------------------------------------------------------------
1032
+
1033
+
1034
+ def p_inflation_op_from_source(source: "InputSource",
1035
+ active_solve: str | None,
1036
+ period_in_use: list[str],
1037
+ period_universe: list[str] | None = None,
1038
+ ) -> "Param | None":
1039
+ """Public entry: ``p_inflation_op[d]`` over ``period_in_use``.
1040
+
1041
+ Lazy port of
1042
+ :func:`._derived_params.p_inflation_op_full_cascade_from_source`.
1043
+ Replaces the workdir read of ``solve_data/p_inflation_factor_operations_yearly.csv``
1044
+ when wired through :func:`apply_npv`.
1045
+ """
1046
+ if not period_in_use:
1047
+ return None
1048
+ if period_universe is None:
1049
+ period_universe = period_in_use
1050
+ factors_lf = _inflation_factors_lf(source, active_solve, period_universe)
1051
+ out = (pl.LazyFrame({"d": period_in_use})
1052
+ .with_columns(alias_to_axis("d", "d"))
1053
+ .join(factors_lf, on="d", how="left")
1054
+ .with_columns(
1055
+ value=pl.col("ops_factor").fill_null(1.0),
1056
+ )
1057
+ .select("d", "value")
1058
+ .sort("d")
1059
+ .collect())
1060
+ if out.height == 0:
1061
+ return None
1062
+ return Param(("d",), out)
1063
+
1064
+
1065
+ def p_ed_fixed_cost_from_source(source: "InputSource",
1066
+ period_with_history: list[str],
1067
+ ) -> "Param | None":
1068
+ """Public entry: ``p_ed_fixed_cost[e, d]`` over ``period_with_history``.
1069
+
1070
+ Replaces the workdir read of ``solve_data/ed_fixed_cost.csv``.
1071
+ Drops zero-valued rows (mirrors the loader's ``filter(value != 0)``).
1072
+ """
1073
+ if not period_with_history:
1074
+ return None
1075
+ all_e_lf = _all_entities_lf(source)
1076
+ pwh_lf = pl.LazyFrame({"d": period_with_history}).with_columns(
1077
+ alias_to_axis("d", "d"))
1078
+ ed_lf = all_e_lf.join(pwh_lf, how="cross")
1079
+ fc_lf = _ed_fixed_cost_raw_lf(source, ed_lf).rename({"fc": "value"})
1080
+ out = (fc_lf
1081
+ .filter(pl.col("value") != 0.0)
1082
+ .select("e", "d", "value")
1083
+ .sort("e", "d")
1084
+ .collect())
1085
+ if out.height == 0:
1086
+ return None
1087
+ return Param(("e", "d"), out)
1088
+
1089
+
1090
+ def ed_entity_annual_discounted_from_source(
1091
+ source: "InputSource",
1092
+ active_solve: str | None,
1093
+ period_invest: list[str],
1094
+ period_in_use: list[str],
1095
+ period_universe: list[str],
1096
+ ) -> "Param | None":
1097
+ """Public entry: ``ed_entity_annual_discounted[e, d]``.
1098
+
1099
+ Returns the full ``entityInvest × period_invest`` frame, including
1100
+ zero-valued rows, to mirror the snapshot CSV produced by
1101
+ :func:`._emit_entity_annual.write_entity_annual_calc_params`
1102
+ (which the loader's ``_read_e_d`` seed retains unfiltered). The
1103
+ loader's ``apply_npv`` only overwrites the seed when this entry
1104
+ returns a non-None frame, so emitting the zero rows is required
1105
+ for the lazy result to match the seed on fixtures where every
1106
+ (e, d) value is zero (e.g. ``work_wind_battery_invest``).
1107
+ """
1108
+ if not period_invest:
1109
+ return None
1110
+ out = (npv_invest_discounted_lf(
1111
+ source, active_solve,
1112
+ period_invest, period_in_use, period_universe)
1113
+ .select("e", "d", "value")
1114
+ .sort("e", "d")
1115
+ .collect())
1116
+ if out.height == 0:
1117
+ return None
1118
+ return Param(("e", "d"), out)
1119
+
1120
+
1121
+ def ed_entity_annual_divest_discounted_from_source(
1122
+ source: "InputSource",
1123
+ active_solve: str | None,
1124
+ period_invest: list[str],
1125
+ period_in_use: list[str],
1126
+ period_universe: list[str],
1127
+ ) -> "Param | None":
1128
+ """Public entry: ``ed_entity_annual_divest_discounted[e, d]``.
1129
+
1130
+ Unfiltered ``entityDivest × period_invest`` — see the rationale on
1131
+ :func:`ed_entity_annual_discounted_from_source`.
1132
+ """
1133
+ if not period_invest:
1134
+ return None
1135
+ out = (npv_divest_discounted_lf(
1136
+ source, active_solve,
1137
+ period_invest, period_in_use, period_universe)
1138
+ .select("e", "d", "value")
1139
+ .sort("e", "d")
1140
+ .collect())
1141
+ if out.height == 0:
1142
+ return None
1143
+ return Param(("e", "d"), out)
1144
+
1145
+
1146
+ def ed_lifetime_fixed_cost_from_source(
1147
+ source: "InputSource",
1148
+ active_solve: str | None,
1149
+ period_with_history: list[str],
1150
+ period_in_use: list[str],
1151
+ period_universe: list[str],
1152
+ ) -> "Param | None":
1153
+ """Public entry: ``ed_lifetime_fixed_cost[e, d]``.
1154
+
1155
+ Unfiltered ``entity × period_with_history`` — see the rationale on
1156
+ :func:`ed_entity_annual_discounted_from_source`.
1157
+ """
1158
+ if not period_with_history:
1159
+ return None
1160
+ out = (lifetime_fixed_cost_invest_lf(
1161
+ source, active_solve,
1162
+ period_with_history, period_in_use, period_universe)
1163
+ .select("e", "d", "value")
1164
+ .sort("e", "d")
1165
+ .collect())
1166
+ if out.height == 0:
1167
+ return None
1168
+ return Param(("e", "d"), out)
1169
+
1170
+
1171
+ def ed_lifetime_fixed_cost_divest_from_source(
1172
+ source: "InputSource",
1173
+ active_solve: str | None,
1174
+ period_invest: list[str],
1175
+ period_in_use: list[str],
1176
+ period_universe: list[str],
1177
+ ) -> "Param | None":
1178
+ """Public entry: ``ed_lifetime_fixed_cost_divest[e, d]``.
1179
+
1180
+ Unfiltered ``entityDivest × period_invest`` — see the rationale on
1181
+ :func:`ed_entity_annual_discounted_from_source`.
1182
+ """
1183
+ if not period_invest:
1184
+ return None
1185
+ out = (lifetime_fixed_cost_divest_lf(
1186
+ source, active_solve,
1187
+ period_invest, period_in_use, period_universe)
1188
+ .select("e", "d", "value")
1189
+ .sort("e", "d")
1190
+ .collect())
1191
+ if out.height == 0:
1192
+ return None
1193
+ return Param(("e", "d"), out)
1194
+
1195
+
1196
+ # ---------------------------------------------------------------------------
1197
+ # Apply boundary
1198
+ # ---------------------------------------------------------------------------
1199
+
1200
+
1201
+ def apply_npv(flex_data: object,
1202
+ source: "InputSource",
1203
+ workdir: Path,
1204
+ *,
1205
+ provider: "object | None" = None) -> None:
1206
+ """Wire the cluster-A NPV helpers into ``flex_data`` (mutates in place).
1207
+
1208
+ Order:
1209
+ 1. ``p_inflation_op`` (depends on years_represented)
1210
+ 2. ``p_ed_fixed_cost`` (depends on period_with_history)
1211
+ 3. ``ed_entity_annual_discounted`` (depends on inv_factor + ed_invest)
1212
+ 4. ``ed_entity_annual_divest_discounted``
1213
+ (depends on inv_factor + ed_divest)
1214
+ 5. ``ed_lifetime_fixed_cost`` (depends on ops_factor + period_with_history)
1215
+ 6. ``ed_lifetime_fixed_cost_divest`` (depends on inv_factor + ed_divest)
1216
+
1217
+ Δ.12b — assignment is unconditional (no silent fall-through to a
1218
+ seed value). Each helper raises on hard errors and returns
1219
+ ``None`` only when the upstream feature is genuinely inactive
1220
+ (no_invest short-circuit, no period_with_history, etc.) — that
1221
+ ``None`` is the explicit "field stays default" signal.
1222
+ """
1223
+ from ._derived_params import (
1224
+ _read_active_solve, _solve_periods, _period_in_use_set,
1225
+ _periodAll_from_source, _read_period_with_history,
1226
+ )
1227
+
1228
+ active_solve = _read_active_solve(workdir, provider=provider)
1229
+ period_in_use = _period_in_use_set(source, active_solve, workdir, provider=provider)
1230
+ period_universe = _periodAll_from_source(source, active_solve, workdir=workdir, provider=provider)
1231
+ period_invest = _solve_periods(source, active_solve, "invest_periods") or []
1232
+ period_with_history = (_read_period_with_history(workdir, provider=provider)
1233
+ or list(period_in_use))
1234
+
1235
+ # 1. p_inflation_op (None == no inflation data → field stays default).
1236
+ flex_data.p_inflation_op = p_inflation_op_from_source(
1237
+ source, active_solve, period_in_use, period_universe)
1238
+
1239
+ # 2. p_ed_fixed_cost — None == no period_with_history (single-solve
1240
+ # fixtures with no historical periods); legitimate "no data" outcome.
1241
+ flex_data.p_ed_fixed_cost = p_ed_fixed_cost_from_source(
1242
+ source, period_with_history)
1243
+
1244
+ # 3-6. NPV / lifetime cascade family. Mirrors the "no_invest"
1245
+ # short-circuit in :mod:`._derived_params.ed_entity_annual_family_from_source`
1246
+ # and :func:`flextool.engine_polars.input._load_invest`: when
1247
+ # neither ed_invest nor ed_divest carries any row, the cluster
1248
+ # outputs are all None (the LP has no v_invest / v_divest
1249
+ # variables that would consume them).
1250
+ ed_invest = getattr(flex_data, "ed_invest_set", None)
1251
+ ed_divest = getattr(flex_data, "ed_divest_set", None)
1252
+ no_invest = (
1253
+ (ed_invest is None or ed_invest.height == 0)
1254
+ and (ed_divest is None or ed_divest.height == 0)
1255
+ )
1256
+ if no_invest or active_solve is None:
1257
+ return
1258
+
1259
+ # Δ.18 — when the override returns None (e.g. synthetic per-sub-solve
1260
+ # ``active_solve`` whose ``invest_periods`` is empty in Spine), preserve
1261
+ # the seed-loaded snapshot CSV value rather than overwriting it. The
1262
+ # override remains authoritative when it returns a non-None Param.
1263
+ def _set_if(field: str, value):
1264
+ if value is not None:
1265
+ setattr(flex_data, field, value)
1266
+
1267
+ _set_if("ed_entity_annual_discounted",
1268
+ ed_entity_annual_discounted_from_source(
1269
+ source, active_solve,
1270
+ period_invest, period_in_use, period_universe))
1271
+
1272
+ _set_if("ed_entity_annual_divest_discounted",
1273
+ ed_entity_annual_divest_discounted_from_source(
1274
+ source, active_solve,
1275
+ period_invest, period_in_use, period_universe))
1276
+
1277
+ _set_if("ed_lifetime_fixed_cost",
1278
+ ed_lifetime_fixed_cost_from_source(
1279
+ source, active_solve,
1280
+ period_with_history, period_in_use, period_universe))
1281
+
1282
+ _set_if("ed_lifetime_fixed_cost_divest",
1283
+ ed_lifetime_fixed_cost_divest_from_source(
1284
+ source, active_solve,
1285
+ period_invest, period_in_use, period_universe))
1286
+
1287
+
1288
+ __all__ = [
1289
+ "period_walk_iterator",
1290
+ "p_inflation_op_from_source",
1291
+ "p_ed_fixed_cost_from_source",
1292
+ "ed_entity_annual_discounted_from_source",
1293
+ "ed_entity_annual_divest_discounted_from_source",
1294
+ "ed_lifetime_fixed_cost_from_source",
1295
+ "ed_lifetime_fixed_cost_divest_from_source",
1296
+ "apply_npv",
1297
+ ]