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,451 @@
1
+ """Precision cleanup and near-duplicate detection for input CSV parameters.
2
+
3
+ This module implements the Agent 7 precision cleanup pass for the
4
+ LP-scaling project (see
5
+ ``~/.claude/projects/-home-jkiviluo-sources-flextool/memory/project_lp_scaling_2026-04.md``).
6
+
7
+ Two user-facing entry points:
8
+
9
+ * :func:`round_to_sigfigs` — round a float to N significant figures.
10
+ Used by :mod:`flextool.spinedb_backend._backend` (structural-parameter
11
+ precision policy) to collapse
12
+ accumulated floating-point noise (e.g. ``1/3`` stored as
13
+ ``0.3333333333333333``) so HiGHS' ``mip_detect_symmetry`` presolver
14
+ collapses structurally-identical coefficients.
15
+
16
+ * :func:`find_near_duplicates` / :func:`report_near_duplicates` — a
17
+ diagnostic that clusters nearly-equal values within each parameter
18
+ CSV. Purpose: point users at cases where symmetry detection could
19
+ benefit if the values were made *exactly* equal. Silent by default;
20
+ only runs when ``--report-near-duplicates`` (or the corresponding
21
+ env var) is set.
22
+
23
+ Design constraints:
24
+
25
+ * Stdlib only (``math``, ``os``, ``csv``, ``pathlib``). No new deps.
26
+ * Default behaviour must be unchanged — a run with no CLI flag, no
27
+ env var, and ``precision_digits=0`` must round-trip every CSV
28
+ byte-for-byte with the pre-Agent-7 state.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import csv
34
+ import math
35
+ import os
36
+ import warnings
37
+ from collections.abc import Iterable, Iterator
38
+ from pathlib import Path
39
+ from typing import Optional
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Precision env-var resolution
44
+ # ---------------------------------------------------------------------------
45
+
46
+ PRECISION_ENV_VAR = "FLEXTOOL_PRECISION_DIGITS"
47
+ """Environment variable fallback for the ``--precision-digits`` CLI flag."""
48
+
49
+ REPORT_ENV_VAR = "FLEXTOOL_REPORT_NEAR_DUPS"
50
+ """Environment variable fallback for ``--report-near-duplicates``."""
51
+
52
+ DEFAULT_PRECISION_DIGITS = 10
53
+ """Default significant-figure count when the user enables rounding by
54
+ setting the CLI flag (or env var) to a non-zero value but gives no explicit
55
+ digit count. The CLI passes ``10`` explicitly, so this is only the
56
+ fallback used by library callers / tests."""
57
+
58
+ _WARNED_HIGH_PRECISION = False
59
+
60
+
61
+ def resolve_precision_digits(cli_value: Optional[int]) -> int:
62
+ """Resolve the effective precision setting.
63
+
64
+ Precedence: explicit CLI value (``--precision-digits N`` or None when
65
+ the flag is not passed) overrides the env var. ``0`` or a negative
66
+ value disables rounding (passthrough). Values > 15 exceed
67
+ ``float64`` precision — emits a one-shot warning and returns 0 so
68
+ rounding is a no-op.
69
+
70
+ Returns the effective digit count, or 0 when rounding is disabled.
71
+ """
72
+ global _WARNED_HIGH_PRECISION
73
+ if cli_value is None:
74
+ raw = os.environ.get(PRECISION_ENV_VAR, "").strip()
75
+ if not raw:
76
+ return 0
77
+ try:
78
+ value = int(raw)
79
+ except ValueError:
80
+ warnings.warn(
81
+ f"{PRECISION_ENV_VAR}={raw!r} is not an integer; "
82
+ f"ignoring and disabling precision rounding.",
83
+ RuntimeWarning,
84
+ stacklevel=2,
85
+ )
86
+ return 0
87
+ else:
88
+ value = int(cli_value)
89
+ if value <= 0:
90
+ return 0
91
+ if value > 15:
92
+ if not _WARNED_HIGH_PRECISION:
93
+ print(
94
+ f"WARNING: precision_digits={value} is higher than "
95
+ f"float64 precision, no-op (passthrough)."
96
+ )
97
+ _WARNED_HIGH_PRECISION = True
98
+ return 0
99
+ return value
100
+
101
+
102
+ def resolve_report_near_duplicates(cli_flag: bool) -> bool:
103
+ """True if the CLI flag is set OR the env var resolves truthy."""
104
+ if cli_flag:
105
+ return True
106
+ raw = os.environ.get(REPORT_ENV_VAR, "").strip().lower()
107
+ return raw in ("1", "true", "yes", "on")
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # round_to_sigfigs
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def round_to_sigfigs(value: float, digits: int) -> float:
116
+ """Round *value* to *digits* significant figures.
117
+
118
+ Edge cases:
119
+
120
+ * ``0.0`` stays ``0.0`` (sign preserved).
121
+ * ``nan`` / ``inf`` / ``-inf`` pass through unchanged.
122
+ * Very small values (``abs(value) < 1e-300``) round to ``0.0`` —
123
+ they would overflow the exponent arithmetic.
124
+ * ``digits <= 0`` returns *value* unchanged (pass-through sentinel).
125
+ * Negative values round symmetrically around 0.
126
+
127
+ The classical formula is
128
+ ``scale = 10 ** (floor(log10(|value|)) + 1 - digits)``;
129
+ rounding via Python's builtin ``round(value, ndigits)`` avoids the
130
+ textbook ``round(v/scale)*scale`` double-rounding artefact (e.g.
131
+ ``0.3333... @ 4 sig figs`` would otherwise come back as
132
+ ``0.33330000000000004`` because ``10**-4`` is not exactly
133
+ representable). ``ndigits`` is negative when rounding above the
134
+ decimal point, which ``round`` handles natively.
135
+ """
136
+ if digits <= 0:
137
+ return value
138
+ if value == 0.0 or not math.isfinite(value):
139
+ return value
140
+ abs_value = abs(value)
141
+ if abs_value < 1e-300:
142
+ # Sub-denormal — would blow up the exponent below.
143
+ return 0.0
144
+ # Position of the leading significant digit (base 10).
145
+ exponent = math.floor(math.log10(abs_value))
146
+ # Python's builtin ``round(x, ndigits)`` uses decimal-aware rounding
147
+ # that avoids the double-rounding artefact of the textbook
148
+ # ``round(x / scale) * scale`` (where 10**-4 cannot be represented
149
+ # exactly). We compute ``ndigits`` so that after rounding there are
150
+ # ``digits`` significant figures. For 0.3333... with digits=4, that
151
+ # is ``ndigits = digits - exponent - 1 = 4 - (-1) - 1 = 4`` → 0.3333.
152
+ ndigits = digits - exponent - 1
153
+ # ``round`` accepts negative ndigits for rounding to tens, hundreds, …
154
+ return round(value, ndigits)
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # CSV-writer hook: format a scalar for the input/ CSV stream
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def format_scalar_for_csv(value, precision_digits: int) -> str:
163
+ """Return the string form of *value* that should land in the CSV.
164
+
165
+ * When *value* is a float / int (or a string that parses as one)
166
+ and ``precision_digits > 0``, the numeric is rounded to that many
167
+ significant figures first.
168
+ * Integer values pass through untouched (``.is_integer()`` heuristic).
169
+ * Non-numeric strings (entity names, method names, ``"yes"``/``"no"``)
170
+ pass through ``str()`` unchanged.
171
+
172
+ Default behaviour (``precision_digits == 0``) is identical to
173
+ ``str(value)`` — the pre-Agent-7 writer convention — so benchmarks
174
+ round-trip byte-for-byte.
175
+ """
176
+ # Fast path: rounding disabled → exactly the old behaviour.
177
+ if precision_digits <= 0:
178
+ return str(value)
179
+
180
+ # True bools: "True"/"False" — pass through.
181
+ if isinstance(value, bool):
182
+ return str(value)
183
+
184
+ # Native numeric types.
185
+ if isinstance(value, (int, float)):
186
+ f = float(value)
187
+ if not math.isfinite(f):
188
+ return str(value)
189
+ # Integer values sidestep rounding so period indices, year
190
+ # counts, tier numbers etc. are never perturbed.
191
+ if isinstance(value, int) or f.is_integer():
192
+ return str(value)
193
+ rounded = round_to_sigfigs(f, precision_digits)
194
+ return _format_float(rounded)
195
+
196
+ # String that might encode a float (common path for map values
197
+ # flattened via convert_map_to_table).
198
+ if isinstance(value, str):
199
+ s = value.strip()
200
+ if not s:
201
+ return value
202
+ try:
203
+ f = float(s)
204
+ except ValueError:
205
+ return value # entity name, method string, etc.
206
+ if not math.isfinite(f):
207
+ return value
208
+ if f.is_integer() and s.lstrip("+-").isdigit():
209
+ # Pure integer literal — keep original form (no ".0" drift).
210
+ return value
211
+ rounded = round_to_sigfigs(f, precision_digits)
212
+ return _format_float(rounded)
213
+
214
+ return str(value)
215
+
216
+
217
+ def _format_float(value: float) -> str:
218
+ """Render a rounded float compactly.
219
+
220
+ Python's ``repr(float)`` uses the shortest round-trippable form,
221
+ which for a value that has been truncated to N sig figs produces a
222
+ clean N-digit representation (no trailing garbage). We prefer
223
+ ``repr`` over ``str`` for Python 2/3 consistency — they are
224
+ identical in Python 3.
225
+ """
226
+ return repr(value)
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Near-duplicate detection
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ def find_near_duplicates(
235
+ values: Iterable[float],
236
+ rel_tol: float = 1e-6,
237
+ ) -> list[list[float]]:
238
+ """Cluster *values* by within-cluster relative spread ≤ *rel_tol*.
239
+
240
+ Algorithm:
241
+
242
+ 1. Drop NaN / non-finite entries; take absolute magnitude only for
243
+ the clustering distance (sign kept on each value for reporting).
244
+ 2. Sort by magnitude.
245
+ 3. Sweep: extend the current cluster as long as
246
+ ``(v - cluster_min) / max(|cluster_min|, |v|, 1e-300) <= rel_tol``.
247
+ 4. Drop singleton clusters; return clusters of size ≥ 2, sorted by
248
+ cluster size descending, then by representative magnitude.
249
+
250
+ The per-parameter scope means we look at e.g. all
251
+ ``p_entity_unitsize`` values across entities in one pool; VOM
252
+ values across units in a separate pool. Cross-parameter matching
253
+ (where symmetry actually bites) is left for a future, deeper tool.
254
+ """
255
+ if rel_tol < 0:
256
+ raise ValueError("rel_tol must be non-negative")
257
+ buckets: list[list[float]] = []
258
+ finite = [float(v) for v in values if math.isfinite(float(v))]
259
+ if not finite:
260
+ return buckets
261
+ # Sort so sign-flips never collapse into a single cluster. Sort by
262
+ # (sign, abs) — negatives together ascending by magnitude, then
263
+ # positives, and a cluster boundary is forced at every sign change.
264
+ def _sk(x: float) -> tuple[int, float]:
265
+ return (1 if x >= 0 else -1, abs(x))
266
+ finite.sort(key=_sk)
267
+ current: list[float] = [finite[0]]
268
+ cluster_min_abs = abs(finite[0])
269
+ cluster_sign = _sk(finite[0])[0]
270
+ for v in finite[1:]:
271
+ va = abs(v)
272
+ vs = _sk(v)[0]
273
+ denom = max(cluster_min_abs, va, 1e-300)
274
+ same_sign = vs == cluster_sign
275
+ if same_sign and (va - cluster_min_abs) / denom <= rel_tol:
276
+ current.append(v)
277
+ else:
278
+ if len(current) >= 2:
279
+ buckets.append(current)
280
+ current = [v]
281
+ cluster_min_abs = va
282
+ cluster_sign = vs
283
+ if len(current) >= 2:
284
+ buckets.append(current)
285
+ buckets.sort(key=lambda b: (-len(b), abs(b[0])))
286
+ return buckets
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Near-duplicate report
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def _iter_numeric_columns(path: Path) -> Iterator[tuple[str, list[tuple[str, float]]]]:
295
+ """Yield ``(column_name, [(entity_label, value), ...])`` per numeric
296
+ column in *path*.
297
+
298
+ Numeric detection: a column is numeric iff every non-empty row in
299
+ it parses as a finite float. Columns that look like period /
300
+ timestep / tier indices (pure integer literals throughout) are
301
+ skipped — symmetry is a numerical-coefficient phenomenon, and
302
+ structural indices would produce noisy "clusters" with zero
303
+ signal.
304
+ """
305
+ try:
306
+ with path.open(newline="") as fh:
307
+ reader = csv.reader(fh)
308
+ try:
309
+ header = next(reader)
310
+ except StopIteration:
311
+ return
312
+ rows = list(reader)
313
+ except OSError:
314
+ return
315
+ if not header or not rows:
316
+ return
317
+
318
+ n_cols = len(header)
319
+ # Per-column parse attempt.
320
+ parsed: list[list[Optional[float]]] = [[] for _ in range(n_cols)]
321
+ for row in rows:
322
+ for i in range(n_cols):
323
+ cell = row[i].strip() if i < len(row) else ""
324
+ if not cell:
325
+ parsed[i].append(None)
326
+ continue
327
+ try:
328
+ parsed[i].append(float(cell))
329
+ except ValueError:
330
+ parsed[i].append(None)
331
+
332
+ for i, col_values in enumerate(parsed):
333
+ non_null = [v for v in col_values if v is not None]
334
+ if len(non_null) < 2:
335
+ continue
336
+ # Column is numeric iff *every* non-empty cell parsed.
337
+ n_non_empty = sum(
338
+ 1 for row in rows
339
+ if i < len(row) and row[i].strip() != ""
340
+ )
341
+ if len(non_null) != n_non_empty:
342
+ continue
343
+ # Skip pure-integer columns (period ids, tier numbers, etc.).
344
+ if all(float(v).is_integer() for v in non_null):
345
+ continue
346
+ # Build entity label = non-numeric columns joined.
347
+ labelled: list[tuple[str, float]] = []
348
+ for r_idx, row in enumerate(rows):
349
+ if col_values[r_idx] is None:
350
+ continue
351
+ label_parts = []
352
+ for j in range(min(len(row), n_cols)):
353
+ if j == i:
354
+ continue
355
+ cell = row[j].strip()
356
+ try:
357
+ float(cell)
358
+ continue # skip other numeric columns from the label
359
+ except ValueError:
360
+ if cell:
361
+ label_parts.append(cell)
362
+ labelled.append((" | ".join(label_parts) or f"row{r_idx}", col_values[r_idx]))
363
+ yield header[i], labelled
364
+
365
+
366
+ def report_near_duplicates(
367
+ input_dir: Path,
368
+ top: int = 10,
369
+ rel_tol: float = 1e-6,
370
+ ) -> None:
371
+ """Scan every CSV in *input_dir* and print the top *top* clusters.
372
+
373
+ Output format (one block per cluster)::
374
+
375
+ near-dup [<size>×] <csv>:<column> value=<repr> spread=<Δ> rel=<r>
376
+ entities: <ent1>, <ent2>, <ent3>, ...
377
+
378
+ Never fails the run: any per-file error is caught and logged. A
379
+ final summary line reports the total number of clusters found.
380
+ """
381
+ input_dir = Path(input_dir)
382
+ if not input_dir.is_dir():
383
+ print(
384
+ f"[precision] report_near_duplicates: {input_dir} is not a "
385
+ f"directory; nothing to scan."
386
+ )
387
+ return
388
+
389
+ all_clusters: list[tuple[int, str, str, list[tuple[str, float]]]] = []
390
+ # (size, csv_file, column, [(entity, value), ...])
391
+
392
+ for csv_path in sorted(input_dir.glob("*.csv")):
393
+ try:
394
+ for column, labelled_values in _iter_numeric_columns(csv_path):
395
+ raw_values = [v for _, v in labelled_values]
396
+ clusters = find_near_duplicates(raw_values, rel_tol=rel_tol)
397
+ if not clusters:
398
+ continue
399
+ # Back-map each cluster value to its (label, value) tuple.
400
+ by_value: dict[float, list[str]] = {}
401
+ for label, v in labelled_values:
402
+ by_value.setdefault(v, []).append(label)
403
+ for cluster in clusters:
404
+ lv: list[tuple[str, float]] = []
405
+ used: dict[float, int] = {}
406
+ for v in cluster:
407
+ labels = by_value.get(v, [])
408
+ idx = used.get(v, 0)
409
+ label = labels[idx] if idx < len(labels) else f"value={v}"
410
+ used[v] = idx + 1
411
+ lv.append((label, v))
412
+ all_clusters.append(
413
+ (len(cluster), csv_path.name, column, lv)
414
+ )
415
+ except Exception as exc: # diagnostic only — never fail the run
416
+ print(
417
+ f"[precision] skipping {csv_path.name}: {exc}"
418
+ )
419
+ continue
420
+
421
+ # Sort: largest cluster first, then by CSV/column for stable output.
422
+ all_clusters.sort(key=lambda c: (-c[0], c[1], c[2]))
423
+ if not all_clusters:
424
+ print(
425
+ f"[precision] no near-duplicate clusters found in {input_dir} "
426
+ f"(rel_tol={rel_tol:g})."
427
+ )
428
+ return
429
+
430
+ shown = all_clusters[:top]
431
+ for size, csv_file, column, entries in shown:
432
+ values = [v for _, v in entries]
433
+ lo = min(values)
434
+ hi = max(values)
435
+ spread = hi - lo
436
+ mag = max(abs(lo), abs(hi), 1e-300)
437
+ rel = spread / mag
438
+ rep = entries[0][1]
439
+ sample_names = [label for label, _ in entries[:3]]
440
+ print(
441
+ f"near-dup [{size}x] {csv_file}:{column} "
442
+ f"value={rep!r} spread={spread:g} rel={rel:g}"
443
+ )
444
+ print(f" entities: {', '.join(sample_names)}")
445
+
446
+ total_values_in_clusters = sum(size for size, *_ in all_clusters)
447
+ print(
448
+ f"[precision] {len(all_clusters)} near-duplicate cluster(s) "
449
+ f"covering {total_values_in_clusters} value(s) "
450
+ f"(showing top {len(shown)}, rel_tol={rel_tol:g})."
451
+ )
File without changes
@@ -0,0 +1,128 @@
1
+ """Benders region decomposition wrapper.
2
+
3
+ Builds a self-contained ``input_region_<region>/`` directory for one
4
+ decomposition region's standalone solve.
5
+
6
+ Step 2.6 (this commit)
7
+ ----------------------
8
+
9
+ The cascade-input :class:`FlexDataProvider` is the single source of
10
+ truth: :func:`flextool.input_derivation.run` populates it with every
11
+ ``input/<name>`` frame, and the region filter
12
+ (:func:`flextool.decomposition.region_filter.build_region_provider`)
13
+ derives a region-scoped Provider from it entirely in-memory. The CLI
14
+ deliverable — the on-disk ``input_region_<region>/`` directory — is
15
+ produced by one call to ``region_provider.snapshot_processed_inputs``
16
+ on the filtered Provider; no monolithic ``input/`` is ever written to
17
+ disk by this driver.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from pathlib import Path
23
+
24
+ from flextool.engine_polars._flex_data_provider import FlexDataProvider
25
+ from flextool.engine_polars._solve_state import FlexToolConfigError
26
+
27
+
28
+ def write_input_for_region(
29
+ input_db_url: str,
30
+ scenario_name: str | None,
31
+ logger: logging.Logger,
32
+ region_group: str,
33
+ output_dir: Path,
34
+ work_folder: Path | None = None,
35
+ precision_digits: int = 0,
36
+ ) -> dict:
37
+ """Write a self-contained ``input_region_<region>/`` directory for one
38
+ decomposition region's standalone solve.
39
+
40
+ Step 2.6: the pipeline is fully in-memory.
41
+
42
+ 1. :func:`flextool.input_derivation.run` populates the cascade-input
43
+ :class:`FlexDataProvider`.
44
+ 2. :func:`region_filter.build_region_provider` derives a region-
45
+ scoped Provider with rows filtered to the region's entities and
46
+ virtual half-flow rows injected for cross-region pipelines.
47
+ 3. ``region_provider.snapshot_processed_inputs(output_dir)``
48
+ materialises the CLI deliverable.
49
+ 4. The coupling manifest is stored in the cascade Provider as
50
+ ``solve_data/region_coupling`` and snapshotted to
51
+ ``work_folder/solve_data/region_coupling.csv``.
52
+
53
+ No bridge: the cascade-input Provider is NOT snapshotted to disk.
54
+
55
+ Parameters
56
+ ----------
57
+ input_db_url, scenario_name, logger, work_folder, precision_digits
58
+ Standard cascade entry-point inputs.
59
+ region_group
60
+ The group name whose ``decomposition_method`` is
61
+ ``benders_regional``. Must exist in the database.
62
+ output_dir
63
+ Destination directory, typically
64
+ ``work_folder / "input_region_<region>"``. Created if missing.
65
+
66
+ Returns a dict: ``{"region": ..., "half_flows": [...], "kept_nodes": ...,
67
+ "kept_units": ..., "kept_connections": ...}``.
68
+ """
69
+ from flextool.decomposition import region_filter
70
+ from flextool.input_derivation import run as _input_derivation_run
71
+
72
+ wf = work_folder if work_folder is not None else Path.cwd()
73
+
74
+ # 1. Populate the cascade-input Provider in-memory. No disk staging.
75
+ provider = FlexDataProvider()
76
+ _input_derivation_run(
77
+ input_db_url,
78
+ provider,
79
+ logger,
80
+ scenario_name=scenario_name,
81
+ work_folder=work_folder,
82
+ precision_digits=precision_digits,
83
+ )
84
+
85
+ # 2. Validate region membership BEFORE filtering — surface a clear
86
+ # error with the available regions if the caller picked a wrong one.
87
+ all_regions = region_filter.discover_decomposition_regions_from_db(input_db_url)
88
+ if region_group not in all_regions:
89
+ raise FlexToolConfigError(
90
+ f"Region '{region_group}' is not declared with "
91
+ f"decomposition_method='benders_regional' in the database. "
92
+ f"Available regions: {sorted(all_regions) or '(none)'}"
93
+ )
94
+
95
+ # 3. Derive the region-scoped Provider in-memory.
96
+ region_provider, result = region_filter.build_region_provider(
97
+ provider,
98
+ region=region_group,
99
+ all_regions=all_regions,
100
+ )
101
+
102
+ # 4. Snapshot the filtered Provider to the CLI-facing output dir.
103
+ # ``snapshot_processed_inputs`` writes every ``input/<name>`` frame
104
+ # under ``<output_dir>/input/<name>.csv``. The region directory's
105
+ # contract is the legacy "input/" subfolder, so we snapshot under
106
+ # output_dir directly (the input/ prefix is already part of the key).
107
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
108
+ # Strip the leading ``input/`` from every key so the output lands as
109
+ # ``output_dir/<file>.csv`` (matching the historical layout).
110
+ flat_provider = FlexDataProvider()
111
+ for key, frame in region_provider.items():
112
+ if key.startswith("input/"):
113
+ flat_provider.put(key.split("/", 1)[1], frame)
114
+ else:
115
+ # Non-input frames stay under their qualified parent.
116
+ flat_provider.put(key, frame)
117
+ flat_provider.snapshot_processed_inputs(output_dir)
118
+
119
+ # 5. Write the coupling manifest. Routes through the Provider for
120
+ # symmetry with the rest of the pipeline; the snapshot lands at
121
+ # ``work_folder/solve_data/region_coupling.csv``.
122
+ manifest_provider = FlexDataProvider()
123
+ region_filter.write_region_coupling_manifest_to_provider(
124
+ manifest_provider,
125
+ results=[result],
126
+ )
127
+ manifest_provider.snapshot_processed_inputs(wf)
128
+ return result