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.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- flextool-4.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
"""Layer 2 (semantic per-type scaling) — implementation.
|
|
2
|
+
|
|
3
|
+
Layer 2 mutates a polar-high :class:`Problem` *in place* before
|
|
4
|
+
``Problem.solve(...)`` and returns a :class:`Layer2Plan` describing the
|
|
5
|
+
inverse transform. The plan is consumed by
|
|
6
|
+
:func:`unscale_solution` after the solver returns, so callers stay
|
|
7
|
+
oblivious to the scaling change.
|
|
8
|
+
|
|
9
|
+
Mathematics (forward semantics — see the bit-for-bit test for the
|
|
10
|
+
correctness anchor):
|
|
11
|
+
|
|
12
|
+
* ``col_factor[j]`` is a positive power-of-two. The variable
|
|
13
|
+
substitution that lands in the LP arrays passed to HiGHS is
|
|
14
|
+
``x_scaled[j] = col_factor[j] * x[j]`` ( ⇔ ``x[j] = x_scaled[j] /
|
|
15
|
+
col_factor[j]``).
|
|
16
|
+
* ``row_factor[i]`` is a positive power-of-two. Each row of the
|
|
17
|
+
scaled LP is the original row **multiplied** by ``row_factor[i]``
|
|
18
|
+
(LHS coefficients, RHS, both).
|
|
19
|
+
|
|
20
|
+
The four LP-array transforms implemented below are then:
|
|
21
|
+
|
|
22
|
+
================== ============================================
|
|
23
|
+
Quantity Scaled value
|
|
24
|
+
================== ============================================
|
|
25
|
+
``matrix[i, j]`` ``matrix[i, j] * row_factor[i] / col_factor[j]``
|
|
26
|
+
``cost[j]`` ``cost[j] / col_factor[j]``
|
|
27
|
+
``lb[j], ub[j]`` ``lb[j] * col_factor[j]``, ``ub[j] * col_factor[j]``
|
|
28
|
+
``rhs[i]`` ``rhs[i] * row_factor[i]``
|
|
29
|
+
================== ============================================
|
|
30
|
+
|
|
31
|
+
Unscale (post-solve):
|
|
32
|
+
|
|
33
|
+
================== =============================================
|
|
34
|
+
Quantity Original value
|
|
35
|
+
================== =============================================
|
|
36
|
+
``x[j]`` ``x_scaled[j] / col_factor[j]``
|
|
37
|
+
``col_dual[j]`` ``col_dual_scaled[j] * col_factor[j]``
|
|
38
|
+
``row_dual[i]`` ``row_dual_scaled[i] * row_factor[i]``
|
|
39
|
+
``obj`` unchanged
|
|
40
|
+
================== =============================================
|
|
41
|
+
|
|
42
|
+
The objective is invariant: ``c x = (c/cf) * (cf*x) = c_scaled *
|
|
43
|
+
x_scaled``. We do not touch the objective offset, the model sense, or
|
|
44
|
+
the integrality flags.
|
|
45
|
+
|
|
46
|
+
Layer 2 deliberately **does not column-scale integer variables** —
|
|
47
|
+
shifting their bounds by a non-unit factor would break integrality of
|
|
48
|
+
the recovered solution. Their bounds, cost, and matrix entries flow
|
|
49
|
+
through unchanged.
|
|
50
|
+
|
|
51
|
+
MPS-export consideration
|
|
52
|
+
------------------------
|
|
53
|
+
|
|
54
|
+
Because Layer 2 mutates the LP arrays *before* they are passed to HiGHS,
|
|
55
|
+
``Highs.writeModel('out.mps')`` exports the **scaled** model. An
|
|
56
|
+
external solver consuming that MPS would therefore receive the
|
|
57
|
+
re-coordinatised LP, not the original; its results would live in scaled
|
|
58
|
+
coordinates and would need the inverse transform recorded in the
|
|
59
|
+
``autoscale_<solve>.yaml`` audit (per-type exponents) to be returned to
|
|
60
|
+
user units.
|
|
61
|
+
|
|
62
|
+
For a workflow that writes MPS and passes it to a third-party solver,
|
|
63
|
+
the safe defaults are either:
|
|
64
|
+
|
|
65
|
+
1. Run with ``--scaling=basic`` or ``--scaling=solver_only`` so the exported MPS reflects the
|
|
66
|
+
unscaled problem, or
|
|
67
|
+
2. Read the per-type exponents from the autoscale YAML report and apply
|
|
68
|
+
the inverse transform to the external solver's solution.
|
|
69
|
+
|
|
70
|
+
Layer 3 (``user_*_scale``) is HiGHS-internal and is **not** captured in
|
|
71
|
+
``writeModel`` output — that scaling is invisible to external solvers
|
|
72
|
+
regardless of FlexTool's autoscale setting.
|
|
73
|
+
"""
|
|
74
|
+
from __future__ import annotations
|
|
75
|
+
|
|
76
|
+
import logging
|
|
77
|
+
import math
|
|
78
|
+
import os
|
|
79
|
+
import sys
|
|
80
|
+
import time
|
|
81
|
+
from dataclasses import dataclass, field
|
|
82
|
+
from typing import Any
|
|
83
|
+
|
|
84
|
+
import numpy as np
|
|
85
|
+
import polars as pl
|
|
86
|
+
|
|
87
|
+
from ._config import ScalingConfig
|
|
88
|
+
from ._layer2_types import (
|
|
89
|
+
VarFamily,
|
|
90
|
+
lookup_var,
|
|
91
|
+
resolve_cstr_rhs_type,
|
|
92
|
+
)
|
|
93
|
+
from ._quantity_types import QuantityType
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_logger = logging.getLogger(__name__)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Capability gate for the bounded coefficient-walk (Phase D-5 step 3).
|
|
101
|
+
#
|
|
102
|
+
# ``bucket_coefficients`` prefers ``polar_high.autoscale._coef_walk`` to walk
|
|
103
|
+
# the block-COO ``(rid/col_id, coef)`` stream in bounded slices instead of
|
|
104
|
+
# materialising the full ``Var ⋈ P1 ⋈ P2 …`` product (see its docstring).
|
|
105
|
+
# The walk ships in polar-high>=2.4.0 (pinned in pyproject), so the import
|
|
106
|
+
# is guaranteed. The capability detect below is retained as a defensive
|
|
107
|
+
# no-op: if a user force-downgrades polar-high, ``bucket_coefficients``
|
|
108
|
+
# falls back to the materialising ``_collect_term_agg`` collect for every
|
|
109
|
+
# term (correct, just unbounded) rather than raising ``ImportError``.
|
|
110
|
+
try: # pragma: no cover - exercised by both-polar_high verification runs
|
|
111
|
+
from polar_high.autoscale._coef_walk import ( # noqa: F401
|
|
112
|
+
CoefWalkRecipe as _CoefWalkRecipe,
|
|
113
|
+
Log2HistogramReducer as _Log2HistogramReducer,
|
|
114
|
+
bounded_coefficient_walk as _bounded_coefficient_walk,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
_HAVE_COEF_WALK = True
|
|
118
|
+
except ImportError:
|
|
119
|
+
_HAVE_COEF_WALK = False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Clamp on the chosen per-type exponents. ±20 keeps the scale factors
|
|
123
|
+
# inside roughly [1e-6, 1e+6] which is a sane band for energy-system
|
|
124
|
+
# LPs; values past that risk losing precision when HiGHS' own
|
|
125
|
+
# equilibration multiplies on top.
|
|
126
|
+
_DEFAULT_CLAMP = 20
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# QuantityType ↔ int round-trip helpers.
|
|
131
|
+
#
|
|
132
|
+
# ``QuantityType`` is a *string*-valued Enum (see ``_quantity_types.py``),
|
|
133
|
+
# so we cannot use ``QuantityType(int_id)`` directly. Build a stable
|
|
134
|
+
# bijection once at import — keyed by the enum's insertion order — and
|
|
135
|
+
# reuse it everywhere polars needs an integer column for ``column_type``
|
|
136
|
+
# joins / group-bys. The mapping is process-local; it is regenerated
|
|
137
|
+
# every time this module is imported, which is fine because callers
|
|
138
|
+
# always reconstruct ``QuantityType`` *inside* this module.
|
|
139
|
+
|
|
140
|
+
_QTY_TO_ID: dict[QuantityType, int] = {q: i for i, q in enumerate(QuantityType)}
|
|
141
|
+
_ID_TO_QTY: dict[int, QuantityType] = {i: q for q, i in _QTY_TO_ID.items()}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _qty_to_id(q: QuantityType | None) -> int | None:
|
|
145
|
+
"""Stable Int32 id for a :class:`QuantityType`, or ``None``."""
|
|
146
|
+
if q is None:
|
|
147
|
+
return None
|
|
148
|
+
return _QTY_TO_ID[q]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _id_to_qty(i: int) -> QuantityType:
|
|
152
|
+
return _ID_TO_QTY[int(i)]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class Layer2Plan:
|
|
157
|
+
"""Forward + inverse Layer-2 transform.
|
|
158
|
+
|
|
159
|
+
``col_factors`` and ``row_factors`` are length-``n_cols`` /
|
|
160
|
+
``n_rows`` ``np.float64`` arrays. All entries are positive
|
|
161
|
+
powers of two (1.0 = identity); the bit-for-bit roundtrip relies
|
|
162
|
+
on this.
|
|
163
|
+
|
|
164
|
+
Attributes
|
|
165
|
+
----------
|
|
166
|
+
col_factors:
|
|
167
|
+
Per-column forward multipliers.
|
|
168
|
+
row_factors:
|
|
169
|
+
Per-row forward multipliers.
|
|
170
|
+
type_exponents:
|
|
171
|
+
Power-of-two exponent picked for each :class:`QuantityType`
|
|
172
|
+
present in the LP (for reporting).
|
|
173
|
+
type_buckets_before:
|
|
174
|
+
Per-type (min, max) magnitude before scaling — fed to the
|
|
175
|
+
Layer-2 section of the autoscale YAML.
|
|
176
|
+
type_buckets_after:
|
|
177
|
+
Per-type (min, max) magnitude after scaling.
|
|
178
|
+
skipped_rows:
|
|
179
|
+
Constraint-row names whose family declares ``rhs_type=None``
|
|
180
|
+
(user-defined constraints / structural zero-RHS rows) and
|
|
181
|
+
therefore receive no per-row factor.
|
|
182
|
+
skipped_integer_cols:
|
|
183
|
+
Column ids of integer variables that received no column
|
|
184
|
+
scaling (their factor is 1.0 in ``col_factors``).
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
col_factors: np.ndarray
|
|
188
|
+
row_factors: np.ndarray
|
|
189
|
+
type_exponents: dict[QuantityType, int]
|
|
190
|
+
type_buckets_before: dict[QuantityType, tuple[float, float]]
|
|
191
|
+
type_buckets_after: dict[QuantityType, tuple[float, float]] = field(
|
|
192
|
+
default_factory=dict,
|
|
193
|
+
)
|
|
194
|
+
skipped_rows: list[str] = field(default_factory=list)
|
|
195
|
+
skipped_integer_cols: list[int] = field(default_factory=list)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Bucketing
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _effective_matrix_type(
|
|
203
|
+
var_family: VarFamily,
|
|
204
|
+
row_type: QuantityType | None,
|
|
205
|
+
) -> QuantityType:
|
|
206
|
+
"""Return the bucket key for a matrix entry sitting in ``row_type``'s
|
|
207
|
+
row and feeding ``var_family``'s column.
|
|
208
|
+
|
|
209
|
+
Layer 2 buckets by the matrix entry's effective physical type — the
|
|
210
|
+
product of the column type and the row's multiplier param's type —
|
|
211
|
+
so ``v_flow``-times-``p_unitsize`` rows land in POWER even though
|
|
212
|
+
the column itself is DIMENSIONLESS.
|
|
213
|
+
|
|
214
|
+
Heuristic:
|
|
215
|
+
|
|
216
|
+
* If the column carries a ``multiplier_param``, the matrix entry's
|
|
217
|
+
type is *whichever of POWER / ENERGY the row demands*. We trust
|
|
218
|
+
the row's ``rhs_type`` for this — every flextool family
|
|
219
|
+
multiplies the dimensionless column by exactly the row's units.
|
|
220
|
+
* Otherwise the column's own type is the entry type.
|
|
221
|
+
* When ``row_type`` is ``None`` (skip-per-row family), we fall
|
|
222
|
+
back to the column's own type so the matrix entry still
|
|
223
|
+
contributes to its column-side bucket.
|
|
224
|
+
"""
|
|
225
|
+
if var_family.multiplier_param is not None and row_type is not None:
|
|
226
|
+
# User constraints (rhs_type=None) hit the column-type branch
|
|
227
|
+
# below; for them we don't push a multiplied effective type
|
|
228
|
+
# because the multiplier param IS the user's coefficient.
|
|
229
|
+
return row_type
|
|
230
|
+
return var_family.column_type
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _per_var_column_type(name: str) -> QuantityType:
|
|
234
|
+
return lookup_var(name).column_type
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Per-type accumulator value: ``(log2_sum, count, abs_min, abs_max)``.
|
|
238
|
+
#
|
|
239
|
+
# Layer 2 only needs ``(log2_sum, count)`` to compute the geometric mean
|
|
240
|
+
# (see :func:`choose_scale_powers`). ``abs_min`` / ``abs_max`` are
|
|
241
|
+
# carried for the report-side ``type_buckets_before`` /
|
|
242
|
+
# ``type_buckets_after`` fields on :class:`Layer2Plan` — both come for
|
|
243
|
+
# free from the polars ``group_by`` so we propagate them rather than
|
|
244
|
+
# losing the information.
|
|
245
|
+
_AccVal = tuple[float, int, float, float]
|
|
246
|
+
_INIT_ACC: _AccVal = (0.0, 0, math.inf, 0.0)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _build_col_id_classification(problem: Any) -> pl.DataFrame:
|
|
250
|
+
"""Return a per-``col_id`` classification table.
|
|
251
|
+
|
|
252
|
+
Columns:
|
|
253
|
+
|
|
254
|
+
* ``col_id`` — Int64
|
|
255
|
+
* ``column_type_id`` — Int32 (see :data:`_QTY_TO_ID`)
|
|
256
|
+
* ``has_multiplier_param`` — Boolean
|
|
257
|
+
|
|
258
|
+
Built once per :func:`bucket_coefficients` call and joined into
|
|
259
|
+
every objective / constraint term's lazy plan. ``has_multiplier_param``
|
|
260
|
+
drives :func:`_effective_matrix_type`'s branch — when set *and* the
|
|
261
|
+
constraint family has a ``rhs_type``, the matrix entry is bucketed
|
|
262
|
+
against the row's type rather than the column's.
|
|
263
|
+
"""
|
|
264
|
+
col_ids: list[int] = []
|
|
265
|
+
type_ids: list[int] = []
|
|
266
|
+
has_mp: list[bool] = []
|
|
267
|
+
for name, var in problem._vars.items():
|
|
268
|
+
try:
|
|
269
|
+
fam = lookup_var(name)
|
|
270
|
+
except KeyError as exc:
|
|
271
|
+
raise KeyError(
|
|
272
|
+
f"Layer 2: variable {name!r} not in VARIABLE_FAMILIES — "
|
|
273
|
+
"register it in _layer2_types.py before solving."
|
|
274
|
+
) from exc
|
|
275
|
+
ct_id = _qty_to_id(fam.column_type)
|
|
276
|
+
mp = fam.multiplier_param is not None
|
|
277
|
+
ids = var.frame["col_id"].to_numpy().tolist()
|
|
278
|
+
col_ids.extend(int(c) for c in ids)
|
|
279
|
+
type_ids.extend([ct_id] * len(ids))
|
|
280
|
+
has_mp.extend([mp] * len(ids))
|
|
281
|
+
return pl.DataFrame(
|
|
282
|
+
{
|
|
283
|
+
"col_id": pl.Series(col_ids, dtype=pl.Int64),
|
|
284
|
+
"column_type_id": pl.Series(type_ids, dtype=pl.Int32),
|
|
285
|
+
"has_multiplier_param": pl.Series(has_mp, dtype=pl.Boolean),
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _collect_term_agg(
|
|
291
|
+
term_lazy: pl.LazyFrame,
|
|
292
|
+
*,
|
|
293
|
+
classification_lazy: pl.LazyFrame,
|
|
294
|
+
rhs_t_id: int | None,
|
|
295
|
+
) -> pl.DataFrame | None:
|
|
296
|
+
"""Aggregate one term to ``(eff_t, log_sum, n, log_min, log_max)``.
|
|
297
|
+
|
|
298
|
+
Returns ``None`` for terms with no finite nonzero coefficient (the
|
|
299
|
+
caller skips merging in that case).
|
|
300
|
+
|
|
301
|
+
Streaming engine: we try the ``streaming`` engine first. Polars'
|
|
302
|
+
streaming planner does not support every expression (``.log()`` and
|
|
303
|
+
``.abs()`` historically had patches of unsupported ops); if the
|
|
304
|
+
streaming collect raises, we fall back to a per-term in-memory
|
|
305
|
+
collect. Even in the fallback the peak is bounded by *one* term's
|
|
306
|
+
materialised size — orders of magnitude smaller than the original
|
|
307
|
+
code's all-terms-at-once Python-list accumulation.
|
|
308
|
+
"""
|
|
309
|
+
if rhs_t_id is None:
|
|
310
|
+
eff_t_expr = pl.col("column_type_id")
|
|
311
|
+
else:
|
|
312
|
+
eff_t_expr = (
|
|
313
|
+
pl.when(pl.col("has_multiplier_param"))
|
|
314
|
+
.then(pl.lit(rhs_t_id, dtype=pl.Int32))
|
|
315
|
+
.otherwise(pl.col("column_type_id"))
|
|
316
|
+
)
|
|
317
|
+
plan = (
|
|
318
|
+
term_lazy
|
|
319
|
+
.join(classification_lazy, on="col_id", how="inner")
|
|
320
|
+
.with_columns(eff_t=eff_t_expr)
|
|
321
|
+
.filter(pl.col("coef") != 0.0)
|
|
322
|
+
.with_columns(log_abs=pl.col("coef").abs().log(2.0))
|
|
323
|
+
.filter(pl.col("log_abs").is_finite())
|
|
324
|
+
.group_by("eff_t")
|
|
325
|
+
.agg(
|
|
326
|
+
pl.col("log_abs").sum().alias("log_sum"),
|
|
327
|
+
pl.len().alias("n"),
|
|
328
|
+
pl.col("log_abs").min().alias("log_min"),
|
|
329
|
+
pl.col("log_abs").max().alias("log_max"),
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
try:
|
|
333
|
+
agg = plan.collect(engine="streaming")
|
|
334
|
+
except Exception as exc: # noqa: BLE001 — polars may raise diverse types
|
|
335
|
+
_logger.debug(
|
|
336
|
+
"Layer 2: streaming engine refused term aggregate (%s); "
|
|
337
|
+
"falling back to in-memory collect for this term.",
|
|
338
|
+
exc,
|
|
339
|
+
)
|
|
340
|
+
agg = plan.collect()
|
|
341
|
+
if agg.height == 0:
|
|
342
|
+
return None
|
|
343
|
+
return agg
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _merge_into_accumulator(
|
|
347
|
+
acc: dict[QuantityType, _AccVal],
|
|
348
|
+
agg: pl.DataFrame,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Fold one term's aggregate into the per-type accumulator."""
|
|
351
|
+
for eff_t_id, log_sum, n, log_min, log_max in zip(
|
|
352
|
+
agg["eff_t"].to_list(),
|
|
353
|
+
agg["log_sum"].to_list(),
|
|
354
|
+
agg["n"].to_list(),
|
|
355
|
+
agg["log_min"].to_list(),
|
|
356
|
+
agg["log_max"].to_list(),
|
|
357
|
+
):
|
|
358
|
+
if eff_t_id is None:
|
|
359
|
+
continue
|
|
360
|
+
t = _id_to_qty(int(eff_t_id))
|
|
361
|
+
ps, pn, pmin, pmax = acc.get(t, _INIT_ACC)
|
|
362
|
+
amin = float(2.0 ** float(log_min))
|
|
363
|
+
amax = float(2.0 ** float(log_max))
|
|
364
|
+
acc[t] = (
|
|
365
|
+
ps + float(log_sum),
|
|
366
|
+
pn + int(n),
|
|
367
|
+
min(pmin, amin),
|
|
368
|
+
max(pmax, amax),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Bounded coefficient-walk wiring (Phase D-5 step 3).
|
|
374
|
+
#
|
|
375
|
+
# ``_collect_term_agg`` above materialises the merged ``Var ⋈ P1 ⋈ P2 …``
|
|
376
|
+
# (or RHS Param) chain per term to reduce it to a per-type
|
|
377
|
+
# ``(Σlog2|coef|, count, min, max)`` histogram. On the FlexTool DES LP the
|
|
378
|
+
# polars streaming planner cannot push the group-by into the deep product,
|
|
379
|
+
# so the product materialises — the residual ~46 GB autoscale peak.
|
|
380
|
+
#
|
|
381
|
+
# The walk below replaces that materialising collect with
|
|
382
|
+
# :func:`polar_high.autoscale._coef_walk.bounded_coefficient_walk` driven by
|
|
383
|
+
# :class:`Log2HistogramReducer`: each term's ``(rid/col_id, coef)`` stream is
|
|
384
|
+
# walked in bounded ``_WALK_BATCH_ROWS`` slices and folded into the same
|
|
385
|
+
# per-bucket ``(Σlog2, count, min, max)`` accumulator. ``scale=(None,0,None)``
|
|
386
|
+
# — bucketing uses RAW ``|coef|`` (no side-vector scaling). The reducer's
|
|
387
|
+
# ``classify`` reproduces the ``col_id → eff_t`` mapping the old
|
|
388
|
+
# ``_collect_term_agg`` joined: for the objective ``eff_t = column_type``; for
|
|
389
|
+
# a matrix family ``eff_t = rhs_type`` when the column carries a multiplier
|
|
390
|
+
# param (and the family has an rhs_type), else ``column_type``.
|
|
391
|
+
#
|
|
392
|
+
# Terms with no recoverable block-COO recipe (a fully-collapsed
|
|
393
|
+
# ``Sum(over=ALL)`` term clears ``var_source`` / ``sum_block_meta`` and ends
|
|
394
|
+
# up ``over is None`` / ``dims == ()``) cannot be rebuilt by the walk; they
|
|
395
|
+
# keep the existing ``_collect_term_agg`` collect as a backstop — bounded by
|
|
396
|
+
# the (tiny) per-type aggregate, the same envelope as before.
|
|
397
|
+
|
|
398
|
+
# 256k keeps each batch's block-COO product comfortably small while
|
|
399
|
+
# amortising per-batch overhead. The histogram's per-batch Σlog2 reassociates
|
|
400
|
+
# vs a single whole-collect sum, so a coefficient on a half-integer log2
|
|
401
|
+
# boundary may shift a chosen exponent by ±1 → a different (objective-
|
|
402
|
+
# invariant) scaling. Accepted per the step-3 correctness bar.
|
|
403
|
+
_WALK_BATCH_ROWS = 256_000
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _layer2_bucket_profiler() -> Any:
|
|
407
|
+
"""Return an ``emit(family, term_idx, **extras)`` callable when
|
|
408
|
+
``POLAR_HIGH_LAYER2_PROFILE=1`` and ``psutil`` is importable, else
|
|
409
|
+
``None``.
|
|
410
|
+
|
|
411
|
+
Mirrors the ``[ranges-stream profile]`` instrument in
|
|
412
|
+
:mod:`polar_high.autoscale._ranges`: one ``[layer2-bucket profile]``
|
|
413
|
+
stderr line per walked term carrying ``family``, ``term_idx``,
|
|
414
|
+
``over_height`` / ``n``, the post-walk wall clock, and an RSS sample —
|
|
415
|
+
so the final DES run can CONFIRM this site was the ~46 GB driver and is
|
|
416
|
+
now bounded.
|
|
417
|
+
"""
|
|
418
|
+
if os.environ.get("POLAR_HIGH_LAYER2_PROFILE") != "1":
|
|
419
|
+
return None
|
|
420
|
+
try:
|
|
421
|
+
import psutil
|
|
422
|
+
except ImportError:
|
|
423
|
+
return None
|
|
424
|
+
proc = psutil.Process()
|
|
425
|
+
t0 = time.monotonic()
|
|
426
|
+
|
|
427
|
+
def _emit(family: str, term_idx: int, **extras: Any) -> None:
|
|
428
|
+
rss = proc.memory_info().rss / (1024 ** 3)
|
|
429
|
+
wall = time.monotonic() - t0
|
|
430
|
+
extras_str = "\t".join(f"{k}={v}" for k, v in extras.items())
|
|
431
|
+
print(
|
|
432
|
+
f"[layer2-bucket profile]\tfamily={family}\tterm_idx={term_idx}"
|
|
433
|
+
f"\trss_gb={rss:.2f}\twall_s={wall:.2f}"
|
|
434
|
+
+ (f"\t{extras_str}" if extras_str else ""),
|
|
435
|
+
file=sys.stderr,
|
|
436
|
+
flush=True,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return _emit
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _build_col_id_eff_t(problem: Any) -> dict[int, tuple[QuantityType, bool]]:
|
|
443
|
+
"""Per-``col_id`` ``(column_type, has_multiplier_param)`` lookup.
|
|
444
|
+
|
|
445
|
+
Drives the walk's ``classify`` closures — the Python-side analogue of
|
|
446
|
+
the ``col_id → (column_type_id, has_multiplier_param)`` classification
|
|
447
|
+
table :func:`_build_col_id_classification` joins into the lazy plan.
|
|
448
|
+
"""
|
|
449
|
+
out: dict[int, tuple[QuantityType, bool]] = {}
|
|
450
|
+
for name, var in problem._vars.items():
|
|
451
|
+
fam = lookup_var(name) # KeyError already filtered by the caller.
|
|
452
|
+
ct = fam.column_type
|
|
453
|
+
mp = fam.multiplier_param is not None
|
|
454
|
+
for cid in var.frame["col_id"].to_numpy().tolist():
|
|
455
|
+
out[int(cid)] = (ct, mp)
|
|
456
|
+
return out
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _classify_matrix(
|
|
460
|
+
col_eff: dict[int, tuple[QuantityType, bool]],
|
|
461
|
+
rhs_t: QuantityType | None,
|
|
462
|
+
):
|
|
463
|
+
"""Return a ``col_id -> QuantityType | None`` classifier for a matrix
|
|
464
|
+
family with row type ``rhs_t``.
|
|
465
|
+
|
|
466
|
+
Reproduces :func:`_effective_matrix_type`'s branch row-for-row: when the
|
|
467
|
+
column carries a multiplier param AND the family has an ``rhs_type`` the
|
|
468
|
+
entry buckets against the row's type, else against the column's own type.
|
|
469
|
+
A ``col_id`` with no registered family classifies to ``None`` (the
|
|
470
|
+
reducer drops it — same as the old inner-join missing the row).
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
def classify(cid: int):
|
|
474
|
+
ent = col_eff.get(int(cid))
|
|
475
|
+
if ent is None:
|
|
476
|
+
return None
|
|
477
|
+
col_t, has_mp = ent
|
|
478
|
+
if has_mp and rhs_t is not None:
|
|
479
|
+
return rhs_t
|
|
480
|
+
return col_t
|
|
481
|
+
|
|
482
|
+
return classify
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _classify_cost(col_eff: dict[int, tuple[QuantityType, bool]]):
|
|
486
|
+
"""Return a ``col_id -> column_type | None`` classifier for the
|
|
487
|
+
objective (cost) walk — ``eff_t == column_type`` (no rhs_type)."""
|
|
488
|
+
|
|
489
|
+
def classify(cid: int):
|
|
490
|
+
ent = col_eff.get(int(cid))
|
|
491
|
+
return None if ent is None else ent[0]
|
|
492
|
+
|
|
493
|
+
return classify
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _merge_hist_into_accumulator(
|
|
497
|
+
acc: dict[QuantityType, _AccVal],
|
|
498
|
+
hist: dict[QuantityType, tuple[float, int, float, float]],
|
|
499
|
+
) -> None:
|
|
500
|
+
"""Fold one walked term's :class:`Log2HistogramReducer` result into the
|
|
501
|
+
per-type accumulator.
|
|
502
|
+
|
|
503
|
+
The reducer keys directly by :class:`QuantityType` (the classify
|
|
504
|
+
closures return ``QuantityType`` values), and packs each bucket as
|
|
505
|
+
``(Σlog2|coef|, count, abs_min, abs_max)`` — the SAME packing the
|
|
506
|
+
accumulator carries — so the fold is a direct combine.
|
|
507
|
+
"""
|
|
508
|
+
for t, (slog, cnt, amin, amax) in hist.items():
|
|
509
|
+
if t is None or cnt == 0:
|
|
510
|
+
continue
|
|
511
|
+
ps, pn, pmin, pmax = acc.get(t, _INIT_ACC)
|
|
512
|
+
acc[t] = (
|
|
513
|
+
ps + float(slog),
|
|
514
|
+
pn + int(cnt),
|
|
515
|
+
min(pmin, float(amin)),
|
|
516
|
+
max(pmax, float(amax)),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _obj_term_recipe(term: Any):
|
|
521
|
+
"""Return a column-mode ``(recipe, spine)`` for an objective term, or
|
|
522
|
+
``None`` if the term cannot route through the walk.
|
|
523
|
+
|
|
524
|
+
Routes when the term carries a Var seed the column-mode walk can rebuild:
|
|
525
|
+
a non-Sum term (``var_source`` set) or a pure-RELABEL Sum term
|
|
526
|
+
(``sum_block_meta`` set, ``reduce_dims ⊆ var.dims``, no map-effect Where)
|
|
527
|
+
— exactly the regime ``_ranges._obj_chain_bounded`` admits, where every
|
|
528
|
+
``col_id`` group is single-element so the per-cell product equals the
|
|
529
|
+
reduced coef. A fully-collapsed ``Sum(over=ALL)`` term (``var_source``
|
|
530
|
+
and ``sum_block_meta`` both cleared) returns ``None`` → the caller keeps
|
|
531
|
+
the existing collect.
|
|
532
|
+
"""
|
|
533
|
+
from polar_high.autoscale._coef_walk import CoefWalkRecipe
|
|
534
|
+
|
|
535
|
+
meta = getattr(term, "sum_block_meta", None)
|
|
536
|
+
if meta is not None:
|
|
537
|
+
var = meta.var_source
|
|
538
|
+
if var is None:
|
|
539
|
+
return None
|
|
540
|
+
if meta.where_map_frames is not None:
|
|
541
|
+
return None
|
|
542
|
+
if not set(meta.reduce_dims).issubset(set(var.dims)):
|
|
543
|
+
return None
|
|
544
|
+
recipe = CoefWalkRecipe.from_term(term)
|
|
545
|
+
return recipe, var.frame
|
|
546
|
+
var = getattr(term, "var_source", None)
|
|
547
|
+
if var is None:
|
|
548
|
+
return None
|
|
549
|
+
recipe = CoefWalkRecipe.from_term(term)
|
|
550
|
+
return recipe, var.frame
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def bucket_coefficients(
|
|
554
|
+
problem: Any,
|
|
555
|
+
) -> tuple[
|
|
556
|
+
dict[QuantityType, _AccVal],
|
|
557
|
+
dict[QuantityType, _AccVal],
|
|
558
|
+
dict[QuantityType, _AccVal],
|
|
559
|
+
dict[int, QuantityType],
|
|
560
|
+
]:
|
|
561
|
+
"""Walk ``problem._vars``, ``problem._cstrs``, ``problem._obj_terms``
|
|
562
|
+
and aggregate every nonzero finite coefficient magnitude by its
|
|
563
|
+
effective :class:`QuantityType`.
|
|
564
|
+
|
|
565
|
+
Returns four maps:
|
|
566
|
+
|
|
567
|
+
* ``matrix_acc[t]`` — constraint-matrix entries, packed as
|
|
568
|
+
``(log2_sum, count, abs_min, abs_max)``.
|
|
569
|
+
* ``cost_acc[t]`` — objective coefficients (same packing).
|
|
570
|
+
* ``bound_acc[t]`` — finite variable bounds (same packing).
|
|
571
|
+
RHS magnitudes are not bucketed here — they are handled per-family
|
|
572
|
+
via the row_factor logic.
|
|
573
|
+
* ``col_id_to_type`` — column id → its column QuantityType. Used
|
|
574
|
+
by :func:`apply_layer2` to push per-column factors back into the
|
|
575
|
+
lazy term plans.
|
|
576
|
+
|
|
577
|
+
Implementation note (rewrite 2026-05-31, Phase D-5 step 3): per term
|
|
578
|
+
the per-type histogram is accumulated by walking the block-COO
|
|
579
|
+
``(rid/col_id, coef)`` stream in bounded ``_WALK_BATCH_ROWS`` slices via
|
|
580
|
+
:func:`polar_high.autoscale._coef_walk.bounded_coefficient_walk` +
|
|
581
|
+
:class:`Log2HistogramReducer`, NEVER materialising the merged
|
|
582
|
+
``Var ⋈ P1 ⋈ P2 …`` product. Peak RSS is bounded by one batch's product
|
|
583
|
+
(not the full chain) — this is the change that removes the residual DES
|
|
584
|
+
autoscale spike and lets the previously-skipped huge families be bucketed
|
|
585
|
+
at all. Terms with no recoverable recipe (fully-collapsed
|
|
586
|
+
``Sum(over=ALL)`` ⇒ ``over is None`` / ``dims == ()``) keep the bounded
|
|
587
|
+
``_collect_term_agg`` per-term collect as a backstop.
|
|
588
|
+
|
|
589
|
+
Capability gate: the bounded walk requires ``polar_high.autoscale.
|
|
590
|
+
_coef_walk`` (block-COO). When that module is absent (older polar_high,
|
|
591
|
+
detected once at import as ``_HAVE_COEF_WALK``) this falls back to the
|
|
592
|
+
pre-step-3 behaviour — the materialising ``_collect_term_agg`` collect for
|
|
593
|
+
every term, including the >1M per-family size skip — so the solve still
|
|
594
|
+
autoscales correctly (just unbounded) instead of raising ``ImportError``.
|
|
595
|
+
"""
|
|
596
|
+
classification = _build_col_id_classification(problem)
|
|
597
|
+
classification_lazy = classification.lazy()
|
|
598
|
+
|
|
599
|
+
# ── col_id → column QuantityType (consumed by apply_layer2). Built
|
|
600
|
+
# before the capability branch so both paths share it.
|
|
601
|
+
col_id_to_type: dict[int, QuantityType] = {}
|
|
602
|
+
for name, var in problem._vars.items():
|
|
603
|
+
fam = lookup_var(name) # KeyError already filtered above.
|
|
604
|
+
for cid in var.frame["col_id"].to_numpy().tolist():
|
|
605
|
+
col_id_to_type[int(cid)] = fam.column_type
|
|
606
|
+
|
|
607
|
+
matrix_acc: dict[QuantityType, _AccVal] = {}
|
|
608
|
+
cost_acc: dict[QuantityType, _AccVal] = {}
|
|
609
|
+
bound_acc: dict[QuantityType, _AccVal] = {}
|
|
610
|
+
|
|
611
|
+
# ── Variable bounds: small (≤ 2 per var family); keep in Python
|
|
612
|
+
# but match the (sum_log2, count, min, max) accumulator shape. Bounds
|
|
613
|
+
# never went through the walk, so this is identical on both paths.
|
|
614
|
+
for name, var in problem._vars.items():
|
|
615
|
+
fam = lookup_var(name)
|
|
616
|
+
for b in (var.lower, var.upper):
|
|
617
|
+
if not math.isfinite(b) or b == 0.0:
|
|
618
|
+
continue
|
|
619
|
+
av = abs(float(b))
|
|
620
|
+
lv = math.log2(av)
|
|
621
|
+
if not math.isfinite(lv):
|
|
622
|
+
continue
|
|
623
|
+
ps, pn, pmin, pmax = bound_acc.get(fam.column_type, _INIT_ACC)
|
|
624
|
+
bound_acc[fam.column_type] = (
|
|
625
|
+
ps + lv, pn + 1, min(pmin, av), max(pmax, av),
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
if not _HAVE_COEF_WALK:
|
|
629
|
+
# ── Capability fallback: this polar_high has no block-COO
|
|
630
|
+
# ``_coef_walk`` (e.g. released ``main``). Reproduce the pre-step-3
|
|
631
|
+
# ``bucket_coefficients`` exactly — the materialising
|
|
632
|
+
# ``_collect_term_agg`` collect for every objective and matrix term,
|
|
633
|
+
# including the >1M per-family size skip (driven by the same
|
|
634
|
+
# ``POLAR_HIGH_RANGES_MAX_FAMILY_ROWS`` env var as Layer 1's skip).
|
|
635
|
+
_logger.debug(
|
|
636
|
+
"Layer 2: polar_high lacks _coef_walk; bucketing via the "
|
|
637
|
+
"pre-step-3 per-term collect (unbounded peak, but correct)."
|
|
638
|
+
)
|
|
639
|
+
for term in problem._obj_terms:
|
|
640
|
+
agg = _collect_term_agg(
|
|
641
|
+
term.lazy,
|
|
642
|
+
classification_lazy=classification_lazy,
|
|
643
|
+
rhs_t_id=None,
|
|
644
|
+
)
|
|
645
|
+
if agg is not None:
|
|
646
|
+
_merge_into_accumulator(cost_acc, agg)
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
_max_family_rows = int(
|
|
650
|
+
os.environ.get("POLAR_HIGH_RANGES_MAX_FAMILY_ROWS", "1000000")
|
|
651
|
+
)
|
|
652
|
+
except (ValueError, TypeError):
|
|
653
|
+
_max_family_rows = 1_000_000
|
|
654
|
+
|
|
655
|
+
for cname, proto, over in problem._cstrs:
|
|
656
|
+
row_count = 0 if over is None else int(over.height)
|
|
657
|
+
if _max_family_rows > 0 and row_count > _max_family_rows:
|
|
658
|
+
continue
|
|
659
|
+
try:
|
|
660
|
+
rhs_t = resolve_cstr_rhs_type(cname)
|
|
661
|
+
except KeyError as exc:
|
|
662
|
+
raise KeyError(
|
|
663
|
+
f"Layer 2: constraint {cname!r} not in CONSTRAINT_FAMILIES "
|
|
664
|
+
"— register it in _layer2_types.py before solving."
|
|
665
|
+
) from exc
|
|
666
|
+
rhs_t_id = _qty_to_id(rhs_t)
|
|
667
|
+
for term in proto.expr.terms:
|
|
668
|
+
agg = _collect_term_agg(
|
|
669
|
+
term.lazy,
|
|
670
|
+
classification_lazy=classification_lazy,
|
|
671
|
+
rhs_t_id=rhs_t_id,
|
|
672
|
+
)
|
|
673
|
+
if agg is not None:
|
|
674
|
+
_merge_into_accumulator(matrix_acc, agg)
|
|
675
|
+
|
|
676
|
+
return matrix_acc, cost_acc, bound_acc, col_id_to_type
|
|
677
|
+
|
|
678
|
+
# ── Bounded coefficient-walk path (Phase D-5 step 3). Reached only when
|
|
679
|
+
# this polar_high ships ``_coef_walk`` (gated above at import).
|
|
680
|
+
col_eff = _build_col_id_eff_t(problem)
|
|
681
|
+
dense_axes = getattr(problem, "_dense_axes", None)
|
|
682
|
+
profile = _layer2_bucket_profiler()
|
|
683
|
+
|
|
684
|
+
from polar_high.autoscale._coef_walk import (
|
|
685
|
+
Log2HistogramReducer,
|
|
686
|
+
bounded_coefficient_walk,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
# ``scale=(None, 0, None)`` — bucketing uses RAW |coef| (no side-vector
|
|
690
|
+
# scaling); the reducer's ``_scaled_abs`` then returns ``|coef|`` verbatim.
|
|
691
|
+
_RAW_SCALE: tuple[Any, int, Any] = (None, 0, None)
|
|
692
|
+
|
|
693
|
+
# ── Objective: rhs_t is N/A → eff_t == column_type. Route each term
|
|
694
|
+
# through the bounded column-mode walk when it carries a Var seed the
|
|
695
|
+
# walk can rebuild; otherwise (fully-collapsed Sum) keep the collect.
|
|
696
|
+
cost_classify = _classify_cost(col_eff)
|
|
697
|
+
for ti, term in enumerate(problem._obj_terms):
|
|
698
|
+
if term.lazy is None:
|
|
699
|
+
continue
|
|
700
|
+
routed = _obj_term_recipe(term)
|
|
701
|
+
if routed is not None:
|
|
702
|
+
recipe, spine = routed
|
|
703
|
+
(hist,) = bounded_coefficient_walk(
|
|
704
|
+
spine,
|
|
705
|
+
recipe,
|
|
706
|
+
_RAW_SCALE,
|
|
707
|
+
[Log2HistogramReducer(_RAW_SCALE, cost_classify)],
|
|
708
|
+
batch_rows=_WALK_BATCH_ROWS,
|
|
709
|
+
dense_axes=dense_axes,
|
|
710
|
+
)
|
|
711
|
+
_merge_hist_into_accumulator(cost_acc, hist)
|
|
712
|
+
if profile is not None:
|
|
713
|
+
profile(
|
|
714
|
+
"<objective>", ti, n=int(spine.height), path="walk",
|
|
715
|
+
)
|
|
716
|
+
else:
|
|
717
|
+
agg = _collect_term_agg(
|
|
718
|
+
term.lazy,
|
|
719
|
+
classification_lazy=classification_lazy,
|
|
720
|
+
rhs_t_id=None,
|
|
721
|
+
)
|
|
722
|
+
if agg is not None:
|
|
723
|
+
_merge_into_accumulator(cost_acc, agg)
|
|
724
|
+
if profile is not None:
|
|
725
|
+
profile("<objective>", ti, path="collect")
|
|
726
|
+
|
|
727
|
+
# ── Matrix: per-family walk of the expression terms.
|
|
728
|
+
#
|
|
729
|
+
# NO blanket family-size skip (Phase D-5 step 3): the bounded
|
|
730
|
+
# coefficient walk below caps the per-term peak at one batch's product,
|
|
731
|
+
# so the old >1M skip — which silently dropped the biggest families
|
|
732
|
+
# (e.g. the DES LP's ``profile_flow_upper_limit``, 1.5M rows × multi-
|
|
733
|
+
# Param) from the scaling decision — is no longer needed. Every family's
|
|
734
|
+
# dim-bound LHS terms are now folded into the histogram. Terms the walk
|
|
735
|
+
# cannot rebuild (scalar / no-over / fully-collapsed Sum) take the bounded
|
|
736
|
+
# ``_collect_term_agg`` collect; those are small by construction (no deep
|
|
737
|
+
# product to materialise — the Sum already reduced it).
|
|
738
|
+
from polar_high.autoscale._coef_walk import CoefWalkRecipe
|
|
739
|
+
|
|
740
|
+
for cname, proto, over in problem._cstrs:
|
|
741
|
+
try:
|
|
742
|
+
rhs_t = resolve_cstr_rhs_type(cname)
|
|
743
|
+
except KeyError as exc:
|
|
744
|
+
raise KeyError(
|
|
745
|
+
f"Layer 2: constraint {cname!r} not in CONSTRAINT_FAMILIES "
|
|
746
|
+
"— register it in _layer2_types.py before solving."
|
|
747
|
+
) from exc
|
|
748
|
+
rhs_t_id = _qty_to_id(rhs_t)
|
|
749
|
+
matrix_classify = _classify_matrix(col_eff, rhs_t)
|
|
750
|
+
for ti, term in enumerate(proto.expr.terms):
|
|
751
|
+
# Route a dim-bound LHS term (real ``over`` grid, open dims, a
|
|
752
|
+
# rebuildable Var/Sum recipe) through the bounded walk; anything
|
|
753
|
+
# else (scalar, no ``over``, fully-collapsed Sum with no recipe)
|
|
754
|
+
# keeps the bounded per-term collect backstop.
|
|
755
|
+
# Routability mirrors ``CoefWalkRecipe.from_term``'s exact
|
|
756
|
+
# precondition via ``is_buildable`` (meta present →
|
|
757
|
+
# ``meta.var_source is not None``; else ``term.var_source is not
|
|
758
|
+
# None``). The earlier SHALLOW ``var_source or sum_block_meta``
|
|
759
|
+
# check let a fully-collapsed ``Sum`` (meta present, but
|
|
760
|
+
# ``meta.var_source`` None) through, then ``from_term`` raised.
|
|
761
|
+
routable = (
|
|
762
|
+
over is not None
|
|
763
|
+
and bool(term.dims)
|
|
764
|
+
and CoefWalkRecipe.is_buildable(term)
|
|
765
|
+
)
|
|
766
|
+
if routable:
|
|
767
|
+
recipe = CoefWalkRecipe.from_term(term)
|
|
768
|
+
(hist,) = bounded_coefficient_walk(
|
|
769
|
+
over,
|
|
770
|
+
recipe,
|
|
771
|
+
_RAW_SCALE,
|
|
772
|
+
[Log2HistogramReducer(_RAW_SCALE, matrix_classify)],
|
|
773
|
+
batch_rows=_WALK_BATCH_ROWS,
|
|
774
|
+
dense_axes=dense_axes,
|
|
775
|
+
)
|
|
776
|
+
_merge_hist_into_accumulator(matrix_acc, hist)
|
|
777
|
+
if profile is not None:
|
|
778
|
+
profile(
|
|
779
|
+
cname, ti, over_height=int(over.height), path="walk",
|
|
780
|
+
)
|
|
781
|
+
else:
|
|
782
|
+
agg = _collect_term_agg(
|
|
783
|
+
term.lazy,
|
|
784
|
+
classification_lazy=classification_lazy,
|
|
785
|
+
rhs_t_id=rhs_t_id,
|
|
786
|
+
)
|
|
787
|
+
if agg is not None:
|
|
788
|
+
_merge_into_accumulator(matrix_acc, agg)
|
|
789
|
+
if profile is not None:
|
|
790
|
+
profile(
|
|
791
|
+
cname, ti,
|
|
792
|
+
over_height=(0 if over is None else int(over.height)),
|
|
793
|
+
path="collect",
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
return matrix_acc, cost_acc, bound_acc, col_id_to_type
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# ---------------------------------------------------------------------------
|
|
800
|
+
# Small helpers
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _col_id_cache(problem: Any) -> dict[int, str]:
|
|
804
|
+
cache: dict[int, str] = {}
|
|
805
|
+
for name, var in problem._vars.items():
|
|
806
|
+
for cid in var.frame["col_id"].to_numpy().tolist():
|
|
807
|
+
cache[int(cid)] = name
|
|
808
|
+
return cache
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# Memoised per-problem; rebuilt by ``_col_id_to_var_name``'s first call.
|
|
812
|
+
_COL_ID_CACHE_ATTR = "_autoscale_col_id_cache"
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _col_id_to_var_name(problem: Any, col_id: int) -> str:
|
|
816
|
+
cache = getattr(problem, _COL_ID_CACHE_ATTR, None)
|
|
817
|
+
if cache is None:
|
|
818
|
+
cache = _col_id_cache(problem)
|
|
819
|
+
try:
|
|
820
|
+
setattr(problem, _COL_ID_CACHE_ATTR, cache)
|
|
821
|
+
except Exception:
|
|
822
|
+
# If the Problem doesn't permit dynamic attrs, fall back
|
|
823
|
+
# to per-call recomputation. polar-high's Problem has a
|
|
824
|
+
# regular __dict__, so this branch should not fire.
|
|
825
|
+
pass
|
|
826
|
+
return cache[col_id]
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _bucket_range(values: list[float]) -> tuple[float, float]:
|
|
830
|
+
if not values:
|
|
831
|
+
return (math.nan, math.nan)
|
|
832
|
+
arr = np.asarray(values)
|
|
833
|
+
return (float(arr.min()), float(arr.max()))
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
# ---------------------------------------------------------------------------
|
|
837
|
+
# Power-of-two exponent picking
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def choose_scale_powers(
|
|
841
|
+
matrix_acc: dict[QuantityType, _AccVal],
|
|
842
|
+
cost_acc: dict[QuantityType, _AccVal],
|
|
843
|
+
bound_acc: dict[QuantityType, _AccVal],
|
|
844
|
+
*,
|
|
845
|
+
clamp: int = _DEFAULT_CLAMP,
|
|
846
|
+
) -> dict[QuantityType, int]:
|
|
847
|
+
"""Pick a power-of-two exponent for each :class:`QuantityType` seen.
|
|
848
|
+
|
|
849
|
+
Per Bröchin et al. (2024) and the autoscaler handoff
|
|
850
|
+
(``specs/flextool-autoscaling-handoff.md``):
|
|
851
|
+
|
|
852
|
+
s_t = round( log2( 1 / geomean(|values|) ) )
|
|
853
|
+
|
|
854
|
+
where the geometric mean is computed via
|
|
855
|
+
``log_mean = sum(log2|values|) / count`` pooled across the matrix,
|
|
856
|
+
cost, and bound accumulators of type ``t``. We clamp into
|
|
857
|
+
``[-clamp, +clamp]`` to keep the factors inside double precision's
|
|
858
|
+
comfort zone.
|
|
859
|
+
|
|
860
|
+
Returns a dict of :class:`QuantityType` → int. Types with no
|
|
861
|
+
samples (``count == 0``) are absent (so callers can default their
|
|
862
|
+
factor to 1.0).
|
|
863
|
+
|
|
864
|
+
Mathematically equivalent to the pre-2026-05-27 implementation that
|
|
865
|
+
pooled raw magnitude lists, modulo at most ±1 on values that sit on
|
|
866
|
+
a half-integer ``round`` boundary (sum-then-divide vs.
|
|
867
|
+
``np.log2(arr).mean()`` reorder floating-point ops).
|
|
868
|
+
"""
|
|
869
|
+
pool: dict[QuantityType, tuple[float, int]] = {}
|
|
870
|
+
for src in (matrix_acc, cost_acc, bound_acc):
|
|
871
|
+
for t, val in src.items():
|
|
872
|
+
log_sum, count = val[0], val[1]
|
|
873
|
+
ps, pn = pool.get(t, (0.0, 0))
|
|
874
|
+
pool[t] = (ps + float(log_sum), pn + int(count))
|
|
875
|
+
|
|
876
|
+
chosen: dict[QuantityType, int] = {}
|
|
877
|
+
for t, (log_sum, count) in pool.items():
|
|
878
|
+
if count == 0:
|
|
879
|
+
continue
|
|
880
|
+
log_mean = log_sum / count
|
|
881
|
+
exp = int(round(-log_mean))
|
|
882
|
+
if exp > clamp:
|
|
883
|
+
exp = clamp
|
|
884
|
+
elif exp < -clamp:
|
|
885
|
+
exp = -clamp
|
|
886
|
+
chosen[t] = exp
|
|
887
|
+
|
|
888
|
+
return chosen
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
# ---------------------------------------------------------------------------
|
|
892
|
+
# Public API
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def apply_layer2(
|
|
896
|
+
problem: Any,
|
|
897
|
+
config: ScalingConfig,
|
|
898
|
+
) -> Layer2Plan:
|
|
899
|
+
"""Apply Layer 2 to ``problem`` in place.
|
|
900
|
+
|
|
901
|
+
Mutates ``problem._vars`` (bound rescale on non-integer columns)
|
|
902
|
+
and writes the two side vectors
|
|
903
|
+
``problem._layer2_col_factor`` / ``problem._layer2_row_factor``
|
|
904
|
+
that the polar-high consumers (``write_mps``, ``_build_lp_arrays``,
|
|
905
|
+
``_solve_streaming``, ``WarmProblem._initial_build``,
|
|
906
|
+
``LpView.from_problem``) multiply into the emitted LHS / cost /
|
|
907
|
+
RHS at consumption time. Also sets ``problem._layer2_locked =
|
|
908
|
+
True`` to prevent post-Layer-2 structural changes that would
|
|
909
|
+
invalidate the side-vector sizes.
|
|
910
|
+
|
|
911
|
+
Does NOT mutate ``problem._cstrs`` or ``problem._obj_terms`` —
|
|
912
|
+
the GLPK-style "scaling lives as metadata, coefficients are
|
|
913
|
+
immutable" property. Returns a :class:`Layer2Plan` carrying the
|
|
914
|
+
inverse transform for :func:`unscale_solution`.
|
|
915
|
+
"""
|
|
916
|
+
matrix_acc, cost_acc, bound_acc, col_id_to_type = bucket_coefficients(problem)
|
|
917
|
+
exponents = choose_scale_powers(matrix_acc, cost_acc, bound_acc)
|
|
918
|
+
|
|
919
|
+
# Per-type bucket-range reporting (computed from the accumulators that
|
|
920
|
+
# the walk above produced). These feed the YAML / console report only;
|
|
921
|
+
# they are NOT needed to install the side vectors, so the
|
|
922
|
+
# exponents-only replay path (:func:`apply_layer2_with_exponents`)
|
|
923
|
+
# passes ``None`` and the plan carries empty buckets.
|
|
924
|
+
all_types = set(matrix_acc) | set(cost_acc) | set(bound_acc)
|
|
925
|
+
type_buckets_before: dict[QuantityType, tuple[float, float]] = {}
|
|
926
|
+
type_buckets_after: dict[QuantityType, tuple[float, float]] = {}
|
|
927
|
+
for t in all_types:
|
|
928
|
+
amin = math.inf
|
|
929
|
+
amax = 0.0
|
|
930
|
+
any_seen = False
|
|
931
|
+
for src in (matrix_acc, cost_acc, bound_acc):
|
|
932
|
+
if t in src:
|
|
933
|
+
_, count, mn, mx = src[t]
|
|
934
|
+
if count > 0:
|
|
935
|
+
amin = min(amin, mn)
|
|
936
|
+
amax = max(amax, mx)
|
|
937
|
+
any_seen = True
|
|
938
|
+
if not any_seen:
|
|
939
|
+
continue
|
|
940
|
+
type_buckets_before[t] = (float(amin), float(amax))
|
|
941
|
+
exp = exponents.get(t, 0)
|
|
942
|
+
f = float(2 ** exp)
|
|
943
|
+
type_buckets_after[t] = (float(amin * f), float(amax * f))
|
|
944
|
+
|
|
945
|
+
return apply_layer2_with_exponents(
|
|
946
|
+
problem,
|
|
947
|
+
exponents,
|
|
948
|
+
type_buckets_before=type_buckets_before,
|
|
949
|
+
type_buckets_after=type_buckets_after,
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def apply_layer2_with_exponents(
|
|
954
|
+
problem: Any,
|
|
955
|
+
exponents: dict[QuantityType, int],
|
|
956
|
+
*,
|
|
957
|
+
type_buckets_before: dict[QuantityType, tuple[float, float]] | None = None,
|
|
958
|
+
type_buckets_after: dict[QuantityType, tuple[float, float]] | None = None,
|
|
959
|
+
) -> Layer2Plan:
|
|
960
|
+
"""Install Layer-2 side vectors on ``problem`` from KNOWN exponents.
|
|
961
|
+
|
|
962
|
+
This is the cheap second half of :func:`apply_layer2` — it walks
|
|
963
|
+
only ``problem._vars`` (O(#var families)) and ``problem._cstrs``
|
|
964
|
+
(O(#constraint families)) to map the per-type ``exponents`` onto
|
|
965
|
+
column / row factors. It performs NO coefficient traversal
|
|
966
|
+
(``bucket_coefficients`` / ``detect_ranges`` / ``_ranges_via_streaming``)
|
|
967
|
+
and therefore none of the multi-GB transient working set those walks
|
|
968
|
+
spike.
|
|
969
|
+
|
|
970
|
+
Used by the orchestrator's per-roll autoscale cache: the first solve
|
|
971
|
+
of a structural shape runs the full :func:`apply_layer2` (deriving
|
|
972
|
+
``exponents``); every subsequent same-shape roll replays the decision
|
|
973
|
+
here against THIS roll's freshly-built ``Problem``, producing
|
|
974
|
+
byte-identical scaled coefficients (the side vectors depend only on
|
|
975
|
+
the per-type exponents and the column/row family layout, both of
|
|
976
|
+
which are invariant for a fixed structural fingerprint).
|
|
977
|
+
|
|
978
|
+
``type_buckets_*`` are optional reporting fields; when omitted the
|
|
979
|
+
returned plan carries empty buckets (the per-roll YAML report's
|
|
980
|
+
Layer-2 section is then range-free, but the scaling applied to the LP
|
|
981
|
+
is identical).
|
|
982
|
+
"""
|
|
983
|
+
n_cols = problem._next_col
|
|
984
|
+
col_factors = np.ones(n_cols, dtype=np.float64)
|
|
985
|
+
|
|
986
|
+
# ── Column factors -------------------------------------------------
|
|
987
|
+
integer_cols: list[int] = []
|
|
988
|
+
for name, var in problem._vars.items():
|
|
989
|
+
if var.integer:
|
|
990
|
+
ids = var.frame["col_id"].to_numpy()
|
|
991
|
+
integer_cols.extend(int(c) for c in ids.tolist())
|
|
992
|
+
continue
|
|
993
|
+
fam = lookup_var(name)
|
|
994
|
+
col_t = fam.column_type
|
|
995
|
+
exp = exponents.get(col_t)
|
|
996
|
+
if exp is None:
|
|
997
|
+
continue
|
|
998
|
+
f = float(2 ** exp)
|
|
999
|
+
ids = var.frame["col_id"].to_numpy()
|
|
1000
|
+
col_factors[ids] = f
|
|
1001
|
+
|
|
1002
|
+
# Variable bound mutation — multiply finite bounds by col_factor.
|
|
1003
|
+
# Skip integer columns (col_factors[j] == 1.0 there by construction).
|
|
1004
|
+
# This is the one place Layer 2 mutates state that's not behind the
|
|
1005
|
+
# side vectors; intentional because Var.lower/upper are scalar per
|
|
1006
|
+
# family and the cost is O(n_var_families), no peak-memory concern.
|
|
1007
|
+
for name, var in problem._vars.items():
|
|
1008
|
+
if var.integer:
|
|
1009
|
+
continue
|
|
1010
|
+
fam = lookup_var(name)
|
|
1011
|
+
f = float(2 ** exponents[fam.column_type]) if fam.column_type in exponents else 1.0
|
|
1012
|
+
if f == 1.0:
|
|
1013
|
+
continue
|
|
1014
|
+
if math.isfinite(var.lower):
|
|
1015
|
+
var.lower = float(var.lower) * f
|
|
1016
|
+
if math.isfinite(var.upper):
|
|
1017
|
+
var.upper = float(var.upper) * f
|
|
1018
|
+
|
|
1019
|
+
# ── Row factors ----------------------------------------------------
|
|
1020
|
+
# Walk ``_cstrs`` in the same order consumers do; ``row_factors_list``
|
|
1021
|
+
# is built 0-based per constraint row. The cost row is NOT in this
|
|
1022
|
+
# vector (objective gets column scaling only — GLPK convention).
|
|
1023
|
+
row_factors_list: list[float] = []
|
|
1024
|
+
skipped_rows: list[str] = []
|
|
1025
|
+
|
|
1026
|
+
for cname, proto, over in problem._cstrs:
|
|
1027
|
+
rhs_t = resolve_cstr_rhs_type(cname)
|
|
1028
|
+
if rhs_t is None:
|
|
1029
|
+
rf = 1.0
|
|
1030
|
+
else:
|
|
1031
|
+
exp = exponents.get(rhs_t)
|
|
1032
|
+
rf = float(2 ** exp) if exp is not None else 1.0
|
|
1033
|
+
row_count = 1 if over is None else int(over.height)
|
|
1034
|
+
if rhs_t is None and row_count > 0:
|
|
1035
|
+
skipped_rows.append(cname)
|
|
1036
|
+
row_factors_list.extend([rf] * row_count)
|
|
1037
|
+
|
|
1038
|
+
row_factors = np.asarray(row_factors_list, dtype=np.float64)
|
|
1039
|
+
|
|
1040
|
+
# ── Install side vectors on the Problem ---------------------------
|
|
1041
|
+
# Size assertion: col_factors must be exactly n_cols. row_factors
|
|
1042
|
+
# is indexed 0-based by constraint row in the order consumers walk
|
|
1043
|
+
# ``_cstrs`` (the cost row is not in this vector).
|
|
1044
|
+
#
|
|
1045
|
+
# IMPORTANT — convention asymmetry. The math (see module docstring):
|
|
1046
|
+
#
|
|
1047
|
+
# x_scaled[j] = col_factor[j] * x[j]
|
|
1048
|
+
# matrix_scaled[i,j] = matrix[i,j] * row_factor[i] / col_factor[j]
|
|
1049
|
+
# cost_scaled[j] = cost[j] / col_factor[j]
|
|
1050
|
+
# rhs_scaled[i] = rhs[i] * row_factor[i]
|
|
1051
|
+
#
|
|
1052
|
+
# Consumers (write_mps, _build_lp_arrays, _solve_streaming,
|
|
1053
|
+
# WarmProblem._initial_build, LpView.from_problem) multiply emitted
|
|
1054
|
+
# values by ``_layer2_row_factor[i]`` and ``_layer2_col_factor[j]``
|
|
1055
|
+
# *directly* — no inversion. For matrix and cost that means the
|
|
1056
|
+
# value we install in ``_layer2_col_factor`` is ``1 / col_factor``
|
|
1057
|
+
# (the inverse of the math ``cf``), so that ``vals * _cf[j]`` yields
|
|
1058
|
+
# the math-correct ``vals / cf[j]``. ``_layer2_row_factor`` is the
|
|
1059
|
+
# forward ``rf`` — applied unchanged to matrix LHS and RHS.
|
|
1060
|
+
#
|
|
1061
|
+
# ``Layer2Plan.col_factors`` keeps the FORWARD ``cf`` (used by
|
|
1062
|
+
# ``unscale_solution`` as ``cv / cf``); only the side vector on the
|
|
1063
|
+
# Problem is inverted. Since every ``cf`` is a power of two,
|
|
1064
|
+
# ``1/cf`` is exact in IEEE.
|
|
1065
|
+
assert col_factors.shape[0] == problem._next_col, (
|
|
1066
|
+
f"Layer 2: col_factors length {col_factors.shape[0]} != "
|
|
1067
|
+
f"problem._next_col {problem._next_col}"
|
|
1068
|
+
)
|
|
1069
|
+
problem._layer2_col_factor = 1.0 / col_factors
|
|
1070
|
+
problem._layer2_row_factor = row_factors
|
|
1071
|
+
# Lock AFTER writing both arrays — otherwise the writes themselves
|
|
1072
|
+
# could trip future guards if they touch any locked code path.
|
|
1073
|
+
problem._layer2_locked = True
|
|
1074
|
+
# Invalidate any cached canonical matrix on the Problem (polar-high
|
|
1075
|
+
# B1, commit a3dd35f). The side vectors above are baked into
|
|
1076
|
+
# ``_matrix.val`` at ``canonicalise()`` time, so an existing cached
|
|
1077
|
+
# matrix built BEFORE this call is stale. No current call path
|
|
1078
|
+
# triggers the stale-cache scenario (canonicalise is normally first
|
|
1079
|
+
# reached AFTER apply_layer2 via write_mps / _build_lp_arrays), but
|
|
1080
|
+
# B2's _build_lp_arrays migration would route every non-streaming
|
|
1081
|
+
# solve through canonicalise(), making a sequence like
|
|
1082
|
+
# ``write_mps → apply_layer2 → solve`` produce silently wrong
|
|
1083
|
+
# results without this flag.
|
|
1084
|
+
problem._canonical_dirty = True
|
|
1085
|
+
|
|
1086
|
+
plan = Layer2Plan(
|
|
1087
|
+
col_factors=col_factors,
|
|
1088
|
+
row_factors=row_factors,
|
|
1089
|
+
type_exponents=dict(exponents),
|
|
1090
|
+
type_buckets_before=dict(type_buckets_before or {}),
|
|
1091
|
+
type_buckets_after=dict(type_buckets_after or {}),
|
|
1092
|
+
skipped_rows=skipped_rows,
|
|
1093
|
+
skipped_integer_cols=integer_cols,
|
|
1094
|
+
)
|
|
1095
|
+
return plan
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
# ---------------------------------------------------------------------------
|
|
1099
|
+
# Unscale
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def unscale_solution(sol: Any, plan: Layer2Plan) -> None:
|
|
1103
|
+
"""In-place: undo the Layer-2 forward transform on ``sol``.
|
|
1104
|
+
|
|
1105
|
+
Mutates ``sol.col_value``, ``sol.col_dual``, ``sol.row_dual``. Also
|
|
1106
|
+
mirrors the unscaled values onto ``sol.highs`` (the live solver
|
|
1107
|
+
handle) so downstream writers that read ``h.getSolution().col_value``
|
|
1108
|
+
see physical-coordinate values rather than the scaled solver state.
|
|
1109
|
+
``sol.obj`` is invariant under the (c→c/cf, x→cf·x) substitution
|
|
1110
|
+
and is left untouched.
|
|
1111
|
+
|
|
1112
|
+
Length checks ensure the plan matches the solution. Mismatch
|
|
1113
|
+
indicates the caller wired the plan to the wrong solve; we raise
|
|
1114
|
+
rather than silently corrupt the results.
|
|
1115
|
+
"""
|
|
1116
|
+
cv = np.asarray(sol.col_value, dtype=np.float64)
|
|
1117
|
+
if cv.shape[0] != plan.col_factors.shape[0]:
|
|
1118
|
+
raise ValueError(
|
|
1119
|
+
f"Layer 2 unscale: col_value length {cv.shape[0]} != "
|
|
1120
|
+
f"col_factors length {plan.col_factors.shape[0]}"
|
|
1121
|
+
)
|
|
1122
|
+
new_col_value = cv / plan.col_factors
|
|
1123
|
+
sol.col_value = new_col_value
|
|
1124
|
+
|
|
1125
|
+
new_col_dual: "np.ndarray | None" = None
|
|
1126
|
+
cd = np.asarray(getattr(sol, "col_dual", None), dtype=np.float64) \
|
|
1127
|
+
if getattr(sol, "col_dual", None) is not None else None
|
|
1128
|
+
if cd is not None and cd.size > 0:
|
|
1129
|
+
if cd.shape[0] != plan.col_factors.shape[0]:
|
|
1130
|
+
raise ValueError(
|
|
1131
|
+
f"Layer 2 unscale: col_dual length {cd.shape[0]} != "
|
|
1132
|
+
f"col_factors length {plan.col_factors.shape[0]}"
|
|
1133
|
+
)
|
|
1134
|
+
new_col_dual = cd * plan.col_factors
|
|
1135
|
+
sol.col_dual = new_col_dual
|
|
1136
|
+
|
|
1137
|
+
new_row_dual: "np.ndarray | None" = None
|
|
1138
|
+
rd = np.asarray(getattr(sol, "row_dual", None), dtype=np.float64) \
|
|
1139
|
+
if getattr(sol, "row_dual", None) is not None else None
|
|
1140
|
+
if rd is not None and rd.size > 0:
|
|
1141
|
+
if rd.shape[0] != plan.row_factors.shape[0]:
|
|
1142
|
+
raise ValueError(
|
|
1143
|
+
f"Layer 2 unscale: row_dual length {rd.shape[0]} != "
|
|
1144
|
+
f"row_factors length {plan.row_factors.shape[0]}"
|
|
1145
|
+
)
|
|
1146
|
+
new_row_dual = rd * plan.row_factors
|
|
1147
|
+
sol.row_dual = new_row_dual
|
|
1148
|
+
|
|
1149
|
+
_push_unscaled_to_highs(
|
|
1150
|
+
sol,
|
|
1151
|
+
new_col_value=new_col_value,
|
|
1152
|
+
new_col_dual=new_col_dual,
|
|
1153
|
+
new_row_dual=new_row_dual,
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _push_unscaled_to_highs(
|
|
1158
|
+
sol: Any,
|
|
1159
|
+
*,
|
|
1160
|
+
new_col_value: "np.ndarray | None" = None,
|
|
1161
|
+
new_col_dual: "np.ndarray | None" = None,
|
|
1162
|
+
new_row_dual: "np.ndarray | None" = None,
|
|
1163
|
+
) -> None:
|
|
1164
|
+
"""Mirror the unscaled values onto ``sol.highs``.
|
|
1165
|
+
|
|
1166
|
+
Downstream output writers (``process_outputs.read_highs_solution``)
|
|
1167
|
+
consume ``h.getSolution().col_value`` directly off the solver handle;
|
|
1168
|
+
without this push they would see the scaled solver state and write
|
|
1169
|
+
physically-meaningless values. Two handle types appear in practice:
|
|
1170
|
+
|
|
1171
|
+
* The duck-typed ``_SolHighsShim`` (cold HiGHS subprocess path) —
|
|
1172
|
+
direct attribute assignment on its ``_SolutionView``.
|
|
1173
|
+
* A real :class:`highspy.Highs` (warm path; commercial-solver cold
|
|
1174
|
+
path that injected primal via ``setSolution``) — round-trip through
|
|
1175
|
+
a fresh ``HighsSolution`` pushed via ``setSolution``.
|
|
1176
|
+
"""
|
|
1177
|
+
h = getattr(sol, "highs", None)
|
|
1178
|
+
if h is None:
|
|
1179
|
+
return
|
|
1180
|
+
|
|
1181
|
+
try:
|
|
1182
|
+
sv = h.getSolution()
|
|
1183
|
+
except Exception:
|
|
1184
|
+
sv = None
|
|
1185
|
+
|
|
1186
|
+
# Shim path: the ``_SolHighsShim._SolutionView`` is a tiny class
|
|
1187
|
+
# whose ``__slots__`` advertise ``col_value`` / ``col_dual`` /
|
|
1188
|
+
# ``row_dual``. Direct assignment makes ``h.getSolution()`` return
|
|
1189
|
+
# the unscaled arrays on the next call.
|
|
1190
|
+
sv_slots = getattr(sv, "__slots__", None) if sv is not None else None
|
|
1191
|
+
if sv_slots is not None and "col_value" in sv_slots:
|
|
1192
|
+
if new_col_value is not None:
|
|
1193
|
+
sv.col_value = new_col_value
|
|
1194
|
+
if new_col_dual is not None:
|
|
1195
|
+
sv.col_dual = new_col_dual
|
|
1196
|
+
if new_row_dual is not None:
|
|
1197
|
+
sv.row_dual = new_row_dual
|
|
1198
|
+
return
|
|
1199
|
+
|
|
1200
|
+
# Real highspy.Highs path: round-trip through HighsSolution +
|
|
1201
|
+
# setSolution. ``setSolution`` after ``run`` overwrites the solver's
|
|
1202
|
+
# stored solution — verified by the inline unit test in
|
|
1203
|
+
# ``tests/test_autoscale_unscale_highs_pushback.py``.
|
|
1204
|
+
#
|
|
1205
|
+
# BUG (fixed): ``setSolution`` AFTER ``run`` also resets HiGHS's cached
|
|
1206
|
+
# ``getObjectiveValue()`` to 0.0 (verified empirically against highspy
|
|
1207
|
+
# 1.14.0). ``write_v_obj`` reads that very value to derive
|
|
1208
|
+
# ``v_obj__{solve}.parquet`` / ``total_cost.val``, so without the
|
|
1209
|
+
# rescue path below the post-autoscale objective collapses to zero
|
|
1210
|
+
# whenever Layer 2 fires on a warm-path solve (this manifested as
|
|
1211
|
+
# the three ``test_commodity_ladder_rolling`` failures in v56). Stash
|
|
1212
|
+
# the pre-setSolution objective on the handle so the writer can
|
|
1213
|
+
# prefer it over the zeroed-out cache.
|
|
1214
|
+
try:
|
|
1215
|
+
import highspy
|
|
1216
|
+
except ImportError: # pragma: no cover — highspy is a hard dep
|
|
1217
|
+
return
|
|
1218
|
+
try:
|
|
1219
|
+
# Capture HiGHS's post-run objective BEFORE setSolution wipes it.
|
|
1220
|
+
# ``sol.obj`` may already be the unscaled-by-Layer-2 value
|
|
1221
|
+
# (Layer 2 cost-side substitution is c -> c / cf, x -> cf · x,
|
|
1222
|
+
# so c^T x is invariant; ``sol.obj`` is left untouched by
|
|
1223
|
+
# ``unscale_solution``). Read straight off the handle so the
|
|
1224
|
+
# rescue path captures whatever the live HiGHS just reported,
|
|
1225
|
+
# including HiGHS's own user_bound_scale unscale.
|
|
1226
|
+
pre_obj = float(h.getObjectiveValue())
|
|
1227
|
+
hs = highspy.HighsSolution()
|
|
1228
|
+
cv_push = new_col_value if new_col_value is not None else sol.col_value
|
|
1229
|
+
hs.col_value = np.asarray(cv_push, dtype=np.float64).tolist()
|
|
1230
|
+
hs.value_valid = True
|
|
1231
|
+
rd_push = new_row_dual if new_row_dual is not None else getattr(sol, "row_dual", None)
|
|
1232
|
+
if rd_push is not None and np.asarray(rd_push).size > 0:
|
|
1233
|
+
hs.row_dual = np.asarray(rd_push, dtype=np.float64).tolist()
|
|
1234
|
+
hs.dual_valid = True
|
|
1235
|
+
h.setSolution(hs)
|
|
1236
|
+
# Re-attach the captured objective so writers (see
|
|
1237
|
+
# ``process_outputs.read_highs_solution.write_v_obj``) can
|
|
1238
|
+
# bypass the zeroed ``getObjectiveValue()`` cache.
|
|
1239
|
+
try:
|
|
1240
|
+
h._flextool_unscaled_objective = pre_obj # type: ignore[attr-defined]
|
|
1241
|
+
except Exception: # pragma: no cover — handle may forbid setattr
|
|
1242
|
+
pass
|
|
1243
|
+
except Exception: # pragma: no cover — version-specific highspy quirk
|
|
1244
|
+
pass
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
__all__ = [
|
|
1248
|
+
"Layer2Plan",
|
|
1249
|
+
"apply_layer2",
|
|
1250
|
+
"bucket_coefficients",
|
|
1251
|
+
"choose_scale_powers",
|
|
1252
|
+
"unscale_solution",
|
|
1253
|
+
]
|