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,478 @@
1
+ """:class:`FlexDataProvider` — single carrier for preprocessing artefacts.
2
+
3
+ The Provider is the single abstraction by which loaders, writers,
4
+ post-processing readers, and orchestration exchange preprocessing
5
+ artefacts. Dict-backed: ``put(name, frame)`` stores a frame;
6
+ ``get(name)`` / ``has(name)`` look it up by exact-match key.
7
+
8
+ Name conventions
9
+ ----------------
10
+
11
+ * The Provider keys frames by their **parent-qualified key**
12
+ (``"<parent>/<stem>"``). Producers emit qualified keys
13
+ (``"solve_data/p_flow_max"``, ``"input/timeline"``, ``"derived/..."``,
14
+ ``"handoff/..."``) and consumers query the same form — typically via
15
+ the typed constants in ``_provider_keys.py`` (``K.SOLVE_DATA_...``)
16
+ or via ``_emit_provider_io._provider_key(path)``.
17
+ * The same basename can appear under multiple parents (the canonical
18
+ example is ``timeline.csv`` which exists in both ``input/`` and
19
+ ``solve_data/``); each is stored under its qualified key and looked
20
+ up by the same.
21
+ * ``get`` is an exact-match lookup against the stored key (after
22
+ ``.csv``-suffix stripping). A typo or unqualified key returns
23
+ ``None`` — the Phase 0a-era bare↔qualified fallback was dropped in
24
+ Phase 4.2-2 to give one canonical key form and one lookup path.
25
+ * ``has(name)`` returns ``True`` iff ``get(name)`` would return a
26
+ non-``None`` frame.
27
+
28
+ Suffix handling
29
+ ---------------
30
+
31
+ The Provider stores keys *without* the ``.csv`` suffix. ``get`` /
32
+ ``has`` / ``put`` accept either form — a trailing ``.csv`` is stripped
33
+ before keying.
34
+
35
+ Source tagging
36
+ --------------
37
+
38
+ ``put(key, frame, *, source=None)`` accepts an optional free-form
39
+ ``source`` string that is retained alongside the frame and surfaced by
40
+ :meth:`FlexDataProvider.get_source`. The natural cascade leaves
41
+ ``source`` unset; external-override writes (see
42
+ :func:`flextool.engine_polars._provider_translators.translate_overrides_to_provider`)
43
+ tag their entries with ``"external_override"`` so downstream audit
44
+ tooling can distinguish overridden keys from naturally-produced ones.
45
+ Eviction by :meth:`release_unused` drops the source entry alongside the
46
+ frame.
47
+ """
48
+ from __future__ import annotations
49
+
50
+ import os
51
+ from pathlib import Path
52
+ from typing import Any, Iterable, Iterator
53
+
54
+ import polars as pl
55
+
56
+
57
+ class EvictedFrameError(KeyError):
58
+ """Raised by :meth:`FlexDataProvider.get` when *name* has been
59
+ evicted by :meth:`FlexDataProvider.release_unused`.
60
+
61
+ Catching this *silently* (e.g. ``except (KeyError, EvictedFrameError)``)
62
+ is almost certainly a bug — eviction is sound by construction
63
+ (driven by the static lifetime map computed from declared ``READS``),
64
+ so a hit here means either the ``READS`` declaration is incomplete
65
+ for the active handler or a handler is reading a frame outside its
66
+ declared scope. Either way, surface the failure.
67
+
68
+ The exception carries the offending frame name and the
69
+ ``last_needed`` item-group token so the caller can pinpoint the
70
+ drift.
71
+ """
72
+
73
+ def __init__(self, name: str, last_needed: Any) -> None:
74
+ super().__init__(
75
+ f"FlexDataProvider: frame {name!r} has been evicted "
76
+ f"(last_needed={last_needed!r}). This typically means "
77
+ f"the active handler's READS declaration is incomplete "
78
+ f"or a handler is reading a frame outside its declared "
79
+ f"scope. See specs/step_3_handoff.md Track B for details."
80
+ )
81
+ self.frame_name = name
82
+ self.last_needed = last_needed
83
+
84
+
85
+ def _strip_csv(name: str) -> str:
86
+ """Drop a trailing ``.csv`` suffix if present."""
87
+ if name.endswith(".csv"):
88
+ return name[: -len(".csv")]
89
+ return name
90
+
91
+
92
+ class FlexDataProvider:
93
+ """Dict-backed carrier of preprocessing artefacts.
94
+
95
+ Interface contract — see the handoff (Step 1 section). This is the
96
+ scaffolding class for the migration; subsequent steps will replace
97
+ its consumers and eventually evolve it into the single data pathway
98
+ of the cascade.
99
+
100
+ The class is intentionally minimal and "dumb" — no caching beyond
101
+ the dict, no lazy loading, no eviction, no indexing. Optimisations
102
+ come later (Step 3) once measurements show where the peaks are.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ *,
108
+ rss_budget_mb: float | None = None,
109
+ retain_all: bool = False,
110
+ ) -> None:
111
+ self._frames: dict[str, pl.DataFrame] = {}
112
+ # Phase 6a — opt-in source-tagging. ``put(key, frame, source=...)``
113
+ # records the tag here; ``get_source(key)`` looks it up. Entries
114
+ # with ``source=None`` (the default) are *not* added to this map
115
+ # so the natural cascade pays zero memory overhead. Eviction
116
+ # clears the matching entry alongside the frame.
117
+ self._sources: dict[str, str] = {}
118
+ # Phase 4 — axis enum vocabulary + contract. Populated by
119
+ # ``input_derivation.run`` against the active SpineDBBackend, or
120
+ # lazy-built by ``load_flextool`` against the workdir sqlite when
121
+ # the cascade entry point bypasses input_derivation. ``None``
122
+ # means activation is off (legacy behaviour).
123
+ self.axis_enums: "dict[str, pl.Enum] | None" = None
124
+ self.contract: "object | None" = None
125
+
126
+ # Track B — READS-driven lifetime + threshold-gated eviction.
127
+ #
128
+ # ``_reads[handler_id]`` is the list of frame names the handler
129
+ # declares it consumes. ``precompute_lifetimes`` walks the
130
+ # handler-to-item-group map and the READS declarations to compute
131
+ # ``_last_needed[name] = max item-group that still needs it``.
132
+ # ``release_unused(after=group)`` drops every frame whose
133
+ # ``_last_needed`` is past ``group`` in iteration order. After
134
+ # eviction a subsequent ``get(name)`` raises
135
+ # :class:`EvictedFrameError` instead of silently re-fetching —
136
+ # we deliberately reject re-derivation (Step 3 design call).
137
+ #
138
+ # Eviction is threshold-gated by ``rss_budget_mb``: when the
139
+ # currently-cached frame footprint stays below the budget, the
140
+ # gate is closed and ``release_unused`` is a no-op. Small
141
+ # problems therefore pay zero eviction overhead; large problems
142
+ # hit the memory bound. ``retain_all`` (set by ``--csv-dump``)
143
+ # disables eviction unconditionally so debug-snapshot runs see
144
+ # every frame intact.
145
+ #
146
+ # ``rss_budget_mb`` resolution order:
147
+ # 1. constructor arg, if non-None.
148
+ # 2. ``FLEXTOOL_RSS_BUDGET_MB`` env var, if set.
149
+ # 3. ``None`` (eviction never fires; threshold gate always closed).
150
+ env_budget = os.environ.get("FLEXTOOL_RSS_BUDGET_MB")
151
+ self.rss_budget_mb: float | None = (
152
+ float(rss_budget_mb) if rss_budget_mb is not None
153
+ else (float(env_budget) if env_budget else None)
154
+ )
155
+ self.retain_all: bool = bool(retain_all)
156
+
157
+ self._reads: dict[str, list[str]] = {}
158
+ # The handler-to-item-group mapping; populated by
159
+ # ``register_handler``. Each handler is invoked at one or more
160
+ # item-groups (often exactly one).
161
+ self._handler_groups: dict[str, list[Any]] = {}
162
+ # The item-group iteration order; populated by
163
+ # ``precompute_lifetimes``. Maps each group token to its
164
+ # position index (0-based).
165
+ self._group_order: dict[Any, int] | None = None
166
+ # The lifetime map: frame name → (item_group_token, index).
167
+ # ``_last_needed`` is only populated after
168
+ # ``precompute_lifetimes`` runs; before that, all frames are
169
+ # treated as live indefinitely.
170
+ self._last_needed: dict[str, tuple[Any, int]] = {}
171
+ # Names that have been evicted by ``release_unused``. Reads on
172
+ # these raise :class:`EvictedFrameError`.
173
+ self._evicted: dict[str, Any] = {}
174
+
175
+ # ------------------------------------------------------------------
176
+ # Mutation
177
+ # ------------------------------------------------------------------
178
+
179
+ def put(
180
+ self,
181
+ name: str,
182
+ frame: pl.DataFrame,
183
+ *,
184
+ source: str | None = None,
185
+ ) -> None:
186
+ """Store *frame* under *name*.
187
+
188
+ *name* may be bare (``"p_flow_max"``), qualified
189
+ (``"solve_data/p_flow_max"``), and may include the ``.csv``
190
+ suffix in either form — the suffix is stripped before keying.
191
+
192
+ Parameters
193
+ ----------
194
+ source:
195
+ Optional free-form tag retained alongside the frame and
196
+ surfaced via :meth:`get_source`. ``None`` (the default)
197
+ leaves no source entry — the natural-cascade case. Phase 6a
198
+ of ``specs/provider_consolidation.md`` adds this so the
199
+ external-override layer can mark its writes with a stable
200
+ ``"external_override"`` tag for downstream audit.
201
+ """
202
+ key = _strip_csv(name)
203
+ self._frames[key] = frame
204
+ if source is None:
205
+ # Overwriting a previously-tagged entry with an untagged put
206
+ # must clear the stale tag — the new frame is the new truth.
207
+ self._sources.pop(key, None)
208
+ else:
209
+ self._sources[key] = source
210
+
211
+ # ------------------------------------------------------------------
212
+ # Lookup
213
+ # ------------------------------------------------------------------
214
+
215
+ def get(self, name: str) -> pl.DataFrame | None:
216
+ """Return the frame for *name*, or ``None`` if unavailable.
217
+
218
+ Lookup logic:
219
+
220
+ 1. If *name* is in the evicted set (Track B), raise
221
+ :class:`EvictedFrameError`. We deliberately do NOT silently
222
+ re-fetch — eviction is sound by static analysis when
223
+ ``READS`` declarations are complete; a hit here points at a
224
+ READS-declaration drift or a handler reading outside its
225
+ declared scope.
226
+ 2. Exact match on the supplied (suffix-stripped) key.
227
+
228
+ Returns ``None`` if no candidate matches. The Phase 0a-era
229
+ bare↔qualified fallback was dropped in Phase 4.2-2 — callers
230
+ must pass the canonical parent-qualified key (typically a
231
+ ``K.*`` constant from ``_provider_keys.py`` or the result of
232
+ ``_emit_provider_io._provider_key(path)``).
233
+ """
234
+ key = _strip_csv(name)
235
+ if key in self._evicted:
236
+ raise EvictedFrameError(key, self._evicted[key])
237
+ return self._frames.get(key)
238
+
239
+ def has(self, name: str) -> bool:
240
+ """Return ``True`` iff :meth:`get` would return a non-``None``
241
+ frame for *name*."""
242
+ return self.get(name) is not None
243
+
244
+ def get_source(self, name: str) -> str | None:
245
+ """Return the source tag recorded for *name*, or ``None``.
246
+
247
+ Phase 6a — opt-in source-tagging. ``None`` is returned both for
248
+ keys that were ``put`` without a ``source`` argument and for
249
+ keys that have never been ``put`` (or have been evicted). This
250
+ accessor never raises.
251
+ """
252
+ return self._sources.get(_strip_csv(name))
253
+
254
+ # ------------------------------------------------------------------
255
+ # Iteration helpers (handy for tests + future snapshot impls).
256
+ # ------------------------------------------------------------------
257
+
258
+ def keys(self) -> list[str]:
259
+ return list(self._frames.keys())
260
+
261
+ def __contains__(self, name: str) -> bool: # pragma: no cover - trivial
262
+ return self.has(name)
263
+
264
+ def __iter__(self) -> Iterator[str]: # pragma: no cover - trivial
265
+ return iter(self._frames)
266
+
267
+ def items(self) -> Iterable[tuple[str, pl.DataFrame]]: # pragma: no cover
268
+ return self._frames.items()
269
+
270
+ # ------------------------------------------------------------------
271
+ # Track B — READS registry + lifetime + eviction.
272
+ # ------------------------------------------------------------------
273
+
274
+ def register_handler(
275
+ self,
276
+ handler_id: str,
277
+ *,
278
+ reads: Iterable[str],
279
+ groups: Iterable[Any] | None = None,
280
+ ) -> None:
281
+ """Declare a handler's frame reads and (optionally) the
282
+ item-groups it fires in.
283
+
284
+ *handler_id* is a stable identifier — typically the fully-
285
+ qualified function name (e.g.
286
+ ``"flextool.engine_polars._emit_arc_unions.write_process_arc_unions"``).
287
+ *reads* is the list of (suffix-stripped) frame names the handler
288
+ consumes via ``get``/``has``. *groups* is the item-group tokens
289
+ at which the handler fires; ``None`` means "fires once at the
290
+ sentinel single-pass group token".
291
+
292
+ Calling twice with the same *handler_id* replaces the previous
293
+ declaration. The Provider does not enforce that registered
294
+ handlers are the only ones reading frames — that's the job of
295
+ the CI tracing mode (Phase B.3).
296
+ """
297
+ key = str(handler_id)
298
+ self._reads[key] = [_strip_csv(r) for r in reads]
299
+ self._handler_groups[key] = list(groups) if groups is not None else []
300
+
301
+ def precompute_lifetimes(self, item_groups: Iterable[Any]) -> None:
302
+ """Compute ``_last_needed[name] = (group, group_idx)`` from the
303
+ currently-registered handler ``READS`` and the iteration order
304
+ of *item_groups*.
305
+
306
+ *item_groups* defines the order in which item-groups will be
307
+ visited by the build loop. For each registered handler, its
308
+ ``groups`` are intersected with this order; the maximum group
309
+ index across all handlers reading *name* becomes the frame's
310
+ ``last_needed``.
311
+
312
+ Handlers that registered with ``groups=None`` (single-pass) are
313
+ assumed to read at every group, so they pin their READS to the
314
+ *last* group in iteration order — effectively "retain for the
315
+ whole build." This is the safe default; refine the registration
316
+ once a handler's actual group span is known.
317
+
318
+ Must be called exactly once per build loop. After this, the
319
+ Provider knows which frames can be evicted *if* the memory
320
+ threshold triggers. Eviction itself is the job of
321
+ :meth:`release_unused`.
322
+
323
+ Re-running ``precompute_lifetimes`` (e.g. between sub-solves)
324
+ re-derives the map from scratch and clears the evicted set —
325
+ eviction state is per-build-loop, not persistent.
326
+ """
327
+ order = list(item_groups)
328
+ if not order:
329
+ # No groups → nothing to evict.
330
+ self._group_order = {}
331
+ self._last_needed = {}
332
+ self._evicted = {}
333
+ return
334
+ group_idx: dict[Any, int] = {g: i for i, g in enumerate(order)}
335
+ last_group_idx = len(order) - 1
336
+ last_group_token = order[-1]
337
+
338
+ last_needed: dict[str, tuple[Any, int]] = {}
339
+ for handler_id, reads in self._reads.items():
340
+ handler_groups = self._handler_groups.get(handler_id, [])
341
+ if not handler_groups:
342
+ # Single-pass / unknown: pin to last group.
343
+ handler_last_idx = last_group_idx
344
+ handler_last_token = last_group_token
345
+ else:
346
+ # Find the latest of the handler's groups in iteration
347
+ # order. Unknown groups are treated as 'past the end',
348
+ # i.e. retain to last (defensive).
349
+ idxs = [
350
+ group_idx.get(g, last_group_idx) for g in handler_groups
351
+ ]
352
+ handler_last_idx = max(idxs)
353
+ handler_last_token = order[handler_last_idx]
354
+ for name in reads:
355
+ prev = last_needed.get(name)
356
+ if prev is None or prev[1] < handler_last_idx:
357
+ last_needed[name] = (handler_last_token, handler_last_idx)
358
+ self._group_order = group_idx
359
+ self._last_needed = last_needed
360
+ self._evicted = {}
361
+
362
+ def release_unused(self, *, after: Any) -> list[str]:
363
+ """Drop every frame whose ``_last_needed`` group index is ``≤``
364
+ the index of *after* in the precomputed iteration order.
365
+
366
+ Returns the list of evicted frame names (callers can log /
367
+ assert on it). Frames that are still needed downstream are
368
+ untouched.
369
+
370
+ No-op when:
371
+
372
+ * ``retain_all`` is True (e.g. ``--csv-dump`` mode).
373
+ * ``precompute_lifetimes`` hasn't been called.
374
+ * The current footprint (``rss_estimate_mb``) is below the
375
+ ``rss_budget_mb`` threshold — small problems pay no eviction
376
+ overhead.
377
+
378
+ Tests can force eviction by setting ``rss_budget_mb=0`` or by
379
+ constructing the Provider with ``rss_budget_mb=0``.
380
+ """
381
+ if self.retain_all or self._group_order is None:
382
+ return []
383
+ if self.rss_budget_mb is not None:
384
+ current_mb = self.rss_estimate_mb()
385
+ if current_mb <= self.rss_budget_mb:
386
+ return []
387
+ try:
388
+ after_idx = self._group_order[after]
389
+ except KeyError:
390
+ # Unknown group token → conservative: don't evict.
391
+ return []
392
+ evicted: list[str] = []
393
+ for name in list(self._frames.keys()):
394
+ ln = self._last_needed.get(name)
395
+ if ln is None:
396
+ continue # frame not declared in any READS — keep
397
+ _, idx = ln
398
+ if idx <= after_idx:
399
+ del self._frames[name]
400
+ # Phase 6a — the source tag is per-frame metadata, so it
401
+ # must vacate when the frame does.
402
+ self._sources.pop(name, None)
403
+ self._evicted[name] = ln[0]
404
+ evicted.append(name)
405
+ return evicted
406
+
407
+ def rss_estimate_mb(self) -> float:
408
+ """Sum of ``frame.estimated_size()`` across the live cache, in
409
+ megabytes. Used as the threshold gate for
410
+ :meth:`release_unused`.
411
+
412
+ ``polars`` tracks frame sizes cheaply (no full walk required).
413
+ Empty / not-yet-materialised frames contribute 0.
414
+ """
415
+ total = 0.0
416
+ for frame in self._frames.values():
417
+ try:
418
+ total += float(frame.estimated_size())
419
+ except Exception: # pragma: no cover — defensive
420
+ # If polars ever changes the API, fall back to 0 rather
421
+ # than crash the budget check.
422
+ pass
423
+ return total / (1024.0 * 1024.0)
424
+
425
+ def is_evicted(self, name: str) -> bool:
426
+ """Return True iff *name* has been evicted by
427
+ :meth:`release_unused`.
428
+
429
+ Test helper; production callers use :meth:`get` and let
430
+ :class:`EvictedFrameError` surface naturally.
431
+ """
432
+ return _strip_csv(name) in self._evicted
433
+
434
+ def reset_lifetimes(self) -> None:
435
+ """Clear the registered READS, item-group order, lifetime map,
436
+ and evicted-frame markers.
437
+
438
+ Use this when transitioning between LP-build phases (e.g.
439
+ between sub-solves in a cascade) so each phase computes its own
440
+ lifetime map. Does NOT touch the actual frame cache.
441
+ """
442
+ self._reads = {}
443
+ self._handler_groups = {}
444
+ self._group_order = None
445
+ self._last_needed = {}
446
+ self._evicted = {}
447
+
448
+ # ------------------------------------------------------------------
449
+ # Snapshot — one-way disk dumps for ``--csv-dump`` debug runs.
450
+ # ------------------------------------------------------------------
451
+
452
+ def snapshot_raw_inputs(self, work_folder: Path) -> None:
453
+ """Write raw input frames to *work_folder*.
454
+
455
+ Currently a no-op — the Provider populates with *derived* /
456
+ processed frames during the cascade; "raw inputs" don't have a
457
+ well-defined separate carrier in the current pipeline. Left as
458
+ a deliberate stub so the contract surface stays stable; revisit
459
+ if a real raw-input snapshot becomes useful.
460
+ """
461
+ pass
462
+
463
+ def snapshot_processed_inputs(self, work_folder: Path) -> None:
464
+ """Write every stored frame to ``work_folder/{name}.csv``.
465
+
466
+ Bare-keyed frames go directly under *work_folder*; parent-qualified
467
+ keys (``"solve_data/foo"``) materialise into subdirectories
468
+ (``work_folder/solve_data/foo.csv``).
469
+ """
470
+ work_folder = Path(work_folder)
471
+ work_folder.mkdir(parents=True, exist_ok=True)
472
+ for name, frame in self._frames.items():
473
+ target = work_folder / f"{name}.csv"
474
+ target.parent.mkdir(parents=True, exist_ok=True)
475
+ frame.write_csv(target)
476
+
477
+
478
+ __all__ = ["FlexDataProvider"]