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,858 @@
1
+ """Warm-LP primitives — structural fingerprint, Param classification, and
2
+ WarmProblem update routine for the native cascade in ``_orchestration``.
3
+
4
+ Two consecutive sub-solves of a chain are "warm-compatible" iff they emit
5
+ an LP of identical shape (same set of vars and cstrs by row count and
6
+ dim signature). When that holds AND every changed Param either belongs
7
+ to the clean-mapping set (:data:`_WARM_PARAMS`) or is declared mutable
8
+ on the :class:`polar_high.WarmProblem` (:data:`_MUTABLE_PARAMS`), the
9
+ warm-update routine pushes the deltas into the live HiGHS instance via
10
+ ``changeRowsBounds`` / ``changeColsCost`` / per-cell coefficient writes
11
+ instead of cold-rebuilding the model.
12
+
13
+ Consumed by ``_orchestration.py::_drive_cascade`` (native polar_high
14
+ cascade — Δ.12d). Re-exported as module attributes on ``chain.py`` for
15
+ backward-compat with callers like ``test_warm_param_autoupdate``.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING
20
+
21
+ import polars as pl
22
+
23
+ from polar_high import Problem, WarmProblem
24
+
25
+ if TYPE_CHECKING:
26
+ from flextool.engine_polars.input import FlexData
27
+
28
+
29
+ __all__ = [
30
+ "_STRUCTURAL_FIELDS",
31
+ "_WARM_PARAMS",
32
+ "_MUTABLE_PARAMS",
33
+ "_WARM_PARAMS_DEFERRED",
34
+ "_WARM_PARAMS_NO_OP",
35
+ "_WARM_PARAM_GATES",
36
+ "_IncompatibleUpdate",
37
+ "_fingerprint",
38
+ "_param_frame_equal",
39
+ "_param_values_position_equal",
40
+ "_gate_active",
41
+ "_apply_warm_updates",
42
+ "_build_warm_problem",
43
+ ]
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Structural-fingerprint fields.
48
+ #
49
+ # Two consecutive sub-solves are "warm-compatible" iff they emit an LP of
50
+ # identical shape — same set of variables (same dims, same row counts) and
51
+ # same set of constraints (same row counts). In flextool the LP shape is
52
+ # determined by which "set"-typed FlexData fields are populated and how
53
+ # many rows they hold. We capture that with a tuple of (field_name,
54
+ # height) pairs.
55
+ #
56
+ # The list is intentionally NOT exhaustive — only fields that we have
57
+ # evidence affect the LP structure in tested scenarios are listed. When
58
+ # warm=True misclassifies a transition (i.e. the obj diverges from the
59
+ # cold rebuild path), add the offending field here.
60
+
61
+ _STRUCTURAL_FIELDS: tuple[str, ...] = (
62
+ # Time + node sets (the foundation of every LP).
63
+ "dt", "nodeBalance", "nodeBalance_dt",
64
+ # Process topology.
65
+ "process_source_sink", "process_source_sink_eff",
66
+ "process_source_sink_noEff", "pss_dt",
67
+ "flow_to_n", "flow_from_n",
68
+ "flow_from_commodity_eff", "flow_from_commodity_noEff",
69
+ "flow_to_commodity",
70
+ "pd_neg_cap",
71
+ # CO2.
72
+ "flow_from_co2_priced", "flow_from_co2_priced_noEff",
73
+ "group_co2_max_period", "flow_from_co2_capped",
74
+ "flow_from_co2_capped_noEff", "group_d_co2_capped",
75
+ "group_co2_max_total", "flow_from_co2_capped_total",
76
+ "flow_from_co2_capped_total_noEff",
77
+ # Indirect (CHP).
78
+ "process_indirect", "process_input_flows",
79
+ "process_output_flows", "process_indirect_dt",
80
+ # User constraints.
81
+ "flow_constraint_idx", "cdt_eq", "cdt_le", "cdt_ge",
82
+ # Profiles.
83
+ "process_profile_upper", "process_profile_lower",
84
+ "process_profile_fixed",
85
+ # Invest / divest.
86
+ "ed_invest_set", "ed_divest_set",
87
+ "pd_invest_set", "pd_divest_set",
88
+ "nd_invest_set", "nd_divest_set",
89
+ "edd_invest_set", "edd_invest_lookback_set", "edd_divest_active",
90
+ "e_invest_total", "e_divest_total",
91
+ "ed_invest_period_set", "ed_divest_period_set",
92
+ # Ramp.
93
+ "process_source_sink_ramp_limit_sink_up",
94
+ "process_source_sink_ramp_limit_sink_down",
95
+ "process_source_sink_ramp_limit_source_up",
96
+ "process_source_sink_ramp_limit_source_down",
97
+ # Online / UC.
98
+ "process_online", "process_online_linear", "process_online_integer",
99
+ "process_minload", "process_min_load_eff",
100
+ "p_online_dt", "pdt_online_linear", "pdt_online_integer",
101
+ "pdt_uptime_set", "pdt_downtime_set",
102
+ "uptime_lookback", "downtime_lookback",
103
+ # Storage.
104
+ "nodeState", "nodeState_dt", "nodeState_first_dt",
105
+ "storage_bind_within_timeblock", "storage_bind_forward_only",
106
+ "storage_bind_within_solve",
107
+ "storage_bind_within_solve_blended_weights",
108
+ # Phase D — landed with the new variant's constraint wiring.
109
+ "storage_bind_forward_only_blended_weights",
110
+ # Phase E — landed with the per-period cyclic-closure variant.
111
+ "storage_bind_within_period_blended_weights",
112
+ "storage_fix_start",
113
+ "dtttdt", "dtttdt_forward_only",
114
+ "n_fix_storage_quantity", "ndt_fix_storage_quantity",
115
+ "dtt_timeline_matching", "period_branch", "period_last",
116
+ "nodeState_last_dt",
117
+ "nodeStateBlock", "period_block", "period_block_succ",
118
+ "period_block_time", "dtttdt_block_interior",
119
+ # RP-blended-weights (bind_within_solve_blended_weights) sets / params —
120
+ # per-solve constants that don't change across iterations within a
121
+ # level; same carry-over kind as the intraperiod-block family above.
122
+ "nodeState_rp", "rp_base_period_set",
123
+ "rp_base_chain", "rp_base_first", "rp_base_last",
124
+ "rp_block_first", "p_rp_last_step", "rp_base__rep",
125
+ "arc_sink_block_dt", "arc_source_block_dt",
126
+ "flow_from_nodeBalance_eff", "flow_from_nodeBalance_noEff",
127
+ "node_profile_upper", "node_profile_lower", "node_profile_fixed",
128
+ # Variable cost partitions.
129
+ "pssdt_varCost_noEff", "pssdt_varCost_eff_unit_source",
130
+ "pssdt_varCost_eff_unit_sink", "pssdt_varCost_eff_connection",
131
+ # Group slack.
132
+ "groupCapacityMargin", "groupInertia", "groupNonSync",
133
+ "group_node", "process_unit",
134
+ "process_sink_inertia", "process_source_inertia",
135
+ "process_sink_nonSync", "process_group_inside_nonSync",
136
+ # Reserves.
137
+ "reserve_upDown_group",
138
+ "reserve_upDown_group_method_timeseries",
139
+ "reserve_upDown_group_method_dynamic",
140
+ "reserve_upDown_group_method_n_1",
141
+ "prundt", "process_reserve_upDown_node_active",
142
+ "process_reserve_upDown_node_increase_reserve_ratio",
143
+ "process_reserve_upDown_node_large_failure_ratio",
144
+ # Cumulative invest / group invest.
145
+ "ed_invest_forbidden_no_investment", "ed_invest_cumulative",
146
+ "group_entity", "g_invest_total", "g_divest_total",
147
+ "g_invest_cumulative", "gd_invest_period", "gd_divest_period",
148
+ "group_process_node",
149
+ # Delays.
150
+ "process_delayed", "process_delayed__duration",
151
+ "process_source_delayed", "process_source_undelayed",
152
+ "process_source_sink_delayed", "process_source_sink_undelayed",
153
+ "dtt__delay_duration",
154
+ )
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Clean-mapping Params for warm updates.
159
+ #
160
+ # Only Params whose contribution to the LP is exactly "RHS of constraint X"
161
+ # OR "objective coefficient of variable Y" via a single-Param algebraic
162
+ # pathway can be warm-updated cleanly. Multi-Param composite expressions
163
+ # (e.g. ``vq_up * p_penalty_up * p_node_capacity_for_scaling * op_factor``)
164
+ # would require the engine to track which Params feed which LP cells —
165
+ # that's WarmProblem's deferred Phase 2.
166
+ #
167
+ # Each entry is (flexdata_field, kind, target, transform, over_field).
168
+ # ``kind`` is either "rhs" (constraint RHS) or "obj" (variable objective
169
+ # coefficient). ``target`` is the constraint or variable name in the
170
+ # built Problem. ``transform`` is None (push the Param as-is) or "neg"
171
+ # (push -Param). ``over_field`` names the FlexData index frame that the
172
+ # constraint was built ``over=`` (used to position-align values when the
173
+ # new sub-solve has different dim labels but same row counts — the
174
+ # rolling-horizon case). None means push the Param's value column
175
+ # directly (used when the constraint axis dim values are stable across
176
+ # rolls, e.g. (n,) for storage-anchor handoff).
177
+ #
178
+ # Adding entries here only widens the set of transitions for which warm
179
+ # update is attempted; if a transition's diff falls entirely inside this
180
+ # set it stays warm, otherwise it falls back to cold rebuild.
181
+
182
+ _WARM_PARAMS: tuple[tuple[str, str, str, str | None, str | None], ...] = (
183
+ ("p_inflow", "rhs", "nodeBalance_eq", "neg", "nodeBalance_dt"),
184
+ )
185
+
186
+ # Params that participate in composite LP cells (multi-Param products).
187
+ # When ``run_chain_from_db(..., warm=True)`` is invoked, the WarmProblem is told to
188
+ # track these via :meth:`polar_high.WarmProblem.declare_mutable` so per-cell
189
+ # auto-update can fire on transitions where any of them differs between
190
+ # sub-solves. This widens the warm-compatible regime BEYOND the clean-RHS
191
+ # subset above to cover:
192
+ # * slack penalties scaled by op-factor and capacity_for_scaling,
193
+ # * commodity-price terms multiplied by step_duration / inflation /
194
+ # period_share / cost_weight,
195
+ # * storage-anchor RHS terms ``p_state_start * p_state_existing_capacity``
196
+ # and ``p_roll_continue_state``,
197
+ # * per-(d,t) fix-storage RHS / inflow time-series.
198
+ # The list is kept narrow; additions are cheap (~tens of MB of side-table
199
+ # storage in the worst case) but each new entry has to be vetted against
200
+ # the auto-update math (numerator vs denominator direction recovery).
201
+ _MUTABLE_PARAMS: tuple[str, ...] = (
202
+ "p_inflow",
203
+ "p_penalty_up", "p_penalty_down",
204
+ "p_state_start", "p_roll_continue_state",
205
+ "p_fix_storage_quantity",
206
+ "p_commodity_price",
207
+ "p_step_duration", "p_timestep_weight",
208
+ "p_inflation_op", "p_period_share",
209
+ "p_node_capacity_for_scaling",
210
+ "p_state_existing_capacity",
211
+ )
212
+
213
+ # Param fields that we know change between sub-solves but cannot warm-update
214
+ # (touched by composite expressions, multi-constraint patterns, or matrix
215
+ # coefficients that would require :meth:`WarmProblem.update_coef`). When
216
+ # any of these differ between consecutive sub-solves and the structural
217
+ # fingerprint matches, we still cold-rebuild — they're listed here purely
218
+ # for clarity / future Phase-2 work.
219
+ #
220
+ # D1 audit (2026-05-03; see ``audit/handoff_full_parity_gaps.md`` §D1)
221
+ # categorised every entry on the
222
+ # ``work_multi_fullYear_battery_nested_multi_invest`` 80-roll chain:
223
+ #
224
+ # * **No-op-on-tested-fixtures** entries (``presence_count == 0`` AND
225
+ # ``diff_count == 0`` across every observed transition) are split
226
+ # into :data:`_WARM_PARAMS_NO_OP` for documentation — they remain
227
+ # here too so a regression on a fixture that DOES populate them is
228
+ # still caught.
229
+ # * **Gated-by-dormant-feature** entries are listed in
230
+ # :data:`_WARM_PARAM_GATES`; on transitions where every gate field
231
+ # is None the diff is "phantom" (the consuming constraint family
232
+ # was never emitted) and we short-circuit the cold-rebuild fallback.
233
+ # * **Sum-collapse RHS-side** entries (Params reaching the LP only via
234
+ # constraint RHS as composite anonymous Params — e.g.
235
+ # ``p_profile_value`` going through ``p_profile_value
236
+ # · p_process_existing_count [· p_process_availability]`` into the
237
+ # RHS of ``profile_flow_*``, or ``p_roll_continue_state`` rebuilt
238
+ # into a sparse ``(n, d, t)`` Param dropped into nodeBalance LHS
239
+ # constants) remain genuine cold-rebuild triggers. Hand-coded
240
+ # warm-update exception paths for these are blocked on engine-side
241
+ # RHS source-tracking AND on rolling-horizon t-label-shift handling
242
+ # for ``nodeState_first_dt`` / ``dtt_timeline_matching`` — both
243
+ # explicitly out of scope for D1 (engine refactor; see follow-ups
244
+ # in ``audit/handoff_param_tracked_autoupdate.md``).
245
+ _WARM_PARAMS_DEFERRED: tuple[str, ...] = (
246
+ # Slack-penalty composites: vq * p_penalty_* * op_factor * scaling.
247
+ "p_penalty_up", "p_penalty_down",
248
+ # Time-weight composites that touch every (d,t)-keyed obj/lhs term.
249
+ "p_step_duration", "p_inflation_op",
250
+ "p_period_share", "p_timestep_weight",
251
+ # Storage handoff / anchor — multi-cstr.
252
+ "p_state_start", "p_roll_continue_state",
253
+ "p_fix_storage_quantity", "p_state_existing_capacity",
254
+ "p_state_unitsize", "p_state_self_discharge", "p_state_upper",
255
+ "p_storage_state_reference_price",
256
+ # Invest handoff — RHS of multiple invest/divest cstrs.
257
+ "p_entity_previously_invested_capacity",
258
+ "p_entity_invested", "p_entity_divested",
259
+ "p_entity_max_units", "p_entity_all_existing",
260
+ "ed_lifetime_fixed_cost", "ed_lifetime_fixed_cost_divest",
261
+ "ed_entity_annual_discounted", "ed_entity_annual_divest_discounted",
262
+ "e_invest_max_total", "e_divest_max_total",
263
+ "ed_invest_max_period", "ed_divest_max_period",
264
+ # Commodity / CO2 — composite obj.
265
+ "p_commodity_price", "p_co2_price", "p_co2_max_period",
266
+ "p_co2_max_total", "p_co2_content",
267
+ # Commodity ladder (cumulative + annual) — RHS / cap terms of
268
+ # ``ladder_tier_cap_cumulative_roll`` / ``ladder_tier_cap_annual_roll``.
269
+ # The rolling cumulative accumulator (``p_ladder_cum_realized_mwh``)
270
+ # AND the per-roll period-fill fraction (``p_f_d_k``) BOTH change
271
+ # between rolls of a rolling-window solve — they encode the cross-
272
+ # solve carry of realised MWh and the current roll's share of each
273
+ # period, respectively. Without listing them here, ``_apply_warm_updates``
274
+ # silently kept roll N+1's LP at roll N's RHS, so the tier-cap
275
+ # constraint never tightened and roll N+1's v_trade collapsed to
276
+ # roll N's solution. Symptom: tests/test_commodity_ladder_rolling.py
277
+ # ``TestWithinPeriodCumulativeRolling::test_within_period_cumulative_completes``
278
+ # (only p2020 in the final accumulator) and
279
+ # ``TestCumulativeLadderBindingCap::test_roll2_uses_tier2_only_after_roll1_saturates_cap``
280
+ # (roll-2 v_trade zero across all tiers). Listing them as deferred
281
+ # forces a cold rebuild on any per-roll diff — the ladder constraints
282
+ # touch composite LP cells with no warm-update side-table entry, so
283
+ # cold rebuild is the only correct option here.
284
+ "p_ladder_cum_realized_mwh", "p_f_d_k",
285
+ "p_ladder_cum_price", "p_ladder_cum_quantity",
286
+ "p_ladder_ann_price", "p_ladder_ann_quantity",
287
+ # Process topology Params used in many cstrs / objs.
288
+ "p_unitsize", "p_flow_upper", "p_flow_upper_existing",
289
+ "p_slope", "p_process_existing_count", "p_process_availability",
290
+ "p_node_availability",
291
+ # Profile Params — drive process_profile_* cstrs.
292
+ "p_profile_value",
293
+ # User constraints.
294
+ "p_flow_constraint_coef", "p_constraint_constant",
295
+ "p_node_constraint_invested_capacity_coeff",
296
+ "p_process_constraint_invested_capacity_coeff",
297
+ "p_node_constraint_state_coeff",
298
+ "p_node_constraint_prebuilt_capacity_coeff",
299
+ "p_process_constraint_prebuilt_capacity_coeff",
300
+ # Variable cost partitions.
301
+ "p_pssdt_varCost", "p_pdt_varCost_source",
302
+ "p_pdt_varCost_sink", "p_pdt_varCost_process",
303
+ # Online / UC.
304
+ "p_startup_cost", "p_section", "p_min_load",
305
+ # Ramp speeds.
306
+ "p_ramp_speed_up_sink", "p_ramp_speed_down_sink",
307
+ "p_ramp_speed_up_source", "p_ramp_speed_down_source",
308
+ # Capacity scaling.
309
+ "p_node_capacity_for_scaling", "p_group_capacity_for_scaling",
310
+ # Inflow (split into positive / negative for slack scaling — feeds
311
+ # nodeBalance terms beyond just RHS).
312
+ "p_positive_inflow", "p_negative_inflow", "pdtNodeInflow_per_step",
313
+ # Existing-fixed cost on entities.
314
+ "p_ed_fixed_cost",
315
+ # Group reserves / capacity-margin / inertia.
316
+ "pdGroup_capacity_margin", "pdGroup_penalty_capacity_margin",
317
+ "pdGroup_inertia_limit", "pdGroup_penalty_inertia",
318
+ "pdGroup_non_synchronous_limit", "pdGroup_penalty_non_synchronous",
319
+ "p_inv_group_cap",
320
+ "p_process_sink_inertia_constant", "p_process_source_inertia_constant",
321
+ # Reserves.
322
+ "pdtReserve_upDown_group_reservation",
323
+ "p_reserve_upDown_group_penalty_reserve",
324
+ "p_process_reserve_upDown_node_reliability",
325
+ "p_process_reserve_upDown_node_max_share",
326
+ "p_process_reserve_upDown_node_large_failure_ratio_value",
327
+ "p_process_reserve_upDown_node_increase_reserve_ratio_value",
328
+ # Cumulative invest / group invest Params.
329
+ "ed_invest_min_period", "ed_divest_min_period",
330
+ "e_invest_min_total", "e_divest_min_total",
331
+ "ed_cumulative_max_capacity", "ed_cumulative_min_capacity",
332
+ "p_group_invest_max_period", "p_group_invest_min_period",
333
+ "p_group_retire_max_period", "p_group_retire_min_period",
334
+ "p_group_invest_max_total", "p_group_invest_min_total",
335
+ "p_group_retire_max_total", "p_group_retire_min_total",
336
+ "p_group_invest_max_cumulative", "p_group_invest_min_cumulative",
337
+ "p_group_max_cumulative_flow", "p_group_min_cumulative_flow",
338
+ "pd_max_cumulative_flow", "pd_min_cumulative_flow",
339
+ "pdt_max_instant_flow", "pdt_min_instant_flow",
340
+ # Block / per-arc step durations.
341
+ "p_arc_step_duration_sink", "p_arc_step_duration_source",
342
+ "p_arc_sink_weight", "p_arc_source_weight",
343
+ # Delays.
344
+ "p_process_delay_weight",
345
+ )
346
+
347
+
348
+ # Subset of :data:`_WARM_PARAMS_DEFERRED` that the D1 audit observed to
349
+ # never differ in any tested chain transition (presence_count == 0 OR
350
+ # diff_count == 0 across every observed transition). Kept as a tuple
351
+ # rather than removed so a future fixture that DOES populate one of
352
+ # these still gets caught by :data:`_WARM_PARAMS_DEFERRED`'s diff scan.
353
+ # This list is documentation only — :func:`_apply_warm_updates` does
354
+ # not consult it.
355
+ _WARM_PARAMS_NO_OP: tuple[str, ...] = (
356
+ # Reserves — none of the in-tree fixtures exercise reserve scenarios.
357
+ "pdtReserve_upDown_group_reservation",
358
+ "p_reserve_upDown_group_penalty_reserve",
359
+ "p_process_reserve_upDown_node_reliability",
360
+ "p_process_reserve_upDown_node_max_share",
361
+ "p_process_reserve_upDown_node_large_failure_ratio_value",
362
+ "p_process_reserve_upDown_node_increase_reserve_ratio_value",
363
+ # Cumulative invest / group invest — not active on the multi-invest
364
+ # nested fixture.
365
+ "ed_invest_min_period", "ed_divest_min_period",
366
+ "e_invest_min_total", "e_divest_min_total",
367
+ "ed_cumulative_max_capacity", "ed_cumulative_min_capacity",
368
+ "p_group_invest_max_period", "p_group_invest_min_period",
369
+ "p_group_retire_max_period", "p_group_retire_min_period",
370
+ "p_group_invest_max_total", "p_group_invest_min_total",
371
+ "p_group_retire_max_total", "p_group_retire_min_total",
372
+ "p_group_invest_max_cumulative", "p_group_invest_min_cumulative",
373
+ "p_group_max_cumulative_flow", "p_group_min_cumulative_flow",
374
+ "pd_max_cumulative_flow", "pd_min_cumulative_flow",
375
+ "pdt_max_instant_flow", "pdt_min_instant_flow",
376
+ # Block / per-arc step durations — only used in multi-block fixtures.
377
+ "p_arc_step_duration_sink", "p_arc_step_duration_source",
378
+ "p_arc_sink_weight", "p_arc_source_weight",
379
+ # Delays.
380
+ "p_process_delay_weight",
381
+ # Variable-cost partitions — none of the tested fixtures exercise
382
+ # priced flows yet.
383
+ "p_pssdt_varCost", "p_pdt_varCost_source",
384
+ "p_pdt_varCost_sink", "p_pdt_varCost_process",
385
+ # CO2 (not active on this fixture).
386
+ "p_co2_price", "p_co2_max_period", "p_co2_max_total", "p_co2_content",
387
+ # Online / UC.
388
+ "p_startup_cost", "p_section", "p_min_load",
389
+ # Ramp speeds.
390
+ "p_ramp_speed_up_sink", "p_ramp_speed_down_sink",
391
+ "p_ramp_speed_up_source", "p_ramp_speed_down_source",
392
+ # Per-process inertia constants.
393
+ "p_process_sink_inertia_constant", "p_process_source_inertia_constant",
394
+ # Divest siblings (only present when divest is active).
395
+ "p_entity_invested", "p_entity_divested",
396
+ "ed_lifetime_fixed_cost_divest", "ed_entity_annual_divest_discounted",
397
+ "e_divest_max_total", "ed_divest_max_period",
398
+ )
399
+
400
+
401
+ # Per-Param "structural gates": tuples of FlexData field names whose
402
+ # non-None state determines whether the Param can possibly contribute to
403
+ # any LP cell on the new sub-solve. When ALL gates are None on
404
+ # ``nxt`` (and, by fingerprint match, also on ``prior``), the consuming
405
+ # constraint family was never emitted and the Param's diff is a phantom
406
+ # — :func:`_apply_warm_updates` skips the cold-rebuild check.
407
+ #
408
+ # Conservative by design: only Params whose consuming pathways are
409
+ # fully gated by tracked structural fields appear here. Params with
410
+ # unconditional consumers (e.g. ``p_inflow`` always reaches
411
+ # ``nodeBalance_eq``) are absent and treated as always-active.
412
+ _WARM_PARAM_GATES: dict[str, tuple[str, ...]] = {
413
+ # Group-slack inflow consumers (capacityMargin / inertia /
414
+ # non_sync_constraint).
415
+ "p_positive_inflow": ("groupNonSync",),
416
+ "p_negative_inflow": ("groupNonSync",),
417
+ "pdtNodeInflow_per_step": ("groupCapacityMargin",),
418
+ "pdGroup_capacity_margin": ("groupCapacityMargin",),
419
+ "pdGroup_penalty_capacity_margin": ("groupCapacityMargin",),
420
+ "pdGroup_inertia_limit": ("groupInertia",),
421
+ "pdGroup_penalty_inertia": ("groupInertia",),
422
+ "pdGroup_non_synchronous_limit": ("groupNonSync",),
423
+ "pdGroup_penalty_non_synchronous": ("groupNonSync",),
424
+ "p_inv_group_cap": ("groupCapacityMargin", "groupInertia",
425
+ "groupNonSync"),
426
+ "p_process_sink_inertia_constant": ("groupInertia",),
427
+ "p_process_source_inertia_constant": ("groupInertia",),
428
+ "p_group_capacity_for_scaling": ("groupCapacityMargin", "groupInertia",
429
+ "groupNonSync"),
430
+ # Reserves.
431
+ "pdtReserve_upDown_group_reservation": ("reserve_upDown_group",),
432
+ "p_reserve_upDown_group_penalty_reserve": ("reserve_upDown_group",),
433
+ "p_process_reserve_upDown_node_reliability": ("prundt",),
434
+ "p_process_reserve_upDown_node_max_share": ("prundt",),
435
+ "p_process_reserve_upDown_node_large_failure_ratio_value":
436
+ ("process_reserve_upDown_node_large_failure_ratio",),
437
+ "p_process_reserve_upDown_node_increase_reserve_ratio_value":
438
+ ("process_reserve_upDown_node_increase_reserve_ratio",),
439
+ # CO2.
440
+ "p_co2_price": ("flow_from_co2_priced",),
441
+ "p_co2_max_period": ("group_co2_max_period",),
442
+ "p_co2_max_total": ("group_co2_max_total",),
443
+ "p_co2_content": ("flow_from_co2_priced", "flow_from_co2_capped",
444
+ "group_co2_max_period",
445
+ "flow_from_co2_capped_total",
446
+ "group_co2_max_total"),
447
+ # Online / UC — only emitted when online sets are populated.
448
+ "p_startup_cost": ("process_online",),
449
+ "p_section": ("process_min_load_eff",),
450
+ "p_min_load": ("process_minload",),
451
+ # Ramps.
452
+ "p_ramp_speed_up_sink": ("process_source_sink_ramp_limit_sink_up",),
453
+ "p_ramp_speed_down_sink": ("process_source_sink_ramp_limit_sink_down",),
454
+ "p_ramp_speed_up_source": ("process_source_sink_ramp_limit_source_up",),
455
+ "p_ramp_speed_down_source":
456
+ ("process_source_sink_ramp_limit_source_down",),
457
+ # Cumulative invest / group invest — gated by their respective sets.
458
+ "ed_invest_min_period": ("ed_invest_period_set",),
459
+ "ed_divest_min_period": ("ed_divest_period_set",),
460
+ "e_invest_min_total": ("e_invest_total",),
461
+ "e_divest_min_total": ("e_divest_total",),
462
+ "ed_cumulative_max_capacity": ("ed_invest_cumulative",),
463
+ "ed_cumulative_min_capacity": ("ed_invest_cumulative",),
464
+ "p_group_invest_max_period": ("gd_invest_period",),
465
+ "p_group_invest_min_period": ("gd_invest_period",),
466
+ "p_group_retire_max_period": ("gd_divest_period",),
467
+ "p_group_retire_min_period": ("gd_divest_period",),
468
+ "p_group_invest_max_total": ("g_invest_total",),
469
+ "p_group_invest_min_total": ("g_invest_total",),
470
+ "p_group_retire_max_total": ("g_divest_total",),
471
+ "p_group_retire_min_total": ("g_divest_total",),
472
+ "p_group_invest_max_cumulative": ("g_invest_cumulative",),
473
+ "p_group_invest_min_cumulative": ("g_invest_cumulative",),
474
+ "p_group_max_cumulative_flow": ("group_process_node",),
475
+ "p_group_min_cumulative_flow": ("group_process_node",),
476
+ "pd_max_cumulative_flow": ("group_process_node",),
477
+ "pd_min_cumulative_flow": ("group_process_node",),
478
+ "pdt_max_instant_flow": ("group_process_node",),
479
+ "pdt_min_instant_flow": ("group_process_node",),
480
+ # Variable-cost partitions.
481
+ "p_pssdt_varCost": ("pssdt_varCost_noEff",
482
+ "pssdt_varCost_eff_unit_source",
483
+ "pssdt_varCost_eff_unit_sink",
484
+ "pssdt_varCost_eff_connection"),
485
+ "p_pdt_varCost_source": ("pssdt_varCost_eff_unit_source",
486
+ "pssdt_varCost_eff_connection"),
487
+ "p_pdt_varCost_sink": ("pssdt_varCost_eff_unit_sink",
488
+ "pssdt_varCost_eff_connection"),
489
+ "p_pdt_varCost_process": ("pssdt_varCost_noEff",),
490
+ # Block / per-arc step durations.
491
+ "p_arc_step_duration_sink": ("arc_sink_block_dt",),
492
+ "p_arc_step_duration_source": ("arc_source_block_dt",),
493
+ "p_arc_sink_weight": ("arc_sink_block_dt",),
494
+ "p_arc_source_weight": ("arc_source_block_dt",),
495
+ # Delays.
496
+ "p_process_delay_weight": ("process_delayed",),
497
+ # User constraints.
498
+ "p_flow_constraint_coef": ("flow_constraint_idx",),
499
+ "p_constraint_constant": ("cdt_eq", "cdt_le", "cdt_ge"),
500
+ "p_node_constraint_invested_capacity_coeff":
501
+ ("flow_constraint_idx", "cdt_eq", "cdt_le", "cdt_ge"),
502
+ "p_process_constraint_invested_capacity_coeff":
503
+ ("flow_constraint_idx", "cdt_eq", "cdt_le", "cdt_ge"),
504
+ "p_node_constraint_state_coeff":
505
+ ("flow_constraint_idx", "cdt_eq", "cdt_le", "cdt_ge"),
506
+ "p_node_constraint_prebuilt_capacity_coeff":
507
+ ("flow_constraint_idx", "cdt_eq", "cdt_le", "cdt_ge"),
508
+ "p_process_constraint_prebuilt_capacity_coeff":
509
+ ("flow_constraint_idx", "cdt_eq", "cdt_le", "cdt_ge"),
510
+ }
511
+
512
+
513
+ class _IncompatibleUpdate(Exception):
514
+ """Raised by :func:`_apply_warm_updates` when the difference between
515
+ two consecutive sub-solves' FlexData includes Params outside the
516
+ clean-mapping set, forcing a cold rebuild for the next sub-solve."""
517
+
518
+
519
+ def _fingerprint(data: "FlexData") -> tuple:
520
+ """Compute a structural fingerprint of a FlexData snapshot.
521
+
522
+ Returns a tuple of ``(field_name, height_or_None)`` pairs covering
523
+ every field listed in :data:`_STRUCTURAL_FIELDS`. Two FlexData
524
+ snapshots with equal fingerprints emit identically-shaped LPs (set
525
+ of vars and cstrs match by row count and dim signature) under the
526
+ current ``build_flextool`` rules.
527
+
528
+ ``height_or_None`` is ``None`` when the field is unset and the
529
+ integer ``height`` when it's a polars DataFrame. Boolean / scalar
530
+ fields contribute their value directly.
531
+ """
532
+ out = []
533
+ for name in _STRUCTURAL_FIELDS:
534
+ v = getattr(data, name, None)
535
+ if v is None:
536
+ out.append((name, None))
537
+ elif isinstance(v, pl.DataFrame):
538
+ out.append((name, int(v.height)))
539
+ elif isinstance(v, bool):
540
+ out.append((name, bool(v)))
541
+ else:
542
+ # Unexpected — be conservative and force-mismatch.
543
+ out.append((name, repr(type(v).__name__)))
544
+ # ``p_nested_solve_first`` is a tri-state flag that swaps a whole
545
+ # constraint family in/out — track it explicitly.
546
+ out.append(("p_nested_solve_first",
547
+ getattr(data, "p_nested_solve_first", None)))
548
+ return tuple(out)
549
+
550
+
551
+ def _param_frame_equal(a, b) -> bool:
552
+ """Return True if two polar_high Params have identical frames.
553
+
554
+ Compares dim signature and value column row-by-row. Robust to
555
+ polars row-order differences via a sort on the dim columns.
556
+ """
557
+ if a is None and b is None:
558
+ return True
559
+ if (a is None) != (b is None):
560
+ return False
561
+ if a.dims != b.dims:
562
+ return False
563
+ af = a.frame
564
+ bf = b.frame
565
+ if af.height != bf.height:
566
+ return False
567
+ if af.height == 0:
568
+ return True
569
+ if a.dims:
570
+ cols = list(a.dims)
571
+ af = af.sort(cols)
572
+ bf = bf.sort(cols)
573
+ return af.equals(bf)
574
+
575
+
576
+ def _param_values_position_equal(a, b) -> bool:
577
+ """Return True if two Params have value columns that are equal at
578
+ matching positions (after sorting each by its full set of dim
579
+ columns).
580
+
581
+ Captures the rolling-horizon case where dim labels (e.g. ``t``)
582
+ shift between sub-solves but the per-position values (e.g. constant
583
+ ``3000.0`` slack penalties for every (n,d,t)) stay identical.
584
+
585
+ Returns ``False`` if dim signatures differ or row counts differ.
586
+ Returns ``True`` if both Params are ``None``. Otherwise compares
587
+ the sorted-by-dims value column element-wise within float64
588
+ precision.
589
+ """
590
+ if a is None and b is None:
591
+ return True
592
+ if (a is None) != (b is None):
593
+ return False
594
+ if a.dims != b.dims:
595
+ return False
596
+ af = a.frame
597
+ bf = b.frame
598
+ if af.height != bf.height:
599
+ return False
600
+ if af.height == 0:
601
+ return True
602
+ if a.dims:
603
+ # Sort each frame by its dim columns positionally — the t-labels
604
+ # in `a` and `b` differ for rolling-horizon snapshots, so we
605
+ # can't just compare frames as-is. Sorting brings the value
606
+ # columns into 1-to-1 positional correspondence assuming the
607
+ # sort orders agree (which they do when both sub-solves have
608
+ # the same number of dim-tuples). This is a fast O(n log n)
609
+ # numeric check rather than a full frame equality.
610
+ cols = list(a.dims)
611
+ av = af.sort(cols)["value"].to_numpy()
612
+ bv = bf.sort(cols)["value"].to_numpy()
613
+ else:
614
+ av = af["value"].to_numpy()
615
+ bv = bf["value"].to_numpy()
616
+ import numpy as np
617
+ return bool(np.array_equal(av, bv))
618
+
619
+
620
+ def _gate_active(d: "FlexData", fld: str) -> bool:
621
+ """Return True if Param ``fld`` can possibly contribute to an LP
622
+ cell on FlexData ``d`` based on its consuming-feature gates.
623
+
624
+ Defaults to True (assume active) for Params absent from
625
+ :data:`_WARM_PARAM_GATES` — gating is opt-in and conservative.
626
+ Returns False ONLY when every gate field is None or an empty
627
+ polars frame on ``d``; that means the consuming constraint family
628
+ was never emitted, so the Param is dormant on this LP and a diff
629
+ in its values can be safely ignored.
630
+ """
631
+ gates = _WARM_PARAM_GATES.get(fld)
632
+ if not gates:
633
+ return True
634
+ for g in gates:
635
+ v = getattr(d, g, None)
636
+ if v is None:
637
+ continue
638
+ # Empty polars frame counts as "gate inactive" — the constraint
639
+ # iterator yields zero rows.
640
+ try:
641
+ if hasattr(v, "height") and v.height == 0:
642
+ continue
643
+ except Exception:
644
+ pass
645
+ return True
646
+ return False
647
+
648
+
649
+ def _apply_warm_updates(warm: WarmProblem,
650
+ prior: "FlexData", nxt: "FlexData") -> int:
651
+ """Push every changed clean-mapping Param from ``prior`` → ``nxt``
652
+ into ``warm``.
653
+
654
+ Returns the count of warm-update calls executed.
655
+
656
+ Raises :class:`_IncompatibleUpdate` if any Param in
657
+ :data:`_WARM_PARAMS_DEFERRED` differs between ``prior`` and ``nxt``
658
+ AND that Param is NOT in :data:`_MUTABLE_PARAMS`. Mutable Params
659
+ are auto-updated via :meth:`polar_high.WarmProblem.update_param`.
660
+ Phantom diffs (Params whose consuming feature is dormant per
661
+ :data:`_WARM_PARAM_GATES`) are skipped — those are the audit-proven
662
+ no-effect cases on this LP. Mutable Params with zero tracked cells
663
+ (Sum-collapse on the build-side composite Param construction) also
664
+ raise :class:`_IncompatibleUpdate` rather than silently no-op'ing
665
+ via ``update_param`` — the silent-corruption guard.
666
+ """
667
+ # First, scan the deferred (force-cold) list — any difference there
668
+ # is a hard "cold rebuild" signal UNLESS the field is in
669
+ # _MUTABLE_PARAMS, in which case auto-update will handle it below.
670
+ mutable_set = set(_MUTABLE_PARAMS)
671
+ deferred_diffs: dict[str, "object"] = {}
672
+ for fld in _WARM_PARAMS_DEFERRED:
673
+ prior_p = getattr(prior, fld, None)
674
+ next_p = getattr(nxt, fld, None)
675
+ if not _param_values_position_equal(prior_p, next_p):
676
+ if fld in mutable_set:
677
+ deferred_diffs[fld] = next_p
678
+ continue
679
+ if not _gate_active(nxt, fld):
680
+ # Phantom diff — the consuming feature is dormant on
681
+ # this sub-solve (e.g. p_negative_inflow when
682
+ # groupNonSync is None), so the Param can't reach any
683
+ # LP cell. Safe to skip; ignoring this diff is
684
+ # equivalent to cold-rebuilding and re-evaluating an
685
+ # unused Param.
686
+ continue
687
+ raise _IncompatibleUpdate(
688
+ f"Param {fld!r} differs between sub-solves and is not in "
689
+ f"the clean-mapping set — falling back to cold rebuild.")
690
+
691
+ import numpy as np
692
+
693
+ n_updates = 0
694
+ for fld, kind, target, transform, over_field in _WARM_PARAMS:
695
+ prior_p = getattr(prior, fld, None)
696
+ next_p = getattr(nxt, fld, None)
697
+ if _param_frame_equal(prior_p, next_p):
698
+ continue
699
+ if next_p is None:
700
+ # Going from "param present" to "param absent" effectively
701
+ # changes the LP shape — treat as cold.
702
+ raise _IncompatibleUpdate(
703
+ f"Param {fld!r} disappeared between sub-solves; "
704
+ f"falling back to cold rebuild.")
705
+ if kind == "rhs":
706
+ # Resolve the new RHS values positionally aligned to the
707
+ # ORIGINAL over frame's row order. The original over
708
+ # frame's dim labels (e.g. ``t``) differ from ``next_p``'s
709
+ # labels in rolling-horizon scenarios, so we can't rely on
710
+ # WarmProblem.update_rhs's label-based join — it would
711
+ # produce zeros for every row. Instead we resolve the new
712
+ # value vector against the NEW over frame (which has the
713
+ # new t-labels) and push as a positional ndarray of length
714
+ # row_count.
715
+ if over_field is None:
716
+ push = next_p
717
+ if transform == "neg":
718
+ push = -next_p
719
+ warm.update_rhs(target, push)
720
+ else:
721
+ new_over = getattr(nxt, over_field, None)
722
+ if new_over is None:
723
+ # Phase E.3: ``pss_dt`` / ``nodeBalance_dt`` /
724
+ # ``nodeState_dt`` / ``process_indirect_dt`` are no
725
+ # longer materialised on FlexData; build the cross-
726
+ # join on demand from the constituents so warm RHS
727
+ # alignment still works.
728
+ from flextool.engine_polars._pdt_join import (
729
+ compute_pss_dt,
730
+ compute_nodeBalance_dt,
731
+ compute_nodeState_dt,
732
+ compute_process_indirect_dt,
733
+ )
734
+ _compute = {
735
+ "pss_dt": compute_pss_dt,
736
+ "nodeBalance_dt": compute_nodeBalance_dt,
737
+ "nodeState_dt": compute_nodeState_dt,
738
+ "process_indirect_dt": compute_process_indirect_dt,
739
+ }.get(over_field)
740
+ if _compute is not None:
741
+ new_over = _compute(nxt)
742
+ if new_over is None:
743
+ raise _IncompatibleUpdate(
744
+ f"warm-update needs FlexData.{over_field}, but it "
745
+ f"is None on the new sub-solve")
746
+ # Left-join new_over with next_p on shared dims; values
747
+ # come out aligned to new_over's row order, which by
748
+ # fingerprint match has the same row count as the
749
+ # original LP cstr over.
750
+ shared = [c for c in next_p.dims if c in new_over.columns]
751
+ if not shared:
752
+ rhs_vec = np.full(new_over.height,
753
+ float(next_p.frame["value"][0]),
754
+ dtype=np.float64)
755
+ else:
756
+ j = new_over.join(next_p.frame, on=shared, how="left")
757
+ rhs_vec = (j["value"].fill_null(0.0)
758
+ .to_numpy()
759
+ .astype(np.float64, copy=False))
760
+ if transform == "neg":
761
+ rhs_vec = -rhs_vec
762
+ warm.update_rhs(target, rhs_vec)
763
+ elif kind == "obj":
764
+ push = next_p
765
+ if transform == "neg":
766
+ push = -next_p
767
+ warm.update_obj_coef(target, push)
768
+ else:
769
+ raise _IncompatibleUpdate(
770
+ f"unknown warm-update kind {kind!r} for {fld!r}")
771
+ n_updates += 1
772
+
773
+ # Auto-update for declared-mutable Params via the Param-tracked
774
+ # cell map. This handles every composite-Param diff that the
775
+ # clean-RHS / clean-obj path can't represent.
776
+ for fld, next_p in deferred_diffs.items():
777
+ if next_p is None:
778
+ # Param disappeared — can't auto-update (no values to push).
779
+ raise _IncompatibleUpdate(
780
+ f"mutable Param {fld!r} went None between sub-solves; "
781
+ f"falling back to cold rebuild.")
782
+ if fld not in warm._mutable_params:
783
+ # Param was tracked-mutable but the LP didn't actually
784
+ # carry it (build skipped its branch). Different LP — treat
785
+ # as cold.
786
+ raise _IncompatibleUpdate(
787
+ f"mutable Param {fld!r} differs but isn't tracked on "
788
+ f"the warm problem; falling back to cold rebuild.")
789
+ # CRITICAL silent-corruption guard (D1 audit, 2026-05-03).
790
+ # ``WarmProblem.update_param`` silently returns when the Param
791
+ # has no tracked cells — that's correct behaviour for Params
792
+ # whose only effect is on a code path that the engine's
793
+ # source-tracker walks (e.g. p_step_duration on dispatch
794
+ # rolls). But many "mutable" Params reach the LP through
795
+ # composite-anonymous-Param construction in flextool/model.py
796
+ # (Sum-collapse: the new Param is built fresh without a
797
+ # ``name=`` or ``_sources=`` link to the origin Param), and
798
+ # for those the side-table is empty even though the LP DOES
799
+ # depend on the values. Pushing a no-op there leaves stale
800
+ # coefficients in the live LP and silently corrupts the
801
+ # objective.
802
+ #
803
+ # Fall back to cold rebuild whenever a mutable Param differs
804
+ # but has zero tracked cells. Loses warm-mode benefit on
805
+ # those transitions but preserves correctness — the only
806
+ # acceptable trade-off given task constraints.
807
+ cells = warm._param_cells.get(fld)
808
+ has_cells = cells is not None and int(cells["rows"].size) > 0
809
+ if not has_cells and not _gate_active(nxt, fld):
810
+ # Param's gates are dormant — diff is phantom, no-op is
811
+ # genuinely safe (the consuming feature was never built).
812
+ continue
813
+ if not has_cells:
814
+ raise _IncompatibleUpdate(
815
+ f"mutable Param {fld!r} differs but the WarmProblem "
816
+ f"recorded zero tracked cells for it (Sum-collapse on "
817
+ f"the build-side composite-Param construction); "
818
+ f"falling back to cold rebuild to avoid silent stale "
819
+ f"LP coefficients.")
820
+ warm.update_param(fld, next_p)
821
+ n_updates += 1
822
+
823
+ return n_updates
824
+
825
+
826
+ def _build_warm_problem(
827
+ data: "FlexData",
828
+ *,
829
+ scale_the_objective: float = 1.0,
830
+ solver_options: dict | None = None,
831
+ ) -> WarmProblem:
832
+ """Build a fresh :class:`polar_high.WarmProblem` from a FlexData
833
+ snapshot, with all :data:`_MUTABLE_PARAMS` declared mutable so the
834
+ per-cell tracking side-table is populated during the build.
835
+
836
+ Parameters
837
+ ----------
838
+ data
839
+ The :class:`FlexData` bundle for this iteration.
840
+ scale_the_objective
841
+ Multiplier applied to the objective inside ``build_flextool``.
842
+ solver_options
843
+ HiGHS options to apply via ``pb.set_solver_options`` BEFORE
844
+ wrapping in :class:`WarmProblem`. Typically the dict returned
845
+ by :func:`flextool.engine_polars.scaling.recommended_highs_options`.
846
+
847
+ Late-imports ``build_flextool`` to avoid a build-time cycle between
848
+ this module and ``model.py``.
849
+ """
850
+ from flextool.engine_polars.model import build_flextool
851
+
852
+ pb = Problem()
853
+ build_flextool(pb, data, scale_the_objective=scale_the_objective)
854
+ if solver_options:
855
+ pb.set_solver_options(solver_options)
856
+ warm = WarmProblem(pb)
857
+ warm.declare_mutable(*_MUTABLE_PARAMS)
858
+ return warm