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,336 @@
1
+ """YAML audit report for the autoscaler.
2
+
3
+ Layer 1 writes the four ranges + trigger flag. Layer 2 / Layer 3 will
4
+ extend the same file with their decisions (column scalers per quantity,
5
+ ``user_bound_scale`` value applied, etc.) — the structure is kept flat
6
+ and self-documenting so the operator can read it without consulting
7
+ schema docs.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from pathlib import Path
13
+ from typing import Any, Mapping
14
+
15
+ from polar_high.autoscale import Layer3Plan, RangeReport
16
+
17
+ from ._layer2 import Layer2Plan
18
+
19
+
20
+ def _decades(span: tuple[float, float]) -> Any:
21
+ """Return the spread of a range as decades (log10(hi/lo)).
22
+
23
+ ``(nan, nan)`` (empty group) returns ``None`` — the caller renders
24
+ that as ``"empty"`` in the console line.
25
+ """
26
+ lo, hi = span
27
+ if math.isnan(lo) or math.isnan(hi):
28
+ return None
29
+ if lo <= 0.0:
30
+ return None
31
+ if hi <= 0.0:
32
+ return None
33
+ return math.log10(hi / lo)
34
+
35
+
36
+ def _fmt_decades(value: Any) -> str:
37
+ """Render a decade spread as ``"7.1d"`` or ``"empty"``."""
38
+ if value is None:
39
+ return "empty"
40
+ return f"{value:.1f}d"
41
+
42
+
43
+ def _coerce_range(span: tuple[float, float]) -> dict[str, Any]:
44
+ """Render a ``(lo, hi)`` tuple as a small YAML dict.
45
+
46
+ NaN entries (the "no finite non-zero entries" sentinel) become the
47
+ string ``"empty"`` so the YAML is still valid (YAML disallows bare
48
+ NaN in most loaders) and the meaning is unambiguous in a manual read.
49
+ """
50
+ lo, hi = span
51
+ if math.isnan(lo) or math.isnan(hi):
52
+ return {"status": "empty"}
53
+ return {
54
+ "min": float(lo),
55
+ "max": float(hi),
56
+ "ratio": float(hi) / float(lo) if lo != 0 else None,
57
+ }
58
+
59
+
60
+ def _coerce_cross_ratio(value: float) -> Any:
61
+ """Render the cross-group ratio, mapping NaN → ``None``."""
62
+ if math.isnan(value):
63
+ return None
64
+ return float(value)
65
+
66
+
67
+ def _render_layer1(report: RangeReport) -> dict[str, Any]:
68
+ """Build the ``layer1`` section of the autoscaler audit YAML.
69
+
70
+ Kept as a small pure function so Layer 2 / Layer 3 sections can be
71
+ composed alongside it without :func:`write_report` growing branches.
72
+ """
73
+ return {
74
+ "ranges": {
75
+ "matrix": _coerce_range(report.matrix),
76
+ "cost": _coerce_range(report.cost),
77
+ "bound": _coerce_range(report.bound),
78
+ "rhs": _coerce_range(report.rhs),
79
+ },
80
+ "cross_group_max_ratio": _coerce_cross_ratio(report.cross_group_max_ratio),
81
+ "trigger": bool(report.trigger),
82
+ }
83
+
84
+
85
+ def _dump_simple_yaml(obj: Any, indent: int = 0) -> str:
86
+ """Tiny dependency-free YAML emitter.
87
+
88
+ Avoids a hard PyYAML dependency for this stub — the autoscaler only
89
+ ever writes scalar / mapping / list trees with no anchors, references
90
+ or tags, so the surface stays trivial. Keys are emitted in insertion
91
+ order (Python 3.7+ dict semantics) so the operator-visible layout is
92
+ stable. Floats are rendered with ``repr`` so round-tripping into a
93
+ real YAML loader recovers the bit-pattern.
94
+
95
+ If a future phase needs richer YAML (e.g. multi-line strings),
96
+ swap to :mod:`yaml`; the API of :func:`write_report` does not change.
97
+ """
98
+ pad = " " * indent
99
+ if isinstance(obj, Mapping):
100
+ if not obj:
101
+ return "{}"
102
+ lines: list[str] = []
103
+ for k, v in obj.items():
104
+ if isinstance(v, (Mapping, list)):
105
+ rendered = _dump_simple_yaml(v, indent + 2)
106
+ if isinstance(v, Mapping) and v:
107
+ lines.append(f"{pad}{k}:")
108
+ lines.append(rendered)
109
+ elif isinstance(v, list) and v:
110
+ lines.append(f"{pad}{k}:")
111
+ lines.append(rendered)
112
+ else:
113
+ lines.append(f"{pad}{k}: {rendered}")
114
+ else:
115
+ lines.append(f"{pad}{k}: {_scalar(v)}")
116
+ return "\n".join(lines)
117
+ if isinstance(obj, list):
118
+ if not obj:
119
+ return "[]"
120
+ return "\n".join(
121
+ f"{pad}- {_dump_simple_yaml(item, indent + 2).lstrip() if isinstance(item, (Mapping, list)) else _scalar(item)}"
122
+ for item in obj
123
+ )
124
+ return _scalar(obj)
125
+
126
+
127
+ def _scalar(v: Any) -> str:
128
+ """Render a scalar leaf for the YAML emitter."""
129
+ if v is None:
130
+ return "null"
131
+ if isinstance(v, bool):
132
+ return "true" if v else "false"
133
+ if isinstance(v, float):
134
+ if math.isnan(v):
135
+ return ".nan"
136
+ if math.isinf(v):
137
+ return ".inf" if v > 0 else "-.inf"
138
+ return repr(v)
139
+ if isinstance(v, int):
140
+ return str(v)
141
+ s = str(v)
142
+ if any(c in s for c in (":", "#", "\n", "{", "}", "[", "]", "&", "*")) or s.strip() != s:
143
+ # Escape via single-quoted scalar — YAML's safest form.
144
+ return "'" + s.replace("'", "''") + "'"
145
+ return s
146
+
147
+
148
+ def render_layer2(plan: Layer2Plan) -> dict[str, Any]:
149
+ """Build the ``layer2`` section of the autoscaler audit YAML.
150
+
151
+ Captures the per-type exponents, the count of skipped rows /
152
+ integer columns, and the per-type before/after magnitude ranges.
153
+ Also surfaces the post-scale RHS range of the
154
+ ``ladder_tier_cap_annual_roll`` family for the H2_trade sanity
155
+ check — the family the regression handoff identifies as the
156
+ Layer-2 trigger.
157
+ """
158
+ exponents = {t.value: int(e) for t, e in plan.type_exponents.items()}
159
+ type_ranges_before = {
160
+ t.value: _coerce_range(r) for t, r in plan.type_buckets_before.items()
161
+ }
162
+ type_ranges_after = {
163
+ t.value: _coerce_range(r) for t, r in plan.type_buckets_after.items()
164
+ }
165
+ return {
166
+ "type_exponents": exponents,
167
+ "skipped_rows_count": len(plan.skipped_rows),
168
+ "skipped_rows": list(plan.skipped_rows),
169
+ "skipped_integer_cols_count": len(plan.skipped_integer_cols),
170
+ "n_cols": int(plan.col_factors.shape[0]),
171
+ "n_rows": int(plan.row_factors.shape[0]),
172
+ "type_ranges_before": type_ranges_before,
173
+ "type_ranges_after": type_ranges_after,
174
+ }
175
+
176
+
177
+ def render_layer3(plan: Layer3Plan) -> dict[str, Any]:
178
+ """Build the ``layer3`` section of the autoscaler audit YAML.
179
+
180
+ Surfaces the three HiGHS options Layer 3 set and the reasoning
181
+ string so the operator can correlate the YAML entry with the
182
+ one-line log emitted at apply time.
183
+ """
184
+ return {
185
+ "user_objective_scale": int(plan.user_objective_scale),
186
+ "user_bound_scale": int(plan.user_bound_scale),
187
+ "simplex_scale_strategy": int(plan.simplex_scale_strategy),
188
+ "reasoning": str(plan.reasoning),
189
+ }
190
+
191
+
192
+ def write_report(result: Mapping[str, Any], path: Path | str) -> Path:
193
+ """Serialise the autoscaler result tree to ``path`` as YAML.
194
+
195
+ ``result`` is a mapping whose top-level keys are layer names
196
+ (``"layer1"``, later ``"layer2"`` / ``"layer3"``) and whose values
197
+ are the per-layer payloads. Layer 1 hands a :class:`RangeReport`;
198
+ we render it via :func:`_render_layer1`. Unknown payload types pass
199
+ through to the generic dumper — keeps the door open for later
200
+ phases without coupling the layers via shared schema.
201
+
202
+ Returns the resolved :class:`Path` that was written, for caller
203
+ bookkeeping (the wire-in logs it).
204
+ """
205
+ rendered: dict[str, Any] = {}
206
+ for key, payload in result.items():
207
+ if isinstance(payload, RangeReport):
208
+ rendered[key] = _render_layer1(payload)
209
+ else:
210
+ rendered[key] = payload
211
+
212
+ path = Path(path)
213
+ path.parent.mkdir(parents=True, exist_ok=True)
214
+ text = _dump_simple_yaml(rendered)
215
+ path.write_text(text + "\n", encoding="utf-8")
216
+ return path
217
+
218
+
219
+ def format_console_summary(
220
+ *,
221
+ ranges_pre: RangeReport,
222
+ ranges_post: "RangeReport | None",
223
+ layer2_plan: "Layer2Plan | None",
224
+ layer3_plan: "Layer3Plan | None",
225
+ threshold_decades: float,
226
+ ) -> str:
227
+ """Compose the one-line console summary surfaced when autoscale fires.
228
+
229
+ Renders the four pre-Layer-1 spreads in decades, the per-type Layer 2
230
+ exponents, the Layer 3 ``user_*_scale`` + ``simplex_scale_strategy``
231
+ values, and the post-action four-range spreads so the operator sees
232
+ "before → after" at a glance.
233
+
234
+ When ``ranges_pre.trigger`` is False the function returns the quieter
235
+ "within comfort zone" confirmation — callers can branch on that path
236
+ by inspecting the report themselves, but exposing the message here
237
+ keeps the format definition in one place.
238
+ """
239
+ if not ranges_pre.trigger:
240
+ return (
241
+ "Autoscale by polar-high: LP within HiGHS comfort zone "
242
+ f"(spreads <= {threshold_decades:g} decades) — no scaling applied"
243
+ )
244
+
245
+ pre_matrix = _fmt_decades(_decades(ranges_pre.matrix))
246
+ pre_cost = _fmt_decades(_decades(ranges_pre.cost))
247
+ pre_bound = _fmt_decades(_decades(ranges_pre.bound))
248
+ pre_rhs = _fmt_decades(_decades(ranges_pre.rhs))
249
+
250
+ parts: list[str] = [
251
+ f"Autoscale by polar-high: ranges pre Matrix={pre_matrix} Cost={pre_cost} "
252
+ f"Bound={pre_bound} RHS={pre_rhs}"
253
+ ]
254
+
255
+ if layer2_plan is not None:
256
+ exps = ", ".join(
257
+ f"{t.value}:{e:+d}" for t, e in layer2_plan.type_exponents.items()
258
+ )
259
+ skipped = len(layer2_plan.skipped_rows)
260
+ parts.append(
261
+ f"L2 exponents {{{exps}}}"
262
+ + (f" skipped_rows={skipped}" if skipped else "")
263
+ )
264
+ else:
265
+ parts.append("L2 skipped")
266
+
267
+ if layer3_plan is not None:
268
+ parts.append(
269
+ f"L3 user_obj={layer3_plan.user_objective_scale} "
270
+ f"user_bnd={layer3_plan.user_bound_scale} "
271
+ f"simplex={layer3_plan.simplex_scale_strategy}"
272
+ )
273
+ else:
274
+ parts.append("L3 skipped")
275
+
276
+ if ranges_post is not None:
277
+ post_matrix = _fmt_decades(_decades(ranges_post.matrix))
278
+ post_cost = _fmt_decades(_decades(ranges_post.cost))
279
+ post_bound = _fmt_decades(_decades(ranges_post.bound))
280
+ post_rhs = _fmt_decades(_decades(ranges_post.rhs))
281
+ parts.append(
282
+ f"ranges post Matrix={post_matrix} Cost={post_cost} "
283
+ f"Bound={post_bound} RHS={post_rhs}"
284
+ )
285
+
286
+ flat = " -> ".join(parts)
287
+ if len(flat) <= 100:
288
+ return flat
289
+ return parts[0] + "".join(f"\n -> {p}" for p in parts[1:])
290
+
291
+
292
+ def format_nonoptimal_hint(ranges: RangeReport) -> str:
293
+ """Compose the scaling-related *possibility* hint for a rejected solve.
294
+
295
+ Returns the empty string unless ``ranges.trigger`` is ``True``. The
296
+ caller must pass the ranges of the *actually solved* (post-autoscale)
297
+ LP — a still-tripped trigger there means the autoscaler could NOT tame
298
+ the range spread, so scaling is a plausible (not proven) contributor to
299
+ the failure. Framed as a possibility with remediations, never as the
300
+ certified cause: a wide range does not by itself explain a non-optimal
301
+ status, and passing the raw pre-autoscale ranges here (which are always
302
+ wide for FlexTool's commodity ladders) would make the hint fire on
303
+ essentially every failure regardless of cause.
304
+ """
305
+ if not ranges.trigger:
306
+ return ""
307
+
308
+ def _span_decades(span: tuple[float, float]) -> str:
309
+ d = _decades(span)
310
+ if d is None:
311
+ return "empty"
312
+ return f"{d:.1f} decades"
313
+
314
+ rhs_span = _span_decades(ranges.rhs)
315
+ cost_span = _span_decades(ranges.cost)
316
+
317
+ return (
318
+ "The solved (post-autoscale) LP is still wide-ranged "
319
+ f"(RHS spans {rhs_span}, Cost spans {cost_span}), so scaling MAY be "
320
+ "a contributing factor. Possible remediations:\n"
321
+ " - Check unit conventions on commodity-ladder entities "
322
+ "(set unitsize so quantity/unitsize <= 1e+6)\n"
323
+ " - Re-run with --highs-threads 1 to bypass parallel-mode "
324
+ "brittleness\n"
325
+ " - Re-run with --scaling=solver_only if you suspect the "
326
+ "autoscaler interferes (HiGHS' internal scaling still runs)"
327
+ )
328
+
329
+
330
+ __all__ = [
331
+ "format_console_summary",
332
+ "format_nonoptimal_hint",
333
+ "render_layer2",
334
+ "render_layer3",
335
+ "write_report",
336
+ ]
@@ -0,0 +1,259 @@
1
+ """Thin compat shim around the native cascade — Δ.12e.
2
+
3
+ The legacy file-symlink driver (loading per-sub-solve
4
+ ``solve_data_<sub>/`` snapshots directly from a work folder) retired
5
+ in Δ.12e once the native cascade in
6
+ :mod:`flextool.engine_polars._orchestration` reached feature parity:
7
+ warm-LP across structurally-compatible iterations (Δ.12d), in-memory
8
+ handoff carriers between sub-solves (Δ.11/Δ.12), full output writer
9
+ coverage (Δ.1, Δ.12c-fix), and override-chain authority for Direct /
10
+ Derived / Projection params (Δ.12-drop, Δ.12c, Δ.12c-fix2).
11
+
12
+ What's left here is a thin convenience wrapper that converts a
13
+ work-folder path (containing a ``tests.sqlite`` / ``input.sqlite``)
14
+ into the canonical native call
15
+ :func:`flextool.engine_polars._orchestration.run_chain_from_db` and
16
+ adapts the result to :class:`ChainStep` shape for backwards
17
+ compatibility with the few external callers that still import
18
+ ``run_chain`` directly.
19
+
20
+ The warm-update primitives (structural fingerprint, Param classification,
21
+ :func:`_apply_warm_updates`) live in :mod:`flextool.engine_polars._warm`
22
+ and are re-exported here as module-level names for backwards
23
+ compatibility (e.g. ``from flextool.engine_polars.chain import
24
+ _MUTABLE_PARAMS`` in ``test_warm_param_autoupdate``).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from pathlib import Path
29
+
30
+ from polar_high import Solution
31
+
32
+ # Backwards-compat re-exports of warm-update primitives that now live in
33
+ # :mod:`flextool.engine_polars._warm`. Documented in the module
34
+ # docstring above; consumed by ``tests/engine_polars/test_warm_param_autoupdate.py``
35
+ # (and any external caller that still does ``from
36
+ # flextool.engine_polars.chain import _MUTABLE_PARAMS``). Listed in
37
+ # ``__all__`` so ruff F401 does not strip them as unused.
38
+ from flextool.engine_polars._warm import ( # noqa: F401
39
+ _STRUCTURAL_FIELDS,
40
+ _WARM_PARAMS,
41
+ _MUTABLE_PARAMS,
42
+ _WARM_PARAMS_DEFERRED,
43
+ _WARM_PARAMS_NO_OP,
44
+ _WARM_PARAM_GATES,
45
+ _IncompatibleUpdate,
46
+ _fingerprint,
47
+ _param_frame_equal,
48
+ _param_values_position_equal,
49
+ _gate_active,
50
+ _apply_warm_updates,
51
+ _build_warm_problem,
52
+ )
53
+
54
+
55
+ __all__ = [
56
+ "run_chain",
57
+ "ChainStep",
58
+ # Re-exported warm-update primitives (see docstring).
59
+ "_STRUCTURAL_FIELDS",
60
+ "_WARM_PARAMS",
61
+ "_MUTABLE_PARAMS",
62
+ "_WARM_PARAMS_DEFERRED",
63
+ "_WARM_PARAMS_NO_OP",
64
+ "_WARM_PARAM_GATES",
65
+ "_IncompatibleUpdate",
66
+ "_fingerprint",
67
+ "_param_frame_equal",
68
+ "_param_values_position_equal",
69
+ "_gate_active",
70
+ "_apply_warm_updates",
71
+ "_build_warm_problem",
72
+ ]
73
+
74
+
75
+ class ChainStep:
76
+ """Per-sub-solve result of :func:`run_chain`.
77
+
78
+ Attributes
79
+ ----------
80
+ solve_name : str
81
+ The sub-solve identifier (e.g. ``"y2025_5week"``).
82
+ solution : polar_high.Solution
83
+ The HiGHS solution for this sub-solve.
84
+ handoff : flextool.SolveHandoff
85
+ The handoff carriers derived from the polar_high solution
86
+ (see :func:`flextool.input.build_handoff_from_solution`).
87
+ warm_used : bool
88
+ True if this sub-solve was solved by warm-updating the prior
89
+ sub-solve's :class:`WarmProblem` instance; False if it was a
90
+ cold rebuild. Always False for the first sub-solve and for
91
+ ``warm=False`` runs.
92
+ """
93
+
94
+ __slots__ = ("solve_name", "solution", "handoff", "warm_used")
95
+
96
+ def __init__(self, solve_name: str, solution: Solution, handoff,
97
+ warm_used: bool = False):
98
+ self.solve_name = solve_name
99
+ self.solution = solution
100
+ self.handoff = handoff
101
+ self.warm_used = warm_used
102
+
103
+ def __repr__(self) -> str: # pragma: no cover — debug-only
104
+ return (f"ChainStep(solve_name={self.solve_name!r}, "
105
+ f"obj={self.solution.obj!r}, "
106
+ f"warm_used={self.warm_used}, "
107
+ f"handoff_empty={self.handoff.is_empty()})")
108
+
109
+
110
+ # Same overrides used by the parity-sweep fixtures — see
111
+ # tests/engine_polars/test_solve_config_parity._discover_fixtures. The
112
+ # keys are work_folder dirnames; values are the scenario names that
113
+ # produced those snapshots.
114
+ _NATIVE_SCENARIO_OVERRIDES: dict[str, str] = {
115
+ "work_2day_stochastic_dispatch_full_storage": "2_day_stochastic_dispatch",
116
+ "work_commodity_ladder_annual": "coal_ladder_annual",
117
+ "work_commodity_ladder_cumulative": "coal_ladder_cumulative",
118
+ "work_delay_source_coef": "water_pump_delayed",
119
+ "work_inflation_check": "wind_battery_invest_lifetime_renew",
120
+ }
121
+
122
+
123
+ def _resolve_native_scenario(db_path: Path, work: Path) -> str | None:
124
+ """Map ``work/`` dirname → scenario name using the same convention
125
+ as the parity tests. Returns ``None`` when no rule matches.
126
+ """
127
+ import re
128
+ import spinedb_api as api
129
+
130
+ if work.name in _NATIVE_SCENARIO_OVERRIDES:
131
+ return _NATIVE_SCENARIO_OVERRIDES[work.name]
132
+
133
+ scen_target = work.name.removeprefix("work_") if work.name.startswith("work_") else None
134
+ if scen_target is None:
135
+ return None
136
+
137
+ try:
138
+ with api.DatabaseMapping("sqlite:///" + str(db_path)) as db:
139
+ scenarios = sorted(s.name for s in db.query(db.scenario_sq).all())
140
+ except Exception:
141
+ return None
142
+
143
+ candidates = [scen_target]
144
+ candidates.append(re.sub(r"(^|_)(\d+)([a-z])", r"\1\2_\3", scen_target))
145
+ candidates.append(re.sub(r"(\d+)_([a-z])", r"\1\2", scen_target))
146
+ if scen_target.endswith("_full_storage"):
147
+ base = scen_target[: -len("_full_storage")]
148
+ candidates.append(re.sub(r"(^|_)(\d+)([a-z])", r"\1\2_\3", base))
149
+ candidates.append(base)
150
+ for cand in candidates:
151
+ if cand in scenarios:
152
+ return cand
153
+ return None
154
+
155
+
156
+ def run_chain(
157
+ work_folder: Path | str,
158
+ *,
159
+ warm: bool = True,
160
+ scenario: str | None = None,
161
+ ) -> dict[str, ChainStep]:
162
+ """Run a flextool multi-solve scenario end-to-end via the native
163
+ cascade — convenience adapter that takes a work-folder path.
164
+
165
+ Δ.12e — this function is now a thin shim that:
166
+
167
+ 1. Looks up ``tests.sqlite`` / ``input.sqlite`` under
168
+ *work_folder*.
169
+ 2. Resolves the scenario name from the dir-name convention (see
170
+ :data:`_NATIVE_SCENARIO_OVERRIDES`) or the explicit
171
+ ``scenario`` kwarg / ``FLEXTOOL_NATIVE_SCENARIO`` env var.
172
+ 3. Delegates to
173
+ :func:`flextool.engine_polars._orchestration.run_chain_from_db`.
174
+ 4. Adapts the result from
175
+ :class:`flextool.engine_polars.OrchestrationStep` to
176
+ :class:`ChainStep` shape (the legacy return type a few
177
+ external callers still import).
178
+
179
+ The previous file-symlink driver (loading
180
+ ``solve_data_<sub>/`` snapshots directly from the work folder)
181
+ and the ``use_handoff_overlay`` / ``native`` / ``chain`` kwargs
182
+ retired in Δ.12e once the native cascade reached feature parity.
183
+ Callers driving DB scenarios should prefer
184
+ :func:`run_chain_from_db` directly.
185
+
186
+ Parameters
187
+ ----------
188
+ work_folder : Path | str
189
+ Directory containing a Spine SQLite (``tests.sqlite`` or
190
+ ``input.sqlite``). The native cascade re-runs flextool's
191
+ preprocessing into a private tempdir; the work folder is
192
+ consulted only for the DB.
193
+ warm : bool, default False
194
+ When True, attempt warm LP updates between consecutive
195
+ structurally-compatible sub-solves using
196
+ :class:`polar_high.WarmProblem`. See
197
+ :func:`flextool.engine_polars._orchestration.run_orchestration`
198
+ for the per-iteration semantics.
199
+ scenario : str | None, default None
200
+ Explicit scenario name override. ``None`` consults the
201
+ ``FLEXTOOL_NATIVE_SCENARIO`` env var, then falls back to the
202
+ ``work_<scenario>`` dir-name convention.
203
+
204
+ Returns
205
+ -------
206
+ dict[str, ChainStep]
207
+ Mapping ``solve_name → ChainStep(solve_name, solution, handoff,
208
+ warm_used)``.
209
+
210
+ Raises
211
+ ------
212
+ ValueError
213
+ If no DB is found under *work_folder* or the scenario can't
214
+ be determined.
215
+ """
216
+ import os
217
+ work = Path(work_folder)
218
+ db_path = None
219
+ for cand in ("tests.sqlite", "input.sqlite"):
220
+ p = work / cand
221
+ if p.exists():
222
+ db_path = p
223
+ break
224
+ if db_path is None:
225
+ raise ValueError(
226
+ f"run_chain: no DB found under {work} "
227
+ f"(looked for tests.sqlite, input.sqlite). The native "
228
+ f"cascade requires a DB scenario."
229
+ )
230
+
231
+ # Late import to avoid a build-time cycle between chain and _orchestration.
232
+ from flextool.engine_polars._orchestration import run_chain_from_db
233
+
234
+ if scenario is None:
235
+ scenario = os.environ.get("FLEXTOOL_NATIVE_SCENARIO") or None
236
+ if scenario is None:
237
+ scenario = _resolve_native_scenario(db_path, work)
238
+ if scenario is None:
239
+ raise ValueError(
240
+ f"run_chain: cannot determine which scenario to run for "
241
+ f"{work}. Pass scenario= explicitly, set "
242
+ f"FLEXTOOL_NATIVE_SCENARIO, or rename the work directory to "
243
+ f"match the scenario name (work_<scenario>). "
244
+ f"work.name={work.name!r}"
245
+ )
246
+
247
+ # Phase C.5 — ``ChainStep`` keeps the per-step ``solution``
248
+ # contract for legacy callers; opt into the full per-step state on
249
+ # the underlying cascade.
250
+ steps = run_chain_from_db(db_path, scenario, warm=warm, keep_solutions=True)
251
+ out: dict[str, ChainStep] = {}
252
+ for name, step in steps.items():
253
+ out[name] = ChainStep(
254
+ solve_name=name,
255
+ solution=step.solution,
256
+ handoff=step.handoff,
257
+ warm_used=getattr(step, "warm_used", False),
258
+ )
259
+ return out