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,1348 @@
1
+ """Native cascade driver.
2
+
3
+ This module owns the per-solve cascade loop. :func:`native_run_model`
4
+ is invoked by :func:`._orchestration._drive_cascade`.
5
+
6
+ Design decisions
7
+ ----------------
8
+
9
+ * **Cascade loop is native; inner preprocessing is not (yet).**
10
+ Scope: own the cascade walk and inline the solve-tree expansion /
11
+ stochastic branching / per-solve setup; not re-port
12
+ ``preprocessing.solve_time`` or the ``solve_writers`` module. Those
13
+ remain authoritative and are still called as functions — the
14
+ ``_native_leaf_set_override()`` context (wired by
15
+ :func:`._orchestration._drive_cascade`) intercepts the already-native
16
+ preprocessing helpers.
17
+ * **Native solve-tree expansion** via
18
+ :class:`flextool.engine_polars._recursive_solve.RecursiveSolveBuilder`
19
+ + :class:`._stochastic.StochasticSolver`.
20
+ * **Direct handoff construction.** The post-solve ``SolveHandoff`` is
21
+ built by ``build_handoff_from_solution`` directly from the polar_high
22
+ ``Solution`` object — no CSV round-trip.
23
+ * **Optional state fields tolerated.** Native :class:`RunnerState`
24
+ may lack ``timing_recorder`` / ``auto_scale``. Callers that consume
25
+ those guard with ``getattr(state, name, default)``.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import copy
30
+ import csv
31
+ import os
32
+ import time
33
+ from collections import defaultdict
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Imports. Per Phase 3 scope, we depend on legacy preprocessing /
37
+ # solve_writer modules directly — Phase 2 covers the override hook that
38
+ # intercepts the already-ported helpers. Phases 4+ will retire the
39
+ # remaining writers.
40
+ # ---------------------------------------------------------------------------
41
+
42
+ from flextool.engine_polars._blocks import emit_block_data_for_solve
43
+ # Step 2.5 — legacy preprocessing package deleted (item 15). The per-
44
+ # solve orchestrator now lives natively at
45
+ # :mod:`flextool.engine_polars._emit_solve_time`.
46
+ from flextool.engine_polars import (
47
+ _emit_solve_time as preprocessing_solve_time,
48
+ )
49
+ from flextool.engine_polars._solve_state import (
50
+ FlexToolConfigError,
51
+ FlexToolSolveError,
52
+ )
53
+ # Step 2.5 — solve_writers calls now resolve to the native polars
54
+ # implementations. The legacy disk-writing module remains in the tree
55
+ # but is no longer called from the cascade.
56
+ from flextool.engine_polars import _emit_solve_writers as solve_writers
57
+ from flextool.engine_polars import _provider_keys
58
+ from flextool.engine_polars import _provider_translators
59
+
60
+ from flextool.engine_polars._flex_data_provider import FlexDataProvider
61
+ from flextool.engine_polars._solve_state import compute_level_key
62
+
63
+ # Native solve-tree expansion + stochastic branching + timeline helpers.
64
+ from flextool.engine_polars._recursive_solve import (
65
+ ParentSolveInfo,
66
+ RecursiveSolveBuilder,
67
+ )
68
+ from flextool.engine_polars._stochastic import StochasticSolver
69
+ from flextool.engine_polars._timeline import (
70
+ get_active_time,
71
+ make_period_block,
72
+ separate_period_and_timeseries_data,
73
+ )
74
+
75
+
76
+ # Map from RP-flavoured storage_binding_method to its non-RP equivalent.
77
+ # Used by :func:`_downgrade_rp_methods_for_non_rp_solve` to strip the
78
+ # ``_blended_weights`` suffix when a solve's active timeset carries no
79
+ # ``representative_period_weights`` entry. The same storage entity is
80
+ # allowed to drive an RP investment solve AND a chronological dispatch
81
+ # solve back-to-back; per-solve downgrade keeps both paths alive.
82
+ _RP_METHOD_DOWNGRADE: dict[str, str] = {
83
+ "bind_within_solve_blended_weights": "bind_within_solve",
84
+ "bind_within_period_blended_weights": "bind_within_period",
85
+ "bind_forward_only_blended_weights": "bind_forward_only",
86
+ }
87
+
88
+
89
+ def _downgrade_rp_methods_for_non_rp_solve(
90
+ *, solve, complete_solve_name, roll_index,
91
+ active_timeset_names, rp_weights, provider, logger,
92
+ ) -> None:
93
+ """Silently downgrade RP storage-binding methods on non-RP solves.
94
+
95
+ Replaces the Phase 5 strict check. When the solve's active timeset
96
+ has NO entry in ``state.timeline.rp_weights``, rewrite the per-solve
97
+ Provider's ``input/node__storage_binding_method`` frame in place,
98
+ mapping every ``*_blended_weights`` row to its non-RP equivalent
99
+ (see :data:`_RP_METHOD_DOWNGRADE`). When at least one active
100
+ timeset DOES have RP weights, do nothing — the RP path is correct.
101
+
102
+ The rewrite touches ONLY the per-solve provider (in-memory); the
103
+ on-disk DB and any upstream input CSVs stay unchanged. Downstream
104
+ in this same solve, ``preprocessing_solve_time.run`` will derive
105
+ ``solve_data/node__storage_binding_method`` from the rewritten
106
+ ``input/...`` key, so all later consumers see the downgraded values
107
+ for THIS solve only.
108
+
109
+ Args:
110
+ solve: per-iter solve name (used in log line).
111
+ complete_solve_name: fully-qualified solve name (debug aid).
112
+ roll_index: integer roll within ``complete_solve_name`` (debug aid).
113
+ active_timeset_names: list of timeset names active in this solve.
114
+ rp_weights: ``state.timeline.rp_weights`` dict (timeset -> weights).
115
+ provider: the per-solve :class:`FlexDataProvider`.
116
+ logger: solve logger. One info-level line is emitted per
117
+ (old, new) downgrade-mapping that fires.
118
+ """
119
+ # Any active timeset has RP weights → RP path is correct, no-op.
120
+ for ts_name in active_timeset_names:
121
+ if ts_name in rp_weights:
122
+ return
123
+
124
+ key = "input/node__storage_binding_method"
125
+ if not provider.has(key):
126
+ return # no per-solve binding-method frame at all → nothing to do
127
+ import polars as pl
128
+ sbm = provider.get(key)
129
+ if sbm.height == 0:
130
+ return
131
+ if "storage_binding_method" in sbm.columns:
132
+ method_col = "storage_binding_method"
133
+ elif "method" in sbm.columns:
134
+ method_col = "method"
135
+ else:
136
+ return # malformed frame; let downstream loaders surface it
137
+
138
+ # Count downgrades per (old, new) pair so the log line is precise.
139
+ per_pair_counts: dict[tuple[str, str], int] = {}
140
+ for old, new in _RP_METHOD_DOWNGRADE.items():
141
+ n = (sbm.filter(pl.col(method_col) == old)
142
+ .select("node")
143
+ .unique()
144
+ .height)
145
+ if n > 0:
146
+ per_pair_counts[(old, new)] = n
147
+ if not per_pair_counts:
148
+ return # nothing to downgrade
149
+
150
+ # In-place rewrite via a single replace_strict expression.
151
+ sbm_new = sbm.with_columns(
152
+ pl.col(method_col).replace_strict(
153
+ _RP_METHOD_DOWNGRADE, default=pl.col(method_col),
154
+ ).alias(method_col),
155
+ )
156
+ provider.put(key, sbm_new)
157
+
158
+ ts_summary = (
159
+ ", ".join(active_timeset_names) if active_timeset_names else "(none)"
160
+ )
161
+ for (old, new), n in per_pair_counts.items():
162
+ logger.info(
163
+ f"Solve '{solve}' has no representative_period_weights "
164
+ f"for active timeset {ts_summary}; downgrading {n} node(s) "
165
+ f"from {old} to {new}."
166
+ )
167
+
168
+
169
+ def native_run_model(state, solver) -> int:
170
+ """Drive the per-solve cascade natively.
171
+
172
+ Walks the solve tree, applies stochastic branching, writes the
173
+ per-solve inputs the solver needs (via ``solve_writers`` /
174
+ ``preprocessing.solve_time`` — both intercepted where native ports
175
+ exist by the override hook in ``_orchestration._drive_cascade``),
176
+ and invokes the per-solve callback ``solver.run(complete_solve_name)``.
177
+
178
+ Args:
179
+ state: ``RunnerState`` carrier — paths, solve config, timeline,
180
+ handoffs, logger. Either the flextool ``RunnerState`` or
181
+ the native :class:`flextool.engine_polars._solve_state.RunnerState`
182
+ shape; optional fields (``timing_recorder``, ``auto_scale``)
183
+ are tolerated via ``getattr``.
184
+ solver: A :class:`SolverRunner` subclass. In the native cascade
185
+ this is ``_PolarHighCascadeSolver`` defined inside
186
+ :func:`._orchestration._drive_cascade`.
187
+
188
+ Returns:
189
+ 0 on success.
190
+
191
+ Raises:
192
+ FlexToolConfigError: configuration errors.
193
+ FlexToolSolveError: any per-solve non-zero exit status.
194
+ """
195
+ active_time_lists: dict = {}
196
+ jump_lists: dict = {}
197
+ solve_period_history: defaultdict[str, list] = defaultdict(list)
198
+ fix_storage_time_lists: dict = {}
199
+ realized_time_lists: dict = {}
200
+ complete_solve: dict = {}
201
+ parent_roll: dict = {}
202
+ period__branch_lists: dict = {}
203
+ branch_start_time_lists: defaultdict = defaultdict()
204
+ all_solves: list = []
205
+
206
+ timer = time.perf_counter()
207
+
208
+ wf = state.paths.work_folder
209
+ for sub in ("solve_data", "output_raw", "output_plots"):
210
+ try:
211
+ os.mkdir(wf / sub)
212
+ except FileExistsError:
213
+ state.logger.debug(f"{sub} folder existed")
214
+
215
+ if not state.solve.model_solve:
216
+ message = (
217
+ "No model. Make sure the 'model' class defines solves [Array]."
218
+ )
219
+ state.logger.error(message)
220
+ raise FlexToolConfigError(message)
221
+ solves = next(iter(state.solve.model_solve.values()))
222
+ if not solves:
223
+ message = "No solves in model."
224
+ state.logger.error(message)
225
+ raise FlexToolConfigError(message)
226
+
227
+ # ------------------------------------------------------------------
228
+ # 1. Expand the solve tree (rolling / nested / stochastic).
229
+ # ------------------------------------------------------------------
230
+ solve_builder = RecursiveSolveBuilder(state)
231
+ for solve in solves:
232
+ parent_info = ParentSolveInfo(solve=None, roll=None)
233
+ result = solve_builder.define_solve_recursive(
234
+ solve, parent_info, None, None, -1,
235
+ )
236
+ all_solves += result.solves
237
+ complete_solve.update(result.complete_solves)
238
+ parent_roll.update(result.parent_roll_lists)
239
+ active_time_lists.update(result.active_time_lists)
240
+ fix_storage_time_lists.update(result.fix_storage_time_lists)
241
+ realized_time_lists.update(copy.deepcopy(result.realized_time_lists))
242
+
243
+ # NOTE: the realized-dispatch overlap resolution (last-wins) runs
244
+ # AFTER the stochastic pass below (step 3.5), where
245
+ # ``realized_time_lists`` is authoritative — the stochastic pass
246
+ # re-derives it.
247
+
248
+ # ------------------------------------------------------------------
249
+ # 2. Per-real-solve period history accumulation (O(N) instead of O(N²)).
250
+ # ------------------------------------------------------------------
251
+ cumulative_contributions: list[tuple[str, float]] = []
252
+ cumulative_period_names: set[str] = set()
253
+
254
+ for solve in state.solve.real_solves:
255
+ timeset_periods = {
256
+ pt[0] for pt in state.solve.timesets_used_by_solves[solve]
257
+ }
258
+ state.solve.solve_period_years_represented[solve] = [
259
+ py for py in state.solve.solve_period_years_represented[solve]
260
+ if py[0] in timeset_periods
261
+ ]
262
+ history_period_names: set[str] = set()
263
+ for period_name, years in cumulative_contributions:
264
+ if period_name not in history_period_names:
265
+ solve_period_history[solve].append((period_name, years))
266
+ history_period_names.add(period_name)
267
+ for period__year in state.solve.solve_period_years_represented[solve]:
268
+ if period__year[0] not in history_period_names:
269
+ solve_period_history[solve].append(
270
+ (period__year[0], period__year[1])
271
+ )
272
+ history_period_names.add(period__year[0])
273
+ # Default to one year per period when years_represented is empty.
274
+ if not state.solve.solve_period_years_represented[solve]:
275
+ for period__timeset in state.solve.timesets_used_by_solves[solve]:
276
+ if period__timeset[0] not in history_period_names:
277
+ solve_period_history[solve].append(
278
+ (period__timeset[0], 1)
279
+ )
280
+ history_period_names.add(period__timeset[0])
281
+ period_dict_names = {
282
+ t[0] for t in (
283
+ state.solve.realized_periods.get(solve, [])
284
+ + state.solve.invest_periods.get(solve, [])
285
+ + state.solve.fix_storage_periods.get(solve, [])
286
+ + state.solve.realized_invest_periods.get(solve, [])
287
+ )
288
+ }
289
+ for period in state.solve.solve_period_years_represented[solve]:
290
+ if (
291
+ period[0] in period_dict_names
292
+ and period[0] not in cumulative_period_names
293
+ ):
294
+ cumulative_contributions.append((period[0], period[1]))
295
+ cumulative_period_names.add(period[0])
296
+
297
+ # ------------------------------------------------------------------
298
+ # 3. Stochastic branching expansion.
299
+ # ------------------------------------------------------------------
300
+ stochastic_solver = StochasticSolver(state)
301
+ (
302
+ period__branch_lists,
303
+ solve_branch__time_branch_lists,
304
+ active_time_lists,
305
+ jump_lists,
306
+ fix_storage_time_lists,
307
+ realized_time_lists,
308
+ branch_start_time_lists,
309
+ ) = stochastic_solver.create_stochastic_periods(
310
+ state.solve.stochastic_branches,
311
+ all_solves,
312
+ complete_solve,
313
+ active_time_lists,
314
+ fix_storage_time_lists,
315
+ realized_time_lists,
316
+ )
317
+
318
+ # ------------------------------------------------------------------
319
+ # 3.5 Resolve realized-dispatch overlap across solves (LAST-WINS).
320
+ #
321
+ # ``realized_time_lists`` is authoritative only HERE — the stochastic
322
+ # pass above re-derives it. When two solves realize the same
323
+ # (period, timestep), the LATER solve in cascade order wins: it keeps
324
+ # the cell and the earlier solve's claim is dropped. This is the
325
+ # maintainer-directed resolution ("the lower level / later solve
326
+ # wins") and makes each solve's persisted realized slice disjoint, so
327
+ # the downstream multi-solve output union stays a clean concat.
328
+ #
329
+ # Implementation: iterate solves in REVERSE cascade order so a later
330
+ # solve is seen first and claims its (period, timestep) pairs. When
331
+ # an earlier solve reaches a timestep already claimed by a later
332
+ # solve, truncate it (and the rest of that period's realized window —
333
+ # realized windows are contiguous per period, so once one cell is
334
+ # taken the remainder overlaps too). Drops the resolved cells from
335
+ # ``realized_time_lists`` itself, which then flows into
336
+ # ``solve_data/realized_dispatch.csv`` and thus each roll's
337
+ # ``flex_data.realized_dispatch`` that drives per-roll persistence.
338
+ # ------------------------------------------------------------------
339
+ already_realized_timesteps: dict[str, set[str]] = {}
340
+ for solve, realized_time_list in reversed(realized_time_lists.items()):
341
+ for period, timesteps in list(realized_time_list.items()):
342
+ if period not in already_realized_timesteps:
343
+ already_realized_timesteps[period] = set()
344
+ for i, entry in enumerate(timesteps):
345
+ # Hitting a cell already owned by a later solve means the
346
+ # rest of this period's window overlaps too — truncate.
347
+ if entry.timestep in already_realized_timesteps[period]:
348
+ del realized_time_lists[solve][period][i:]
349
+ break
350
+ already_realized_timesteps[period].add(entry.timestep)
351
+ if not realized_time_lists[solve][period]:
352
+ del realized_time_lists[solve][period]
353
+
354
+ for solve in active_time_lists.keys():
355
+ for period in active_time_lists[solve]:
356
+ if (period, period) in period__branch_lists[solve] and not any(
357
+ period == sublist[0]
358
+ for sublist in solve_period_history[complete_solve[solve]]
359
+ ):
360
+ message = (
361
+ f"The years_represented is defined, but not to all "
362
+ f"of the periods ({period}) in the solve"
363
+ )
364
+ state.logger.error(message)
365
+ raise FlexToolConfigError(message)
366
+
367
+ timing = time.perf_counter() - timer
368
+ state.logger.debug(f"--- Pre-processing of data: {timing:.4f} seconds ---")
369
+ timing_recorder = getattr(state, "timing_recorder", None)
370
+ if timing_recorder is not None:
371
+ timing_recorder.record(
372
+ "preprocessing_global",
373
+ seconds=timing,
374
+ t_start=time.perf_counter() - timing,
375
+ )
376
+ timer = timer + timing
377
+
378
+ # Step 2.5-E Phase A — Provider-routed pdt -> {pd, pt} split. The
379
+ # cascade-input Provider on ``state`` already carries the seeded
380
+ # ``input/pdt_commodity`` / ``input/pdt_group`` frames; the two
381
+ # derived shards land back on the Provider under the same parent-
382
+ # qualified keys (``input/pd_*``, ``input/pt_*``).
383
+ cascade_input_provider_seed: "FlexDataProvider | None" = getattr(
384
+ state, "cascade_input_provider", None,
385
+ )
386
+ if cascade_input_provider_seed is None:
387
+ cascade_input_provider_seed = FlexDataProvider()
388
+ state.cascade_input_provider = cascade_input_provider_seed
389
+ separate_period_and_timeseries_data(
390
+ state.timeline.timelines,
391
+ state.solve.timesets_used_by_solves,
392
+ provider=cascade_input_provider_seed,
393
+ work_folder=wf,
394
+ )
395
+
396
+ # Step 1-f — cascade-wide Provider seeded once and re-used for every
397
+ # sub-solve's pre-populated frames. Captures the
398
+ # ``solve_writers.emit_timesets`` output (``input/timesets_in_use.csv``
399
+ # + ``input/timesets__timeline.csv``) so the per-sub-solve preprocessing
400
+ # readers can find them via ``provider.get`` without touching disk.
401
+ cascade_input_provider: "FlexDataProvider | None" = getattr(
402
+ state, "cascade_input_provider", None,
403
+ )
404
+ if cascade_input_provider is None:
405
+ cascade_input_provider = FlexDataProvider()
406
+ state.cascade_input_provider = cascade_input_provider
407
+
408
+ # Solve-loop-invariant timesets — hoisted out of the per-solve loop.
409
+ solve_writers.emit_timesets(
410
+ state.solve.timesets_used_by_solves,
411
+ state.timeline.timesets__timeline,
412
+ provider=cascade_input_provider,
413
+ )
414
+
415
+ # ------------------------------------------------------------------
416
+ # 5. Per-solve loop.
417
+ # ------------------------------------------------------------------
418
+ first = True
419
+ previous_complete_solve = None
420
+ last_captured_solve: str | None = None
421
+ cached_complete_active_time_lists: dict = {}
422
+
423
+ # Pre-compute per-iter level keys. Same-key consecutive iters are
424
+ # rolling continuations of one base solve; a key change marks a
425
+ # sequential / nested level transition. Used to gate phase-progress
426
+ # output: within-group rolling iters emit only the "Solve start"
427
+ # marker, while the last iter of each group emits the four phase
428
+ # checkpoints whose deltas aggregate across the whole group.
429
+ level_keys = [
430
+ compute_level_key(
431
+ solve_name=s,
432
+ complete_solve_name=complete_solve[s],
433
+ solve_config=state.solve,
434
+ timeline_config=state.timeline,
435
+ )
436
+ for s in all_solves
437
+ ]
438
+ # Phase 2 — expose the full pre-planned sequence of level_keys onto
439
+ # ``state`` so the per-solve callback (``_PolarHighCascadeSolver.run``)
440
+ # can answer the "is there another solve of THIS level coming?"
441
+ # question used by the warm-path per-level retention slim. Empty
442
+ # tuple is safe (no upcoming iter exists for the empty case).
443
+ state._all_level_keys = tuple(level_keys)
444
+
445
+ for i, solve in enumerate(all_solves):
446
+ timer_in_solve = time.perf_counter()
447
+
448
+ # Between-solves memory snapshot — fires before every roll/solve
449
+ # (i > 0). The previous iter now always emits the four phase
450
+ # checkpoints (ending in ``Outputs written``), so this shows
451
+ # what's left in memory at each solve boundary and cross-solve
452
+ # retention is visible per roll in the standard log. The Δ
453
+ # columns are taken vs. the previous emitted line (``Outputs
454
+ # written``), so positive Δrss here is memory the previous
455
+ # solve's outputs phase did not release.
456
+ _memrec_iter = getattr(state, "_memory_recorder", None)
457
+ if (
458
+ i > 0
459
+ and _memrec_iter is not None
460
+ ):
461
+ try:
462
+ _memrec_iter.checkpoint(
463
+ "solve_cleanup", state.logger,
464
+ user_label="Solve cleanup",
465
+ )
466
+ except Exception:
467
+ pass
468
+
469
+ # Per-sub-solve marker — plain text line (no timer/memory).
470
+ # Always printed so the user can see roll progression even
471
+ # when the four phase checkpoints are suppressed for within-
472
+ # group rolling iters.
473
+ if _memrec_iter is not None and getattr(_memrec_iter, "verbose", True):
474
+ try:
475
+ print(
476
+ f"Solve start: {complete_solve[solve]}, "
477
+ f"{i + 1}/{len(all_solves)}",
478
+ flush=True,
479
+ )
480
+ except OSError:
481
+ pass
482
+
483
+ state.logger.debug(
484
+ f"Creating timelines for solve {solve} ({i})"
485
+ )
486
+ cs = complete_solve[solve]
487
+
488
+ # Per-level Provider detection (Design A, step A2). Below we
489
+ # reuse a single FlexDataProvider per distinct level_key so
490
+ # iters at the same level share one Provider instance.
491
+ _level_key = level_keys[i]
492
+ # level_key is debug-only output (verbose mem mode):
493
+ # ``runner.state.logger`` is forced to ERROR level by the
494
+ # orchestration driver, so the regular ERROR-level print would
495
+ # always escape. Gate behind the same env var that surfaces
496
+ # the full mem-checkpoint trace.
497
+ if os.environ.get("FLEXTOOL_MEMORY_VERBOSE") == "1":
498
+ state.logger.error(
499
+ "level_key for solve %r (complete=%r): %r",
500
+ solve, complete_solve[solve], _level_key,
501
+ )
502
+
503
+ # Per-level boundary (Design A, step A3): when the level_key
504
+ # changes from the previous iter, drop any warm-LP carry-over
505
+ # on the cascade solver so we don't accidentally reuse
506
+ # structures keyed on the prior level's matrix shape. The
507
+ # warm-LP fingerprint check inside ``_PolarHighCascadeSolver.run``
508
+ # will ALSO null these on shape change (a level transition
509
+ # always changes the FlexData shape in practice), but doing
510
+ # it explicitly at the level boundary makes the intent
511
+ # explicit and gives subprocess-per-chain work a clean
512
+ # lifecycle hook.
513
+ _last_level_key = getattr(state, "_last_level_key_seen", None)
514
+ if _last_level_key is not None and _last_level_key != _level_key:
515
+ if hasattr(solver, "_warm_problem"):
516
+ solver._warm_problem = None
517
+ if hasattr(solver, "_prior_data"):
518
+ solver._prior_data = None
519
+ state._last_level_key_seen = _level_key
520
+
521
+ if cs not in cached_complete_active_time_lists:
522
+ cached_complete_active_time_lists[cs] = get_active_time(
523
+ cs,
524
+ state.solve.timesets_used_by_solves,
525
+ state.timeline.timeset_durations,
526
+ state.timeline.timelines,
527
+ state.timeline.timesets__timeline,
528
+ )
529
+ complete_active_time_lists = cached_complete_active_time_lists[cs]
530
+
531
+ # Combined period/timeset list including history periods.
532
+ period__timesets_with_history = list(
533
+ state.solve.timesets_used_by_solves[complete_solve[solve]]
534
+ )
535
+ current_periods = {pt[0] for pt in period__timesets_with_history}
536
+ current_timeset = (
537
+ period__timesets_with_history[0][1]
538
+ if period__timesets_with_history else None
539
+ )
540
+ for history_period, _ in solve_period_history[complete_solve[solve]]:
541
+ if history_period not in current_periods:
542
+ if current_timeset:
543
+ period__timesets_with_history.append(
544
+ (history_period, current_timeset)
545
+ )
546
+ current_periods.add(history_period)
547
+
548
+ # Per-level Provider (Design A, step A2). Two consecutive
549
+ # iters with the same ``_level_key`` reuse the same Provider —
550
+ # e.g. the 72 dispatch rolls on the multi-invest fixture all
551
+ # share one Provider, while each of the 4 invest sub-solves
552
+ # (distinct period windows -> distinct keys) gets its own.
553
+ # On level transition, build a fresh Provider seeded from the
554
+ # cascade-input frames; per-iter writers below overwrite their
555
+ # own keys in place across iters at the same level.
556
+ if not hasattr(state, "_level_providers"):
557
+ state._level_providers = {}
558
+ sub_solve_provider = state._level_providers.get(_level_key)
559
+ if sub_solve_provider is None:
560
+ sub_solve_provider = FlexDataProvider()
561
+ # Seed cascade-wide ``input/*.csv`` frames so per-iter
562
+ # readers find them via ``provider.get``
563
+ # (e.g. ``input/timesets_in_use.csv``).
564
+ for _key, _frame in cascade_input_provider.items():
565
+ sub_solve_provider.put(_key, _frame)
566
+ state._level_providers[_level_key] = sub_solve_provider
567
+ # else: existing Provider — already seeded; per-iter writers
568
+ # below will overwrite their own keys in place.
569
+ # Resolve nesting parent's complete-solve name; reused below by
570
+ # the parent-handoff translator call (see Phase 4.1e).
571
+ _parent_solve_for_carriers = parent_roll.get(solve)
572
+ _parent_complete_for_carriers = (
573
+ complete_solve.get(_parent_solve_for_carriers)
574
+ if _parent_solve_for_carriers else None
575
+ )
576
+
577
+ # S1-g-3 — expose the per-sub-solve Provider to writer entry
578
+ # points BEFORE preprocessing runs, so native writers threaded
579
+ # with ``provider=`` (via :func:`_emit_solve_time.run`) can
580
+ # fetch it from ``state.current_provider``. Writers not yet
581
+ # threaded still resolve their reads via the Provider-as-seed
582
+ # bridge installed above; Step 2 deletes both paths.
583
+ state.current_provider = sub_solve_provider
584
+
585
+ # Memory checkpoints — fire only on the first sub-solve so the
586
+ # output stays readable on multi-roll scenarios. Used to
587
+ # attribute the pre-load_flextool preprocessing-chain memory
588
+ # spike across writer groups.
589
+ _mem_cp = None
590
+ if i == 0:
591
+ _memrec_native = getattr(state, "_memory_recorder", None)
592
+ if _memrec_native is not None:
593
+ def _mem_cp(label: str, user_label: str,
594
+ _rec=_memrec_native, _log=state.logger) -> None:
595
+ _rec.checkpoint(label, _log, user_label=user_label)
596
+ if _mem_cp is not None:
597
+ _mem_cp("prep_seeded",
598
+ "prep: provider seeded")
599
+
600
+ solve_writers.emit_full_timelines(
601
+ state.timeline.stochastic_timesteps[solve],
602
+ period__timesets_with_history,
603
+ state.timeline.timesets__timeline,
604
+ state.timeline.timelines,
605
+ str(wf / "solve_data/steps_in_timeline.csv"),
606
+ provider=sub_solve_provider,
607
+ )
608
+ solve_writers.emit_active_timelines(
609
+ active_time_lists[solve],
610
+ str(wf / "solve_data/steps_in_use.csv"),
611
+ provider=sub_solve_provider,
612
+ )
613
+ solve_writers.emit_active_timelines(
614
+ complete_active_time_lists,
615
+ str(wf / "solve_data/steps_complete_solve.csv"),
616
+ complete=True,
617
+ provider=sub_solve_provider,
618
+ )
619
+ solve_writers.emit_step_jump(
620
+ jump_lists[solve],
621
+ provider=sub_solve_provider,
622
+ )
623
+ pb_time, pb_succ = make_period_block(active_time_lists[solve])
624
+ solve_writers.emit_period_block(
625
+ pb_time, pb_succ,
626
+ provider=sub_solve_provider,
627
+ )
628
+
629
+ if _mem_cp is not None:
630
+ _mem_cp("prep_timeline_writers_done",
631
+ "prep: timeline writers done")
632
+
633
+ state.logger.debug("Creating period data")
634
+ solve_writers.emit_period_years(
635
+ period__branch_lists[solve],
636
+ solve_period_history[complete_solve[solve]],
637
+ str(wf / "solve_data/period_with_history.csv"),
638
+ provider=sub_solve_provider,
639
+ )
640
+ solve_writers.emit_periods(
641
+ complete_solve[solve],
642
+ state.solve.realized_invest_periods,
643
+ str(wf / "solve_data/realized_invest_periods_of_current_solve.csv"),
644
+ provider=sub_solve_provider,
645
+ )
646
+ # If realized_invest_periods is empty but both invest_periods and
647
+ # realized_periods are defined, fall back to realized_periods.
648
+ if (
649
+ not state.solve.realized_invest_periods[complete_solve[solve]]
650
+ and state.solve.invest_periods[complete_solve[solve]]
651
+ and state.solve.realized_periods[complete_solve[solve]]
652
+ ):
653
+ solve_writers.emit_periods(
654
+ complete_solve[solve],
655
+ state.solve.realized_periods,
656
+ str(
657
+ wf
658
+ / "solve_data/realized_invest_periods_of_current_solve.csv"
659
+ ),
660
+ provider=sub_solve_provider,
661
+ )
662
+ sub_solve_provider.put(
663
+ _provider_keys.SOLVE_DATA_INVEST_PERIODS_OF_CURRENT_SOLVE,
664
+ solve_writers.derive_periods(
665
+ complete_solve[solve], state.solve.invest_periods,
666
+ ),
667
+ )
668
+
669
+ years_rep = state.solve.solve_period_years_represented[
670
+ complete_solve[solve]
671
+ ]
672
+ if not years_rep:
673
+ years_rep = [
674
+ (pt[0], 1)
675
+ for pt in state.solve.timesets_used_by_solves[
676
+ complete_solve[solve]
677
+ ]
678
+ ]
679
+ solve_writers.emit_years_represented(
680
+ period__branch_lists[solve],
681
+ years_rep,
682
+ str(wf / "solve_data/p_years_represented.csv"),
683
+ provider=sub_solve_provider,
684
+ )
685
+ solve_writers.emit_period_years(
686
+ period__branch_lists[solve],
687
+ years_rep,
688
+ str(wf / "solve_data/p_discount_years.csv"),
689
+ provider=sub_solve_provider,
690
+ )
691
+ solve_writers.emit_current_solve(
692
+ solve, str(wf / "solve_data/solve_current.csv"),
693
+ provider=sub_solve_provider,
694
+ )
695
+
696
+ if _mem_cp is not None:
697
+ _mem_cp("prep_period_writers_done",
698
+ "prep: period writers done")
699
+
700
+ # ---- LP scaling: emit user-set row-scaling state and the
701
+ # header-only objective-scaling CSV. ``analyze_solve`` runs
702
+ # post-solve from ``_orchestration.py`` for reporting (Γ-scale);
703
+ # auto-row-scaling and objective auto-scaling were removed in
704
+ # Δ.22 along with the ``--auto-scale`` CLI flag.
705
+ solve_writers.emit_p_use_row_scaling(
706
+ solve,
707
+ state.solve.use_row_scaling,
708
+ str(wf / "solve_data/p_use_row_scaling.csv"),
709
+ provider=sub_solve_provider,
710
+ )
711
+ solve_writers.emit_scale_the_objective_header_only(
712
+ wf / "solve_data",
713
+ provider=sub_solve_provider,
714
+ )
715
+
716
+ if _mem_cp is not None:
717
+ _mem_cp("prep_scaling_done", "prep: scaling done")
718
+
719
+ solve_writers.emit_first_steps(
720
+ active_time_lists[solve],
721
+ str(wf / "solve_data/first_timesteps.csv"),
722
+ provider=sub_solve_provider,
723
+ )
724
+ solve_writers.emit_last_steps(
725
+ active_time_lists[solve],
726
+ str(wf / "solve_data/last_timesteps.csv"),
727
+ provider=sub_solve_provider,
728
+ )
729
+
730
+ state.logger.debug("Create realized timeline")
731
+ solve_writers.emit_realized_dispatch(
732
+ realized_time_lists[solve],
733
+ complete_solve[solve],
734
+ state.solve.realized_periods.get(complete_solve[solve], []),
735
+ provider=sub_solve_provider,
736
+ )
737
+ solve_writers.emit_fix_storage_timesteps(
738
+ fix_storage_time_lists[solve],
739
+ complete_solve[solve],
740
+ state.solve.fix_storage_periods.get(complete_solve[solve], []),
741
+ provider=sub_solve_provider,
742
+ )
743
+ solve_writers.emit_delayed_durations(
744
+ active_time_lists[solve],
745
+ complete_solve[solve],
746
+ state.solve.delay_durations,
747
+ provider=sub_solve_provider,
748
+ )
749
+
750
+ if _mem_cp is not None:
751
+ _mem_cp("prep_step_writers_done",
752
+ "prep: step + realized + fix_storage + delayed writers done")
753
+
754
+ state.logger.debug("Possible stochastics")
755
+ solve_writers.emit_branch__period_relationship(
756
+ period__branch_lists[solve],
757
+ str(wf / "solve_data/period__branch.csv"),
758
+ provider=sub_solve_provider,
759
+ )
760
+ solve_writers.emit_branch_weights_and_map(
761
+ complete_solve[solve],
762
+ active_time_lists[solve],
763
+ solve_branch__time_branch_lists[solve],
764
+ branch_start_time_lists[solve],
765
+ period__branch_lists[solve],
766
+ state.solve.stochastic_branches,
767
+ provider=sub_solve_provider,
768
+ )
769
+ solve_writers.emit_first_and_last_periods(
770
+ active_time_lists[solve],
771
+ state.solve.timesets_used_by_solves[complete_solve[solve]],
772
+ period__branch_lists[solve],
773
+ provider=sub_solve_provider,
774
+ )
775
+
776
+ if _mem_cp is not None:
777
+ _mem_cp("prep_branch_writers_done",
778
+ "prep: branch + first_and_last_periods writers done")
779
+
780
+ # ---- Storage fixing from upper level ----
781
+ if (
782
+ [complete_solve[solve]] in state.solve.contains_solves.values()
783
+ and complete_solve[parent_roll[solve]] in state.solve.fix_storage_periods
784
+ ):
785
+ storage_fix_values_exist = True
786
+ else:
787
+ storage_fix_values_exist = False
788
+
789
+ if storage_fix_values_exist:
790
+ state.logger.info("Nested timeline matching")
791
+ matching_map = stochastic_solver.write_timeline_matching_map(
792
+ active_time_lists[parent_roll[solve]],
793
+ active_time_lists[solve],
794
+ complete_solve[parent_roll[solve]],
795
+ complete_solve[solve],
796
+ period__branch_lists[solve],
797
+ )
798
+ with open(
799
+ wf / "solve_data/timeline_matching_map.csv", "w", newline=""
800
+ ) as realfile:
801
+ writer = csv.writer(realfile)
802
+ writer.writerow(["period", "step", "upper_step"])
803
+ for (period, step), upper_step in matching_map.items():
804
+ writer.writerow([period, step, upper_step])
805
+ else:
806
+ with open(wf / "solve_data/timeline_matching_map.csv", "w") as realfile:
807
+ realfile.write("period,step,upper_step\n")
808
+
809
+ # Aggregate timeseries when step size changes between solves.
810
+ if previous_complete_solve != complete_solve[solve]:
811
+ state.logger.debug(
812
+ "Aggregating timeline and parameters for the new step size"
813
+ )
814
+ # Step 2.5-E Phase C — route averaged-timeseries reads /
815
+ # writes through the cascade-input Provider so the
816
+ # ``solve_data/pt_*`` frames persist across every sub-solve
817
+ # belonging to the same ``complete_solve``. Legacy code
818
+ # relied on the on-disk ``solve_data/pt_*.csv`` files
819
+ # remaining valid for all rolls of one ``complete_solve``;
820
+ # in Provider-land the equivalent is keeping the frames on
821
+ # the cascade-input Provider so each fresh sub-solve
822
+ # Provider seeds them at the top of its iter (line 444).
823
+ state.timeline.create_averaged_timeseries(
824
+ complete_solve[solve], state.solve, state.logger,
825
+ provider=cascade_input_provider,
826
+ work_folder=wf,
827
+ )
828
+ # Mirror the freshly aggregated frames onto the current
829
+ # sub-solve Provider too — this iter's readers must find
830
+ # them without waiting for the next seed pass.
831
+ for _key, _frame in cascade_input_provider.items():
832
+ if _key.startswith("solve_data/pt_") or _key.startswith(
833
+ "solve_data/pbt_",
834
+ ):
835
+ sub_solve_provider.put(_key, _frame)
836
+ previous_complete_solve = complete_solve[solve]
837
+
838
+ # ---- Block data (Agent 1.1) ----
839
+ # Δ.31: thread the per-sub-solve Provider so the legacy reads
840
+ # find the ``input/*.csv`` frames in memory. Without this the
841
+ # cascade silently produces a single-block layout (the input/
842
+ # CSVs are kept on the Provider rather than flushed to disk),
843
+ # collapsing every coarse-block fixture (e.g. lh2_three_region)
844
+ # to the default block and breaking the daily-block aggregation.
845
+ try:
846
+ emit_block_data_for_solve(
847
+ solve=complete_solve[solve],
848
+ solve_config=state.solve,
849
+ timeline_config=state.timeline,
850
+ work_folder=wf,
851
+ active_time_list=active_time_lists[solve],
852
+ default_jump_list=jump_lists[solve],
853
+ provider=sub_solve_provider,
854
+ emit_provider=sub_solve_provider,
855
+ )
856
+ except FlexToolConfigError:
857
+ raise
858
+ except Exception as exc: # pragma: no cover — defensive only
859
+ state.logger.warning(
860
+ f"blocks: emission failed (non-fatal — not consumed yet): {exc}"
861
+ )
862
+
863
+ first_of_nested_level = solve in state.solve.first_of_complete_solve
864
+ last_of_nested_level = solve in state.solve.last_of_solve
865
+
866
+ # Phase 4.1j — the parent-to-child fix_storage hand-off is now
867
+ # carried exclusively by the typed ``handoff/*`` Provider keys
868
+ # seeded at iteration start by the parent-handoff translator
869
+ # (Phase 4.1e). The legacy shutil.copy archive path and the
870
+ # in-memory carrier fallback that wrote to the now-dead
871
+ # ``solve_data/fix_storage_*`` Provider keys have been deleted;
872
+ # all downstream consumers (input.py, _derived_params.py,
873
+ # _emit_arc_unions.py, _emit_per_solve.py) consult ``handoff/*``.
874
+
875
+ solve_writers.emit_solve_status(
876
+ first_of_nested_level, last_of_nested_level,
877
+ nested=True,
878
+ provider=sub_solve_provider,
879
+ )
880
+ last = i == len(solves) - 1
881
+ solve_writers.emit_solve_status(
882
+ first, last,
883
+ provider=sub_solve_provider,
884
+ )
885
+ if i == 0:
886
+ first = False
887
+ solve_writers.emit_empty_investment_file(
888
+ provider=sub_solve_provider,
889
+ )
890
+ solve_writers.emit_empty_storage_fix_file(
891
+ provider=sub_solve_provider,
892
+ )
893
+ solve_writers.emit_headers_for_empty_output_files(
894
+ str(wf / "solve_data/costs_discounted.csv"),
895
+ "param_costs,costs_discounted",
896
+ provider=sub_solve_provider,
897
+ )
898
+ solve_writers.emit_headers_for_empty_output_files(
899
+ str(wf / "solve_data/co2.csv"),
900
+ "param_co2,model_wide",
901
+ provider=sub_solve_provider,
902
+ )
903
+ solve_writers.emit_headers_for_empty_output_files(
904
+ str(wf / "solve_data/period_capacity.csv"),
905
+ "period",
906
+ provider=sub_solve_provider,
907
+ )
908
+
909
+ # ---- Representative-period / timeset weights ----
910
+ timesets_used = state.solve.timesets_used_by_solves.get(
911
+ complete_solve[solve], []
912
+ )
913
+ active_timeset_names = [ts for _, ts in timesets_used]
914
+ # Phase C — silent degrade. Replaces the Phase 5 strict check.
915
+ # When the active timeset has no representative_period_weights,
916
+ # rewrite the per-solve provider's node__storage_binding_method
917
+ # so the three ``*_blended_weights`` variants degrade to their
918
+ # non-RP equivalents for THIS solve only. Same storage entity
919
+ # can now legitimately drive both an RP-active investment solve
920
+ # and a chronological dispatch solve back-to-back.
921
+ _downgrade_rp_methods_for_non_rp_solve(
922
+ solve=solve,
923
+ complete_solve_name=complete_solve[solve],
924
+ roll_index=i,
925
+ active_timeset_names=active_timeset_names,
926
+ rp_weights=state.timeline.rp_weights,
927
+ provider=sub_solve_provider,
928
+ logger=state.logger,
929
+ )
930
+ for ts_name in active_timeset_names:
931
+ if (
932
+ ts_name in state.timeline.rp_weights
933
+ and ts_name in state.timeline.timeset_weights
934
+ ):
935
+ message = (
936
+ f"Timeset '{ts_name}' has both "
937
+ "representative_period_weights and timeset_weights set. "
938
+ "Pick one: use representative_period_weights for RP "
939
+ "scenarios and timeset_weights for non-RP per-step "
940
+ "weighting."
941
+ )
942
+ state.logger.error(message)
943
+ raise FlexToolConfigError(message)
944
+
945
+ # Phase E — determine the RP chain TOPOLOGY for this solve based
946
+ # on which blended-weights binding methods appear in the per-solve
947
+ # ``input/node__storage_binding_method`` frame. The frame family
948
+ # emitted to the per-solve provider can carry only ONE chain
949
+ # topology, so mixing ``bind_within_solve_blended_weights`` (or
950
+ # ``bind_forward_only_blended_weights`` — same across-solve
951
+ # topology) with ``bind_within_period_blended_weights`` (per-
952
+ # FlexTool-period topology) in a single solve is rejected here
953
+ # with a ``FlexToolConfigError`` listing the conflict and the
954
+ # canonical fix (separate solves, or pick one variant for the
955
+ # whole solve). The downgrade above has already rewritten
956
+ # blended-weights methods to their non-RP equivalents on non-RP
957
+ # solves, so this branch only sees genuine RP-active solves.
958
+ _rp_variant = "within_solve"
959
+ _wp_nodes: list[str] = []
960
+ _ws_or_fo_nodes: list[str] = []
961
+ if sub_solve_provider.has("input/node__storage_binding_method"):
962
+ _sbm = sub_solve_provider.get(
963
+ "input/node__storage_binding_method"
964
+ )
965
+ if _sbm.height > 0:
966
+ _mcol = ("storage_binding_method"
967
+ if "storage_binding_method" in _sbm.columns
968
+ else "method")
969
+ import polars as _pl
970
+ _wp_df = _sbm.filter(
971
+ _pl.col(_mcol) == "bind_within_period_blended_weights"
972
+ ).select("node").unique()
973
+ _ws_df = _sbm.filter(_pl.col(_mcol).is_in([
974
+ "bind_within_solve_blended_weights",
975
+ "bind_forward_only_blended_weights",
976
+ ])).select("node").unique()
977
+ _wp_nodes = _wp_df["node"].to_list() if _wp_df.height else []
978
+ _ws_or_fo_nodes = (
979
+ _ws_df["node"].to_list() if _ws_df.height else []
980
+ )
981
+ if _wp_nodes and _ws_or_fo_nodes:
982
+ _shown_wp = _wp_nodes[:5]
983
+ _shown_ws = _ws_or_fo_nodes[:5]
984
+ raise FlexToolConfigError(
985
+ f"Solve '{complete_solve[solve]}' mixes "
986
+ f"bind_within_period_blended_weights "
987
+ f"({len(_wp_nodes)} node(s), e.g. "
988
+ f"{', '.join(_shown_wp)}) with "
989
+ f"bind_within_solve_blended_weights / "
990
+ f"bind_forward_only_blended_weights "
991
+ f"({len(_ws_or_fo_nodes)} node(s), e.g. "
992
+ f"{', '.join(_shown_ws)}). The two RP chain "
993
+ f"topologies (per-FlexTool-period vs. across-"
994
+ f"solve) cannot share the per-solve RP frame "
995
+ f"family — split into separate solves, or pick "
996
+ f"a single blended-weights variant for every "
997
+ f"node in this solve."
998
+ )
999
+ if _wp_nodes:
1000
+ _rp_variant = "within_period"
1001
+
1002
+ rp_written = False
1003
+ if _rp_variant == "within_period":
1004
+ # Iterate ALL RP-bearing active timesets (each owning a
1005
+ # distinct FlexTool period) and accumulate per-period inputs.
1006
+ # Cross-period chain edges are dropped by _compute_rp_frames
1007
+ # so each period closes its own cycle independently.
1008
+ per_period_inputs: list = []
1009
+ for ts_name in active_timeset_names:
1010
+ if ts_name not in state.timeline.rp_weights:
1011
+ continue
1012
+ period_name = None
1013
+ for p, ts in timesets_used:
1014
+ if ts == ts_name:
1015
+ period_name = p
1016
+ break
1017
+ if not period_name:
1018
+ continue
1019
+ timeline_name = state.timeline.timesets__timeline[ts_name]
1020
+ timeline_steps = [
1021
+ step
1022
+ for step, _dur in state.timeline.timelines.get(
1023
+ timeline_name, []
1024
+ )
1025
+ ]
1026
+ per_period_inputs.append((
1027
+ period_name,
1028
+ state.timeline.rp_weights[ts_name],
1029
+ state.timeline.timeset_durations[ts_name],
1030
+ timeline_steps,
1031
+ ))
1032
+ if per_period_inputs:
1033
+ # The legacy single-period args are ignored when variant
1034
+ # is within_period (asserted inside _compute_rp_frames),
1035
+ # but pass through harmless placeholders for the
1036
+ # signature; the function reads from per_period_inputs.
1037
+ first_p_name, first_rp_w, first_ts_dur, first_tl = (
1038
+ per_period_inputs[0]
1039
+ )
1040
+ solve_writers.emit_rp_data(
1041
+ rp_weights=first_rp_w,
1042
+ timeset_duration_entries=first_ts_dur,
1043
+ period_name=first_p_name,
1044
+ timeline_steps=first_tl,
1045
+ provider=sub_solve_provider,
1046
+ variant="within_period",
1047
+ per_period_inputs=per_period_inputs,
1048
+ )
1049
+ rp_written = True
1050
+ else:
1051
+ for ts_name in active_timeset_names:
1052
+ if ts_name in state.timeline.rp_weights:
1053
+ period_name = None
1054
+ for p, ts in timesets_used:
1055
+ if ts == ts_name:
1056
+ period_name = p
1057
+ break
1058
+ if period_name:
1059
+ timeline_name = state.timeline.timesets__timeline[ts_name]
1060
+ timeline_steps = [
1061
+ step
1062
+ for step, _dur in state.timeline.timelines.get(
1063
+ timeline_name, []
1064
+ )
1065
+ ]
1066
+ solve_writers.emit_rp_data(
1067
+ rp_weights=state.timeline.rp_weights[ts_name],
1068
+ timeset_duration_entries=state.timeline.timeset_durations[
1069
+ ts_name
1070
+ ],
1071
+ period_name=period_name,
1072
+ timeline_steps=timeline_steps,
1073
+ provider=sub_solve_provider,
1074
+ )
1075
+ rp_written = True
1076
+ break
1077
+ if not rp_written:
1078
+ # Phase C — Phase 5's strict ``_assert_blended_weights_have_rp_weights``
1079
+ # check has been retired. Per-solve downgrade fired earlier
1080
+ # in this iter (see ``_downgrade_rp_methods_for_non_rp_solve``
1081
+ # above), so any blended-weights node has already been
1082
+ # rewritten to its non-RP equivalent in this solve's
1083
+ # provider. Emit the empty RP scaffolding so downstream
1084
+ # consumers don't see missing keys.
1085
+ solve_writers.emit_empty_rp_data(
1086
+ provider=sub_solve_provider,
1087
+ )
1088
+ solve_writers.emit_timeset_cost_weight(
1089
+ active_time_list=active_time_lists[solve],
1090
+ timesets_used_by_solve=timesets_used,
1091
+ timeset_weights=state.timeline.timeset_weights,
1092
+ provider=sub_solve_provider,
1093
+ )
1094
+
1095
+ state.logger.debug("Starting model creation")
1096
+
1097
+ if timing_recorder is not None:
1098
+ roll_setup_seconds = time.perf_counter() - timer_in_solve
1099
+ timing_recorder.record(
1100
+ "roll_setup",
1101
+ solve=solve,
1102
+ roll_index=i,
1103
+ seconds=roll_setup_seconds,
1104
+ t_start=timer_in_solve,
1105
+ )
1106
+
1107
+ # Per-roll diagnostics handles.
1108
+ state.current_scale_solve_name = solve
1109
+ state.current_roll_index = i
1110
+
1111
+ # Per-solve preprocessing chain (still authoritative — the
1112
+ # override hook intercepts already-ported helpers). The prior
1113
+ # SolveHandoff is fanned into ``handoff/<field>`` Provider keys
1114
+ # via :func:`_provider_translators.translate_handoff_to_provider`
1115
+ # so cascade consumers go through ``provider.get(K.HANDOFF_X)``
1116
+ # — no ``prior_handoff`` parameter is threaded through the
1117
+ # cascade (Phase 2 of specs/provider_consolidation.md).
1118
+ prior_handoff = (
1119
+ state.handoffs.get(last_captured_solve)
1120
+ if state.handoffs is not None and last_captured_solve is not None
1121
+ else None
1122
+ )
1123
+ _provider_translators.translate_handoff_to_provider(
1124
+ prior_handoff, sub_solve_provider,
1125
+ )
1126
+ # Phase 4.1e — when nested, parent solve's handoff shadows the
1127
+ # sequential prior in ``handoff/*`` keys: both translator calls
1128
+ # write the same Provider keys, and parent's call lands after
1129
+ # sequential's so parent's values win where both are populated.
1130
+ # Reuses ``_parent_complete_for_carriers`` resolved earlier in
1131
+ # the same iteration scope. Guarded by ``if parent_handoff is
1132
+ # not None`` so non-nested cascades skip the second call
1133
+ # entirely; calling the translator with ``None`` would write
1134
+ # empty frames via its empty-schema fallback, obliterating the
1135
+ # sequential prior's just-written data.
1136
+ parent_handoff = (
1137
+ state.handoffs.get(_parent_complete_for_carriers)
1138
+ if state.handoffs is not None and _parent_complete_for_carriers is not None
1139
+ else None
1140
+ )
1141
+ if parent_handoff is not None:
1142
+ _provider_translators.translate_handoff_to_provider(
1143
+ parent_handoff, sub_solve_provider,
1144
+ )
1145
+ # Phase 5b — external overrides shadow handoff via the
1146
+ # ``override/*`` Provider layer. The callable is set by
1147
+ # external code wrapping the runner (file-watch / ZeroMQ /
1148
+ # etc.); default is None (no overrides). Lands AFTER the
1149
+ # sequential + parent handoff translators so override values
1150
+ # take precedence in ``read_handoff_frame`` consumers
1151
+ # (Phase 5a infrastructure). If the callable raises, the
1152
+ # exception propagates — external code owns clean error
1153
+ # reporting.
1154
+ _override_provider = getattr(state, "override_provider", None)
1155
+ if _override_provider is not None:
1156
+ overrides = _override_provider()
1157
+ if overrides:
1158
+ _provider_translators.translate_overrides_to_provider(
1159
+ overrides, sub_solve_provider,
1160
+ )
1161
+ state.logger.info(
1162
+ f"[override] applied {len(overrides)} keys at "
1163
+ f"iter={i} solve={complete_solve[solve]}"
1164
+ )
1165
+ state.logger.debug(
1166
+ f"[override] keys: {sorted(overrides.keys())}"
1167
+ )
1168
+ _phase_timing = (
1169
+ os.environ.get("FLEXTOOL_PHASE_TIMING") == "1"
1170
+ and timing_recorder is not None
1171
+ )
1172
+ _t_preproc_start = time.perf_counter() if _phase_timing else 0.0
1173
+ if _mem_cp is not None:
1174
+ _mem_cp("prep_before_solve_time_dispatcher",
1175
+ "prep: block + rp/timeset + status + empty writers done")
1176
+ # Step 1-f — preprocessing emits directly into
1177
+ # ``sub_solve_provider`` via the threaded ``provider=`` keyword;
1178
+ # the Provider is the sole in-memory carrier.
1179
+ preprocessing_solve_time.run(
1180
+ state, complete_solve[solve],
1181
+ provider=sub_solve_provider,
1182
+ )
1183
+ if _mem_cp is not None:
1184
+ _mem_cp("prep_solve_time_dispatcher_done",
1185
+ "prep: _emit_solve_time.run (per-solve sets + params dispatcher) done")
1186
+ # Step 1-f — Provider stash on state. Read by
1187
+ # ``_PolarHighCascadeSolver.run`` to thread through into
1188
+ # ``load_flextool`` / ``write_outputs_for_solve`` /
1189
+ # ``build_handoff_from_solution``. Replaces any prior
1190
+ # sub-solve's Provider (per-sub-solve memory discipline).
1191
+ state.current_provider = sub_solve_provider
1192
+ if _phase_timing:
1193
+ timing_recorder.record(
1194
+ "per_iter",
1195
+ subphase="preprocessing",
1196
+ solve=complete_solve[solve],
1197
+ roll_index=i,
1198
+ seconds=time.perf_counter() - _t_preproc_start,
1199
+ t_start=_t_preproc_start,
1200
+ )
1201
+
1202
+ # Phase 6b — opt-in source-tagging audit dump. When
1203
+ # ``FLEXTOOL_AUDIT_SOURCES=1`` is set in the environment, append
1204
+ # every Provider key carrying a non-None source tag to
1205
+ # ``<work_folder>/audit_sources.log``. The override translator
1206
+ # tags its writes with ``source="external_override"`` (Phase 6a);
1207
+ # other writes leave the source slot empty, so the log captures
1208
+ # exactly the externally-injected entries for the just-completed
1209
+ # preprocessing pass. Append mode accumulates across sub-solves.
1210
+ if os.environ.get("FLEXTOOL_AUDIT_SOURCES") == "1":
1211
+ _provider_translators.dump_provider_sources(
1212
+ sub_solve_provider,
1213
+ wf / "audit_sources.log",
1214
+ complete_solve[solve],
1215
+ )
1216
+
1217
+ # Phase 4 (Gap F) — expose the upper-level (nesting) parent's
1218
+ # complete solve name so ``_PolarHighCascadeSolver.run`` can look the
1219
+ # parent's :class:`SolveHandoff` up out of ``state.handoffs`` and
1220
+ # pass it to ``build_handoff_from_solution`` (which uses it to skip
1221
+ # the workdir's ``fix_storage_{price,usage}.csv`` reads). Resets
1222
+ # to None for top-level solves.
1223
+ _parent_solve = parent_roll.get(solve)
1224
+ state.current_parent_complete = (
1225
+ complete_solve.get(_parent_solve) if _parent_solve else None
1226
+ )
1227
+
1228
+ # Tell the solver to emit the four per-iter phase checkpoints
1229
+ # (FlexData built / Matrix built / Solver / Outputs written) on
1230
+ # EVERY iter, so each roll/solve reports its own deltas rather
1231
+ # than aggregating across the whole level group. This makes the
1232
+ # per-roll time/memory breakdown visible between solves (the
1233
+ # ``Solve cleanup`` snapshot above closes each boundary).
1234
+ state.emit_phase_checkpoints_this_iter = True
1235
+ # Phase 2 — expose the current iter's position in the level_keys
1236
+ # sequence so the per-solve callback can decide whether to retain
1237
+ # or drop this level's ``Solution.highs`` / ``flex_data_provider``
1238
+ # after the per-iter writers run.
1239
+ state._current_iter_index = i
1240
+ state._current_level_key = _level_key
1241
+ exit_status = solver.run(complete_solve[solve])
1242
+ state.emit_phase_checkpoints_this_iter = False
1243
+ state.current_parent_complete = None
1244
+ state.current_scale_solve_name = None
1245
+ state.current_roll_index = None
1246
+
1247
+ if exit_status == 0:
1248
+ state.logger.debug("Success!")
1249
+ state.logger.debug(
1250
+ "------------------------------------------------------------"
1251
+ "-------------------------------"
1252
+ )
1253
+ else:
1254
+ message = f"Error: {exit_status}"
1255
+ state.logger.error(message)
1256
+ raise FlexToolSolveError(message)
1257
+
1258
+ # ---- mod-phase timing ingest (legacy diagnostic) ----
1259
+ if timing_recorder is not None:
1260
+ mod_phases_path = wf / "solve_data" / "mod_phases.csv"
1261
+ if mod_phases_path.exists():
1262
+ try:
1263
+ with open(mod_phases_path) as _mp:
1264
+ _reader = csv.DictReader(_mp)
1265
+ for _row in _reader:
1266
+ try:
1267
+ _seconds = float(
1268
+ _row.get("seconds", "") or 0.0
1269
+ )
1270
+ except ValueError:
1271
+ continue
1272
+ _phase_name = (_row.get("phase") or "").strip()
1273
+ if not _phase_name:
1274
+ continue
1275
+ timing_recorder.record(
1276
+ "solve",
1277
+ subphase=f"mod_{_phase_name}",
1278
+ solve=complete_solve[solve],
1279
+ roll_index=i,
1280
+ seconds=_seconds,
1281
+ )
1282
+ except Exception as _exc: # diagnostic only
1283
+ state.logger.debug(
1284
+ f"mod_phases ingest failed for "
1285
+ f"{complete_solve[solve]}: {_exc}"
1286
+ )
1287
+
1288
+ # In-memory handoff bookkeeping. ``solver.run`` deposits a
1289
+ # polar_high-derived ``SolveHandoff`` into ``state.handoffs`` via
1290
+ # ``build_handoff_from_solution``. Phase 3 of
1291
+ # specs/provider_consolidation.md deleted the legacy
1292
+ # ``capture_post_solve`` constructor — nothing reads from disk
1293
+ # to overwrite that handoff.
1294
+ if state.handoffs is not None:
1295
+ last_captured_solve = complete_solve[solve]
1296
+ state.last_captured_solve = last_captured_solve
1297
+ # Phase 4.1a — refresh ``handoff/*`` Provider keys from the
1298
+ # post-solve ``SolveHandoff`` so the current iteration's
1299
+ # Provider exposes the FINAL cumulative state of this roll
1300
+ # (not just the prior-roll state seeded at iteration start).
1301
+ # ``csv_dump`` snapshots the Provider after the last roll;
1302
+ # without this refresh the on-disk
1303
+ # ``handoff/cumulative_commodity.csv`` would reflect the
1304
+ # second-to-last roll instead of the final one. The next
1305
+ # iteration's iteration-start translator is unaffected: it
1306
+ # reads from ``state.handoffs[last_captured_solve]`` directly.
1307
+ _latest_handoff = state.handoffs.get(last_captured_solve)
1308
+ if _latest_handoff is not None:
1309
+ _provider_translators.translate_handoff_to_provider(
1310
+ _latest_handoff, sub_solve_provider,
1311
+ )
1312
+
1313
+ # ---- Scaling report — handled by the orchestrator.
1314
+ # The diagnostic TXT report is emitted by
1315
+ # ``_orchestration._write_scale_csv_and_report`` (called from
1316
+ # the cascade post-solve path at ``_orchestration.py:1352``).
1317
+ # The duplicate native call site was removed in Tier 4
1318
+ # Commit 4 since the orchestrator's call uses the modern
1319
+ # ``flex_data`` / ``Solution`` API and emits the same
1320
+ # ``solve_data/scaling_report.txt`` artifact with identical
1321
+ # base-name deduplication.
1322
+
1323
+ # Phase 4.1l — the post-solve fix_storage carrier refresh that
1324
+ # used to fan the wide ``SolveHandoff.fix_storage`` frame into
1325
+ # per-metric ``solve_data/fix_storage_*`` Provider keys is
1326
+ # retired. The narrow ``handoff/fix_storage_{quantity,price,
1327
+ # usage}`` keys are seeded directly by the iteration-start
1328
+ # parent-handoff translator from the three narrow SolveHandoff
1329
+ # fields; no post-solve fan-out is required.
1330
+
1331
+ # Phase 4.1a — the ladder rolling accumulators
1332
+ # (``cumulative_commodity`` / ``cum_sim_hours``) cross sub-solves
1333
+ # via the iteration-start handoff translator
1334
+ # (``translate_handoff_to_provider``). No fan-out needed here:
1335
+ # the next iteration reads ``handoff/cumulative_commodity`` /
1336
+ # ``handoff/cum_sim_hours`` directly from the typed SolveHandoff.
1337
+
1338
+ if len(state.solve.model_solve) > 1:
1339
+ message = (
1340
+ "Trying to run more than one model - not supported. The results "
1341
+ "of the first model are retained."
1342
+ )
1343
+ state.logger.error(message)
1344
+ raise FlexToolConfigError(message)
1345
+ return 0
1346
+
1347
+
1348
+ __all__ = ["native_run_model"]