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,593 @@
1
+ """Stochastic branching of time periods.
2
+
3
+ Architecture notes
4
+ ------------------
5
+
6
+ * :class:`StochasticSolver` adds branched versions of periods to the
7
+ per-solve active-time lists when ``state.solve.stochastic_branches``
8
+ declares branches for a solve. Branches receive LP variables
9
+ (active time) but are NOT committed (``realized_time_lists`` /
10
+ ``fix_storage_time_lists`` are filtered to the realized branch only).
11
+
12
+ * :func:`write_timeline_matching_map` returns an in-memory dict
13
+ instead of writing CSV; downstream consumers read the dict directly.
14
+
15
+ * **R-O6 invariant** (``audit/a6_b_dim_alternative.md §1-3``): branches
16
+ do NOT enter ``invest_periods``. ``v_invest`` stays realized-only.
17
+ Recourse investment is a future capability requiring a separate
18
+ b-dim refactor; this module must not introduce per-branch invest
19
+ variables.
20
+
21
+ * Mutates ``state.timeline.stochastic_timesteps`` (a slight code smell
22
+ — it would be cleaner to return a fresh dict, but downstream readers
23
+ consume the mutated state).
24
+
25
+ Behavioural quirks
26
+ ------------------
27
+
28
+ * **Zero-weight branch**: excluded from ``new_active_time_list`` but
29
+ only excluded from ``solve_branch__time_branch_lists`` if both
30
+ ``branch != period`` AND the realized flag is not ``"yes"``. Subtle
31
+ three-way condition.
32
+
33
+ * **Continuation-after-branching**: when a roll's jump exceeds a
34
+ period length, branches continue into subsequent periods using the
35
+ same ``period + "_" + branch`` naming. The naming is "sticky"
36
+ across periods.
37
+
38
+ * **Single realized branch per period**: each period must have
39
+ exactly one ``realized: yes`` row (or zero before any branching has
40
+ occurred). Found 0 or > 1 → raise ``FlexToolConfigError``.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import bisect
45
+ import logging
46
+ from collections import defaultdict
47
+ from typing import Any, TYPE_CHECKING
48
+
49
+ from flextool.engine_polars._solve_state import FlexToolConfigError
50
+ from flextool.engine_polars._timeline import make_step_jump
51
+
52
+ if TYPE_CHECKING:
53
+ from flextool.engine_polars._solve_state import RunnerState
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Free helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def _get_timeset_for_period(
62
+ period_timesets: list[tuple[str, str]],
63
+ real_period: str,
64
+ ) -> str | None:
65
+ """Find the timeset corresponding to *real_period* in *period_timesets*."""
66
+ for period_timeset in period_timesets:
67
+ if period_timeset[0] == real_period:
68
+ return period_timeset[1]
69
+ return None
70
+
71
+
72
+ def connect_two_timelines(
73
+ state: "RunnerState",
74
+ period: str,
75
+ first_solve: str,
76
+ second_solve: str,
77
+ period__branch: list[tuple[str, str]],
78
+ ) -> tuple[dict[str, float], dict[str, float]]:
79
+ """Compute cumulative durations from start for two solves' timelines.
80
+
81
+ Returns two ``{timestep: cumulative_hours}`` dicts so callers can
82
+ align timesteps between the two solves.
83
+
84
+ Args:
85
+ state: Runner state (reads ``solve.timesets_used_by_solves`` and
86
+ ``timeline.{timesets__timeline, timelines}``).
87
+ period: Period (or branch) name to align on.
88
+ first_solve / second_solve: Solve names.
89
+ period__branch: List of ``(real_period, branched_period)``
90
+ tuples. Used to map *period* back to its real period for
91
+ timeset lookup.
92
+
93
+ Raises:
94
+ ValueError: When the timeset for *real_period* can't be located
95
+ in either solve's ``timesets_used_by_solves``.
96
+ """
97
+ first_period_timesets = state.solve.timesets_used_by_solves[first_solve]
98
+ second_period_timesets = state.solve.timesets_used_by_solves[second_solve]
99
+
100
+ real_period: str | None = None
101
+ for row in period__branch:
102
+ if row[1] == period:
103
+ real_period = row[0]
104
+
105
+ first_timeset = _get_timeset_for_period(first_period_timesets, real_period)
106
+ second_timeset = _get_timeset_for_period(second_period_timesets, real_period)
107
+
108
+ if first_timeset is None:
109
+ raise ValueError(
110
+ f"Could not find first_timeset for real_period={real_period} "
111
+ f"in first_period_timesets={first_period_timesets}"
112
+ )
113
+ if second_timeset is None:
114
+ raise ValueError(
115
+ f"Could not find second_timeset for real_period={real_period} "
116
+ f"in second_period_timesets={second_period_timesets}"
117
+ )
118
+
119
+ first_timeline = state.timeline.timesets__timeline[first_timeset]
120
+ second_timeline = state.timeline.timesets__timeline[second_timeset]
121
+
122
+ first_timeline_duration_from_start: dict[str, float] = dict()
123
+ second_timeline_duration_from_start: dict[str, float] = dict()
124
+ counter: float = 0
125
+ for timestep in state.timeline.timelines[first_timeline]:
126
+ first_timeline_duration_from_start[timestep[0]] = counter
127
+ counter += float(timestep[1])
128
+ counter = 0
129
+ for timestep in state.timeline.timelines[second_timeline]:
130
+ second_timeline_duration_from_start[timestep[0]] = counter
131
+ counter += float(timestep[1])
132
+
133
+ return first_timeline_duration_from_start, second_timeline_duration_from_start
134
+
135
+
136
+ def find_previous_timestep(
137
+ state: "RunnerState",
138
+ from_active_time_list: dict[str, list[tuple[str, ...]]],
139
+ period_timestamp: tuple[str, str],
140
+ this_solve: str,
141
+ from_solve: str,
142
+ period__branch: list[tuple[str, str]],
143
+ ) -> str:
144
+ """Find the previous timestep in *from_solve* matching *period_timestamp* in *this_solve*."""
145
+ this_timeline_duration_from_start, from_timeline_duration_from_start = (
146
+ connect_two_timelines(
147
+ state, period_timestamp[0], this_solve, from_solve, period__branch
148
+ )
149
+ )
150
+
151
+ real_period: str | None = None
152
+ for row in period__branch:
153
+ if row[1] == period_timestamp[0]:
154
+ real_period = row[0]
155
+ from_start = this_timeline_duration_from_start[period_timestamp[1]]
156
+ last_timestep = from_active_time_list[real_period][0].timestep
157
+ previous_timestep = from_active_time_list[real_period][-1].timestep
158
+ for timestep in from_active_time_list[real_period]:
159
+ if from_timeline_duration_from_start[timestep.timestep] > from_start:
160
+ previous_timestep = last_timestep
161
+ break
162
+ last_timestep = timestep.timestep
163
+ return previous_timestep
164
+
165
+
166
+ def find_next_timestep(
167
+ state: "RunnerState",
168
+ from_active_time_list: dict[str, list[tuple[str, ...]]],
169
+ period_timestamp: tuple[str, str],
170
+ this_solve: str,
171
+ from_solve: str,
172
+ ) -> str:
173
+ """Find the next timestep in *from_solve* matching *period_timestamp* in *this_solve*.
174
+
175
+ The ``period__branch`` arg is implicitly the identity mapping
176
+ ``[(period, period)]`` here — used when the lookup happens before
177
+ any branches have been generated.
178
+ """
179
+ this_timeline_duration_from_start, from_timeline_duration_from_start = (
180
+ connect_two_timelines(
181
+ state,
182
+ period_timestamp[0],
183
+ this_solve,
184
+ from_solve,
185
+ [(period_timestamp[0], period_timestamp[0])],
186
+ )
187
+ )
188
+
189
+ from_start = this_timeline_duration_from_start[period_timestamp[1]]
190
+ next_timestep = from_active_time_list[period_timestamp[0]][-1].timestep
191
+ for timestep in from_active_time_list[period_timestamp[0]]:
192
+ if from_timeline_duration_from_start[timestep.timestep] >= from_start:
193
+ next_timestep = timestep.timestep
194
+ break
195
+ return next_timestep
196
+
197
+
198
+ def write_timeline_matching_map(
199
+ state: "RunnerState",
200
+ upper_active_time_list: dict[str, list[tuple[str, ...]]],
201
+ lower_active_time_list: dict[str, list[tuple[str, ...]]],
202
+ upper_solve: str,
203
+ lower_solve: str,
204
+ period__branch: list[tuple[str, str]],
205
+ ) -> dict[tuple[str, str], str]:
206
+ """Build the timeline matching map for nested storage fixing.
207
+
208
+ Maps each lower-solve ``(period, timestep)`` pair to the
209
+ corresponding upper-solve timestep using binary search on
210
+ cumulative timeline durations.
211
+
212
+ Returns:
213
+ ``{(period, timestep): upper_timestep, ...}`` dict.
214
+
215
+ Note: the AMPL/.mod reference path also writes
216
+ ``solve_data/timeline_matching_map.csv`` to disk. The native
217
+ engine keeps the result in-memory; the CSV write is a separate
218
+ concern that lives in the orchestrator (Γ.8.D) when file output
219
+ is requested for compatibility with downstream tools.
220
+ """
221
+ # Pre-compute period -> real_period mapping.
222
+ period_to_real: dict[str, str] = {row[1]: row[0] for row in period__branch}
223
+
224
+ upper_period_timesets = state.solve.timesets_used_by_solves[upper_solve]
225
+ upper_periods: set[str] = {pt[0] for pt in upper_period_timesets}
226
+
227
+ matching_map: dict[tuple[str, str], str] = dict()
228
+ for period, lower_active_time in lower_active_time_list.items():
229
+ real_period = period_to_real.get(period)
230
+
231
+ # Skip periods that don't exist in the upper solve's timesets.
232
+ if real_period not in upper_periods:
233
+ continue
234
+
235
+ # Call connect_two_timelines once per period, not per timestep.
236
+ this_timeline_duration, from_timeline_duration = connect_two_timelines(
237
+ state, period, lower_solve, upper_solve, period__branch
238
+ )
239
+
240
+ upper_timesteps = upper_active_time_list[real_period]
241
+
242
+ from_durations: list[float] = []
243
+ from_timestep_names: list[str] = []
244
+ for ts in upper_timesteps:
245
+ from_durations.append(from_timeline_duration[ts.timestep])
246
+ from_timestep_names.append(ts.timestep)
247
+
248
+ default_timestep = upper_timesteps[-1].timestep
249
+
250
+ for timestep in lower_active_time:
251
+ period_timestep = (period, timestep.timestep)
252
+ from_start = this_timeline_duration[timestep.timestep]
253
+
254
+ # Binary search: find first index where duration > from_start.
255
+ idx = bisect.bisect_right(from_durations, from_start)
256
+
257
+ if idx == 0:
258
+ previous_timestep = upper_timesteps[0].timestep
259
+ elif idx >= len(from_durations):
260
+ previous_timestep = default_timestep
261
+ else:
262
+ previous_timestep = from_timestep_names[idx - 1]
263
+
264
+ matching_map[period_timestep] = previous_timestep
265
+
266
+ return matching_map
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # StochasticSolver
271
+ # ---------------------------------------------------------------------------
272
+
273
+
274
+ class StochasticSolver:
275
+ """Apply stochastic branching to per-solve active-time lists.
276
+
277
+ See :meth:`create_stochastic_periods` for the entry point. The
278
+ standalone helpers above (``connect_two_timelines``,
279
+ ``find_previous_timestep``, ``find_next_timestep``,
280
+ ``write_timeline_matching_map``) are kept as module-level functions
281
+ so callers don't need a ``StochasticSolver`` instance for read-only
282
+ queries.
283
+ """
284
+
285
+ def __init__(self, state: "RunnerState") -> None:
286
+ self.state = state
287
+ self.logger: logging.Logger = state.logger
288
+
289
+ # ------------------------------------------------------------------
290
+ # Convenience instance wrappers (kept for backwards compatibility).
291
+ # The underlying logic lives in module-level helpers above.
292
+ # ------------------------------------------------------------------
293
+
294
+ def connect_two_timelines(
295
+ self,
296
+ period: str,
297
+ first_solve: str,
298
+ second_solve: str,
299
+ period__branch: list[tuple[str, str]],
300
+ ) -> tuple[dict[str, float], dict[str, float]]:
301
+ return connect_two_timelines(
302
+ self.state, period, first_solve, second_solve, period__branch
303
+ )
304
+
305
+ def find_previous_timestep(
306
+ self,
307
+ from_active_time_list: dict[str, list[tuple[str, ...]]],
308
+ period_timestamp: tuple[str, str],
309
+ this_solve: str,
310
+ from_solve: str,
311
+ period__branch: list[tuple[str, str]],
312
+ ) -> str:
313
+ return find_previous_timestep(
314
+ self.state,
315
+ from_active_time_list,
316
+ period_timestamp,
317
+ this_solve,
318
+ from_solve,
319
+ period__branch,
320
+ )
321
+
322
+ def find_next_timestep(
323
+ self,
324
+ from_active_time_list: dict[str, list[tuple[str, ...]]],
325
+ period_timestamp: tuple[str, str],
326
+ this_solve: str,
327
+ from_solve: str,
328
+ ) -> str:
329
+ return find_next_timestep(
330
+ self.state,
331
+ from_active_time_list,
332
+ period_timestamp,
333
+ this_solve,
334
+ from_solve,
335
+ )
336
+
337
+ def write_timeline_matching_map(
338
+ self,
339
+ upper_active_time_list: dict[str, list[tuple[str, ...]]],
340
+ lower_active_time_list: dict[str, list[tuple[str, ...]]],
341
+ upper_solve: str,
342
+ lower_solve: str,
343
+ period__branch: list[tuple[str, str]],
344
+ ) -> dict[tuple[str, str], str]:
345
+ return write_timeline_matching_map(
346
+ self.state,
347
+ upper_active_time_list,
348
+ lower_active_time_list,
349
+ upper_solve,
350
+ lower_solve,
351
+ period__branch,
352
+ )
353
+
354
+ # ------------------------------------------------------------------
355
+ # create_stochastic_periods — main entry point
356
+ # ------------------------------------------------------------------
357
+
358
+ def create_stochastic_periods(
359
+ self,
360
+ stochastic_branches: dict[str, Any],
361
+ solves: list[str],
362
+ complete_solves: dict[str, str],
363
+ active_time_lists: dict[str, dict],
364
+ fix_storage_time_lists: dict[str, dict],
365
+ realized_time_lists: dict[str, dict],
366
+ ) -> tuple[
367
+ defaultdict[str, list],
368
+ defaultdict[str, list],
369
+ dict[str, dict],
370
+ dict[str, Any],
371
+ dict[str, dict],
372
+ dict[str, dict],
373
+ defaultdict[str, Any],
374
+ ]:
375
+ """Apply stochastic branching to per-solve active-time lists.
376
+
377
+ Branches are added to ``active_time_lists`` for optimization
378
+ but NOT to ``realized_time_lists`` / ``fix_storage_time_lists``;
379
+ branches are future scenarios that aren't committed.
380
+
381
+ IMPORTANT: branches do NOT enter ``invest_periods`` either.
382
+ Per ``audit/a6_b_dim_alternative.md`` (R-O6), ``v_invest`` is
383
+ realized-only — recourse investment is a future capability.
384
+
385
+ Args:
386
+ stochastic_branches: ``{solve: [(period, branch, start_step,
387
+ realized_yes_no, weight), ...]}``. Read from the DB.
388
+ solves: Flat list of all solve names (output of
389
+ :class:`RecursiveSolveBuilder`).
390
+ complete_solves: ``{solve: complete_solve_name}``.
391
+ active_time_lists / fix_storage_time_lists / realized_time_lists:
392
+ Per-solve time lists from the recursive builder.
393
+
394
+ Returns:
395
+ ``(period__branch_lists, solve_branch__time_branch_lists,
396
+ active_time_lists, jump_lists, fix_storage_time_lists,
397
+ realized_time_lists, branch_start_time_lists)``.
398
+
399
+ Raises:
400
+ FlexToolConfigError: When a solve's start time isn't in any
401
+ ``stochastic_branches`` row, or when more than one
402
+ ``realized: yes`` row matches a period.
403
+ """
404
+ period__branch_lists: defaultdict[str, list] = defaultdict(list)
405
+ solve_branch__time_branch_lists: defaultdict[str, list] = defaultdict(list)
406
+ jump_lists: dict[str, Any] = dict()
407
+ branch_start_time_lists: defaultdict[str, Any] = defaultdict()
408
+
409
+ for solve in solves:
410
+ new_realized_time_list: dict = dict()
411
+ new_fix_storage_time_list: dict = dict()
412
+ new_active_time_list: dict = dict()
413
+
414
+ info = stochastic_branches[complete_solves[solve]]
415
+ active_time_list = active_time_lists[solve]
416
+ realized_time_list = realized_time_lists[solve]
417
+ fix_storage_time_list = fix_storage_time_lists[solve]
418
+
419
+ branched = False
420
+ branches: list[str] = []
421
+ branch_start_time_lists[solve] = None
422
+
423
+ # First step of the solve (used for validation below).
424
+ first_step: tuple[str, str] | None = None
425
+ for period, active_time in active_time_list.items():
426
+ first_step = (period, active_time[0].timestep)
427
+ break
428
+
429
+ # Validate: when stochastic_branches has rows for this
430
+ # complete-solve, at least one row's start_step must match
431
+ # the solve's first step AND be marked realized.
432
+ found_start = False
433
+ for row in info:
434
+ if first_step is not None and first_step[1] == row[2] and "yes" == row[3]:
435
+ found_start = True
436
+ if found_start is False and len(info) != 0:
437
+ message = (
438
+ "A realized start time of the solve cannot be found from "
439
+ "the stochastic_branches parameter. "
440
+ "Check that stochastic_branches has a realized : yes, "
441
+ "branch for the start of the solve "
442
+ "and that the possible rolling_jump matches with the "
443
+ "branch starts"
444
+ )
445
+ self.logger.error(message)
446
+ raise FlexToolConfigError(message)
447
+
448
+ # Walk periods — at the first branch trigger, fan out into
449
+ # branches; subsequent periods continue the same branches
450
+ # (continuation-after-branching at lines 329-340 of the
451
+ # reference).
452
+ for period, active_time in active_time_list.items():
453
+ if not branched:
454
+ period__branch_lists[solve].append((period, period))
455
+ # Get all start times: row[0]=period, row[1]=branch,
456
+ # row[2]=start_step, row[3]=realized_yn, row[4]=weight.
457
+ start_times: defaultdict[str, list] = defaultdict(list)
458
+ for row in info:
459
+ if row[0] == period:
460
+ start_times[row[2]].append((row[1], row[4], row[3]))
461
+
462
+ # Trigger branching at the first step that appears
463
+ # in start_times.
464
+ for step in active_time:
465
+ if step.timestep in start_times.keys():
466
+ branched = True
467
+ branch_start_time_lists[solve] = (period, step.timestep)
468
+
469
+ # Add active time for base period.
470
+ new_active_time_list[period] = active_time
471
+
472
+ # Copy realized + fix_storage for base period only.
473
+ if period in realized_time_list:
474
+ new_realized_time_list[period] = realized_time_list[period]
475
+ if period in fix_storage_time_list:
476
+ new_fix_storage_time_list[period] = fix_storage_time_list[period]
477
+
478
+ # Create branches: each gets active time but
479
+ # NOT realized/fix_storage.
480
+ for branch__weight__real in start_times[step.timestep]:
481
+ branch = branch__weight__real[0]
482
+ branches.append(branch)
483
+ solve_branch = period + "_" + branch
484
+
485
+ # Zero-weight branch is excluded from
486
+ # both active time AND
487
+ # solve_branch__time_branch when the
488
+ # three-way condition holds.
489
+ if (
490
+ float(branch__weight__real[1]) != 0.0
491
+ and branch != period
492
+ and branch__weight__real[2] != "yes"
493
+ ):
494
+ new_active_time_list[solve_branch] = active_time[0:]
495
+ solve_branch__time_branch_lists[solve].append(
496
+ (solve_branch, branch)
497
+ )
498
+
499
+ period__branch_lists[solve].append((period, solve_branch))
500
+
501
+ # Track branched timesteps for downstream
502
+ # disambiguation. Mutates state.timeline.
503
+ for i in active_time[0:]:
504
+ self.state.timeline.stochastic_timesteps[solve].append(
505
+ (solve_branch, i.timestep)
506
+ )
507
+ break
508
+ else:
509
+ # Continuation-after-branching: extend each branch
510
+ # into the next period using the same branch-name
511
+ # prefix.
512
+ for branch in branches:
513
+ solve_branch = period + "_" + branch
514
+ period__branch_lists[solve].append((period, solve_branch))
515
+ solve_branch__time_branch_lists[solve].append(
516
+ (solve_branch, branch)
517
+ )
518
+ for i in active_time_list[period]:
519
+ self.state.timeline.stochastic_timesteps[solve].append(
520
+ (solve_branch, i.timestep)
521
+ )
522
+
523
+ # Periods before any branching: copy as-is.
524
+ if not branched:
525
+ new_active_time_list[period] = active_time_list[period]
526
+ if period in realized_time_list:
527
+ new_realized_time_list[period] = realized_time_list[period]
528
+ if period in fix_storage_time_list:
529
+ new_fix_storage_time_list[period] = fix_storage_time_list[period]
530
+
531
+ # Find the realized branch for each period. Each period
532
+ # must have exactly one realized: yes row (or zero before
533
+ # any branching has occurred).
534
+ for period, active_time in active_time_list.items():
535
+ found = 0
536
+ # Before branching: row[0]==period, row[2]==first step,
537
+ # row[3]=='yes'.
538
+ for row in info:
539
+ if (
540
+ row[0] == period
541
+ and row[2] == active_time[0].timestep
542
+ and row[3] == "yes"
543
+ ):
544
+ found += 1
545
+ solve_branch__time_branch_lists[solve].append((period, row[1]))
546
+ # After branching: lookup against the branch-start row.
547
+ if found == 0 and branch_start_time_lists[solve] is not None:
548
+ for row in info:
549
+ if (
550
+ row[0] == branch_start_time_lists[solve][0]
551
+ and row[2] == branch_start_time_lists[solve][1]
552
+ and row[3] == "yes"
553
+ ):
554
+ found += 1
555
+ solve_branch__time_branch_lists[solve].append(
556
+ (period, row[1])
557
+ )
558
+ if (branch_start_time_lists[solve] is not None and found == 0) or found > 1:
559
+ message = (
560
+ "Each period should have one and only one realized branch. "
561
+ "Found: " + str(found) + "\n"
562
+ )
563
+ self.logger.error(message)
564
+ raise FlexToolConfigError(message)
565
+
566
+ # Update the time lists for this solve.
567
+ realized_time_lists[solve] = new_realized_time_list
568
+ fix_storage_time_lists[solve] = new_fix_storage_time_list
569
+ active_time_lists[solve] = new_active_time_list
570
+ jump_lists[solve] = make_step_jump(
571
+ new_active_time_list,
572
+ period__branch_lists[solve],
573
+ solve_branch__time_branch_lists[solve],
574
+ )
575
+
576
+ return (
577
+ period__branch_lists,
578
+ solve_branch__time_branch_lists,
579
+ active_time_lists,
580
+ jump_lists,
581
+ fix_storage_time_lists,
582
+ realized_time_lists,
583
+ branch_start_time_lists,
584
+ )
585
+
586
+
587
+ __all__ = [
588
+ "StochasticSolver",
589
+ "connect_two_timelines",
590
+ "find_previous_timestep",
591
+ "find_next_timestep",
592
+ "write_timeline_matching_map",
593
+ ]