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,1479 @@
1
+ """BlockLayout — multi-resolution timestep grouping (Δ.2).
2
+
3
+ Native ``engine_polars`` port of
4
+ ``flextool/engine_polars/_blocks.py`` (1368 LOC). Produces, in one
5
+ pass, every per-solve block-related frame consumed downstream:
6
+
7
+ * ``entity_block`` — ``(entity, block)``
8
+ * ``process_side_block`` — ``(process, side, block)``
9
+ * ``process_block`` — ``(process, block)`` — process-unified
10
+ * ``block_step_duration`` — ``(block, period, step, step_duration)``
11
+ * ``overlap_set`` — ``(period, block_coarse, step_coarse,
12
+ block_fine, step_fine, fraction)``
13
+ * ``block_step_previous`` — per-block predecessor 7-tuples
14
+ * ``block_period_time_first/_last`` — per-block period boundaries
15
+
16
+ The algorithm is a 1:1 port of the reference (multi-resolution
17
+ alignment is genuinely intricate; the port preserves every quirk of
18
+ the original — including the two-block-deep limit and the
19
+ aligned-subsets-only assumption). Internal bookkeeping uses Python
20
+ dicts/tuples (the input data is small — O(entities + arcs)) and the
21
+ output surface is polars DataFrames so consumers get a uniform shape.
22
+
23
+ Key decisions:
24
+
25
+ * **Lazy polars at the rim**: the public ``BlockLayout`` exposes
26
+ ``LazyFrame``-friendly polars frames; consumers ``.collect()`` once
27
+ at LP-build time.
28
+ * **Cache per-(b_coarse, b_fine) overlap rows** as the schematic
29
+ recommends — built once during ``derive_overlap_set`` and shared
30
+ across every arc that references the pair.
31
+ * **Two-block-deep limit accepted** — the reference assumes one
32
+ resolution-group block + the natural fine block; the port preserves
33
+ this. Lifting it is a model-design question.
34
+ * **No defensive gating** — degenerate single-block fixtures produce
35
+ identity-only overlap rows (matching pre-v51 behaviour); fixtures
36
+ with no resolution groups bypass the multi-block branches entirely.
37
+ * **Fail loud** — ambiguous group memberships and non-aligned subsets
38
+ raise ``FlexToolConfigError`` / ``NotImplementedError`` (matching
39
+ the reference).
40
+
41
+ Reference: ``flextool/engine_polars/_blocks.py`` (read-only).
42
+ """
43
+ from __future__ import annotations
44
+
45
+ from collections import defaultdict
46
+ from dataclasses import dataclass, field
47
+ from typing import TYPE_CHECKING, Iterable
48
+
49
+ import polars as pl
50
+
51
+ from flextool.engine_polars._axis_enums import (
52
+ rename_to_axis,
53
+ schema_dtype,
54
+ )
55
+ from flextool.engine_polars._solve_state import FlexToolConfigError
56
+
57
+
58
+ # Substrate handle for the cascade-wide axis enum vocabulary.
59
+ # Bare ``None`` here; ``cast_dim`` / ``schema_dtype`` in
60
+ # ``_axis_enums`` fall back to ``_LIVE_AXIS_ENUMS_CTX`` (the live
61
+ # ContextVar) when this is ``None``, so substrate sites pick up
62
+ # activation set by ``load_flextool`` automatically.
63
+ _enums: "dict | None" = None
64
+
65
+ if TYPE_CHECKING: # pragma: no cover — import cycle guard only
66
+ from flextool.engine_polars._solve_config import SolveConfig
67
+ from flextool.engine_polars._timeline import TimelineConfig
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Public constants & schema
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ DEFAULT_BLOCK = "default"
76
+
77
+
78
+ # Eight per-solve block CSV stems written by
79
+ # ``flextool.engine_polars._blocks.write_block_data`` and consumed by
80
+ # :meth:`BlockLayout.load_from_solve_data`. Defined as a module
81
+ # constant so the off-cascade workdir bridge can seed only these files
82
+ # into the ephemeral Provider (without picking up unrelated workdir
83
+ # artefacts like ``solve_progress.csv`` whose ragged shape would fail
84
+ # the strict CSV reader).
85
+ _BLOCK_CSV_STEMS: tuple[str, ...] = (
86
+ "entity_block",
87
+ "process_side_block",
88
+ "process_block",
89
+ "block_step_duration",
90
+ "overlap_set",
91
+ "block_step_previous",
92
+ "block_period_time_first",
93
+ "block_period_time_last",
94
+ )
95
+
96
+
97
+ # Direct (1var) and indirect (nvar) ct_method classification — matches
98
+ # ``set method_direct`` / ``set method_indirect`` in flextool_base.dat.
99
+ # Classification is at the ct_method level: unknown values default to
100
+ # direct (the more conservative choice for block assignment).
101
+ _CT_METHOD_DIRECT: frozenset[str] = frozenset({
102
+ "constant_efficiency",
103
+ "min_load_efficiency",
104
+ "no_losses_no_variable_cost",
105
+ "variable_cost_only",
106
+ "unidirectional",
107
+ })
108
+ _CT_METHOD_INDIRECT: frozenset[str] = frozenset({
109
+ "regular",
110
+ "exact",
111
+ })
112
+
113
+
114
+ def _is_direct(ct_method: str) -> bool:
115
+ """Return ``True`` for 1var-per-process (direct) methods."""
116
+ return (
117
+ ct_method in _CT_METHOD_DIRECT
118
+ or ct_method not in _CT_METHOD_INDIRECT
119
+ )
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Validation
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def validate_group_membership(
128
+ group_unit: Iterable[tuple[str, str]],
129
+ group_connection: Iterable[tuple[str, str]],
130
+ group_node: Iterable[tuple[str, str]],
131
+ resolution_groups: dict[str, float],
132
+ decomposition_groups: dict[str, str],
133
+ reserve_upDown_group: Iterable[tuple[str, str, str]] | None = None,
134
+ process_reserve_upDown_node: Iterable[tuple[str, str, str, str]] | None = None,
135
+ ) -> None:
136
+ """Reject configurations with ambiguous block / region membership.
137
+
138
+ Mirrors ``flextool/engine_polars/_blocks.validate_group_membership``
139
+ 1:1. Each entity may belong to at most one resolution-group and
140
+ at most one decomposition-group; reserve participants must NOT
141
+ sit in any resolution-group (V1 reserve-block compatibility rule).
142
+
143
+ Raises:
144
+ FlexToolConfigError: on any of the rule violations.
145
+ """
146
+ res_set = set(resolution_groups.keys())
147
+ decomp_set = {g for g, m in decomposition_groups.items() if m and m != "none"}
148
+
149
+ entity_res: dict[str, list[str]] = defaultdict(list)
150
+ entity_decomp: dict[str, list[str]] = defaultdict(list)
151
+
152
+ for members in (group_unit, group_connection, group_node):
153
+ for g, e in members:
154
+ if g in res_set:
155
+ entity_res[e].append(g)
156
+ if g in decomp_set:
157
+ entity_decomp[e].append(g)
158
+
159
+ errors: list[str] = []
160
+ for e, gs in entity_res.items():
161
+ if len(gs) > 1:
162
+ errors.append(
163
+ f"entity '{e}' is a member of multiple resolution-groups "
164
+ f"({sorted(set(gs))}); each entity may belong to at most "
165
+ f"one group with new_stepduration set."
166
+ )
167
+ for e, gs in entity_decomp.items():
168
+ if len(gs) > 1:
169
+ errors.append(
170
+ f"entity '{e}' is a member of multiple decomposition-groups "
171
+ f"({sorted(set(gs))}); each entity may belong to at most "
172
+ f"one group with decomposition_method != 'none'."
173
+ )
174
+
175
+ reserve_groups: set[str] = set()
176
+ if reserve_upDown_group is not None:
177
+ for row in reserve_upDown_group:
178
+ if len(row) >= 3:
179
+ reserve_groups.add(row[2])
180
+ reserve_entities: set[str] = set()
181
+ for g, n in group_node:
182
+ if g in reserve_groups:
183
+ reserve_entities.add(n)
184
+ if process_reserve_upDown_node is not None:
185
+ for row in process_reserve_upDown_node:
186
+ if len(row) >= 4:
187
+ reserve_entities.add(row[0]) # process
188
+ reserve_entities.add(row[3]) # node
189
+
190
+ for e in sorted(reserve_entities):
191
+ gs = entity_res.get(e, [])
192
+ if gs:
193
+ errors.append(
194
+ f"entity '{e}' participates in reserves but is a member of "
195
+ f"resolution-group(s) {sorted(set(gs))}; V1 restricts "
196
+ f"reserves to the default (finest) block — move '{e}' out "
197
+ f"of the resolution-group or drop the reserve participation."
198
+ )
199
+
200
+ if errors:
201
+ raise FlexToolConfigError("; ".join(errors))
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Block derivation primitives
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ def _solve_step_duration(
210
+ solve: str,
211
+ solve_config: "SolveConfig | None",
212
+ timeline_config: "TimelineConfig | None",
213
+ ) -> float:
214
+ """Return the default-block step duration for *solve* in hours.
215
+
216
+ Priority (matches ``blocks._solve_step_duration``):
217
+
218
+ 1. ``timeline.new_step_durations[solve]`` (v50 parameter).
219
+ 2. The smallest step duration that actually appears in any of the
220
+ solve's timelines.
221
+
222
+ Falls back to ``1.0`` when neither source is available (unit-test
223
+ fixture path).
224
+ """
225
+ if timeline_config is not None:
226
+ raw = timeline_config.new_step_durations.get(solve)
227
+ if raw is not None:
228
+ try:
229
+ return float(raw)
230
+ except (TypeError, ValueError):
231
+ pass
232
+
233
+ if timeline_config is not None and solve_config is not None:
234
+ timesets_used = solve_config.timesets_used_by_solves.get(solve, [])
235
+ durations: list[float] = []
236
+ for _period, ts in timesets_used:
237
+ tl = timeline_config.timesets__timeline.get(ts)
238
+ if not tl:
239
+ continue
240
+ for _step, dur in timeline_config.timelines.get(tl, []):
241
+ try:
242
+ durations.append(float(dur))
243
+ except (TypeError, ValueError):
244
+ continue
245
+ if durations:
246
+ return min(durations)
247
+ return 1.0
248
+
249
+
250
+ def _assign_entity_to_group(
251
+ entity: str,
252
+ memberships: Iterable[tuple[str, str]],
253
+ resolution_groups: dict[str, float],
254
+ ) -> str | None:
255
+ """Return the resolution-group *entity* belongs to, or ``None``."""
256
+ for g, e in memberships:
257
+ if e == entity and g in resolution_groups:
258
+ return g
259
+ return None
260
+
261
+
262
+ def _aggregate_timeline(
263
+ rows: list[tuple[str, float]], coarse_duration: float,
264
+ ) -> list[tuple[str, float]]:
265
+ """Aggregate a fine-resolution timeline to *coarse_duration* hours.
266
+
267
+ Mirrors ``blocks._aggregate_timeline`` 1:1. Aligned-subsets only:
268
+ raises ``NotImplementedError`` when fine rows can't pack cleanly
269
+ into coarse rows.
270
+ """
271
+ out: list[tuple[str, float]] = []
272
+ i = 0
273
+ n = len(rows)
274
+ eps = 1e-9
275
+ while i < n:
276
+ acc = 0.0
277
+ first_label = rows[i][0]
278
+ j = i
279
+ while j < n and acc + float(rows[j][1]) <= coarse_duration + eps:
280
+ acc += float(rows[j][1])
281
+ j += 1
282
+ if abs(acc - coarse_duration) <= eps:
283
+ break
284
+ if abs(acc - coarse_duration) > eps:
285
+ raise NotImplementedError(
286
+ f"Non-aligned subset: fine rows starting at "
287
+ f"'{first_label}' accumulate to {acc}h but coarse "
288
+ f"block stepduration is {coarse_duration}h. "
289
+ f"Non-integer multiples of the default step are not "
290
+ f"supported yet (aligned-subsets only)."
291
+ )
292
+ out.append((first_label, coarse_duration))
293
+ i = j
294
+ return out
295
+
296
+
297
+ def _cyclic_block_predecessors(
298
+ block: str,
299
+ per_period_rows: dict[str, list[tuple[str, float]]],
300
+ ) -> list[tuple[str, str, str, str, str, str, str]]:
301
+ """Cyclic predecessor pattern over a block's aggregated timeline.
302
+
303
+ Mirrors ``blocks._cyclic_block_predecessors``. Wraps cyclically
304
+ on the first step of the first period; first step of subsequent
305
+ periods crosses to the previous period's block-last step.
306
+ """
307
+ periods = list(per_period_rows.keys())
308
+ if not periods:
309
+ return []
310
+ first_period = periods[0]
311
+ last_period = periods[-1]
312
+
313
+ out: list[tuple[str, str, str, str, str, str, str]] = []
314
+ for pi, period in enumerate(periods):
315
+ rows = per_period_rows[period]
316
+ if not rows:
317
+ continue
318
+ block_last_step = rows[-1][0]
319
+ for si, (step, _dur) in enumerate(rows):
320
+ if si > 0:
321
+ prev_step = rows[si - 1][0]
322
+ prev_within_ts = prev_step
323
+ prev_period = period
324
+ prev_within_solve = prev_step
325
+ else:
326
+ if period == first_period:
327
+ prev_period_rows = per_period_rows[last_period]
328
+ prev_step = (
329
+ prev_period_rows[-1][0]
330
+ if prev_period_rows else step
331
+ )
332
+ prev_period = last_period
333
+ prev_within_solve = prev_step
334
+ prev_within_ts = block_last_step
335
+ else:
336
+ prev_period = periods[pi - 1]
337
+ prev_rows = per_period_rows[prev_period]
338
+ prev_step = (
339
+ prev_rows[-1][0]
340
+ if prev_rows else step
341
+ )
342
+ prev_within_solve = prev_step
343
+ prev_within_ts = block_last_step
344
+ out.append(
345
+ (
346
+ block,
347
+ period,
348
+ step,
349
+ prev_step,
350
+ prev_within_ts,
351
+ prev_period,
352
+ prev_within_solve,
353
+ )
354
+ )
355
+ return out
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # BlockLayout — public surface
360
+ # ---------------------------------------------------------------------------
361
+
362
+
363
+ @dataclass
364
+ class BlockLayout:
365
+ """Per-solve block layout — multi-resolution storage.
366
+
367
+ Internal bookkeeping (``node_block``, ``process_block_in/_out/_block``,
368
+ ``block_step_duration``) lives as Python dicts because the input
369
+ data is small (O(entities + arcs)) and the algorithm is naturally
370
+ expressed in dict/tuple form. The output surface (``entity_block``,
371
+ ``process_side_block``, ``block_step_duration_frame``, etc.) exposes
372
+ every frame the LP-build layer needs as a polars DataFrame in the
373
+ same column-naming convention the reference CSVs use.
374
+
375
+ Attributes
376
+ ----------
377
+ Internal (matching ``BlockAssignments``):
378
+
379
+ node_block : dict[str, str]
380
+ ``node_name → block_name``.
381
+ process_block_in / process_block_out / process_block : dict[str, str]
382
+ Per-process block on each side; ``process_block`` is the
383
+ unified block used for UC / startup / shutdown indices.
384
+ block_step_duration : dict[str, float]
385
+ ``block_name → hours per step``. ``DEFAULT_BLOCK`` always
386
+ present.
387
+
388
+ Frames (every CSV the reference emits):
389
+
390
+ entity_block_frame : pl.DataFrame
391
+ Columns ``(entity, block)``.
392
+ process_side_block_frame : pl.DataFrame
393
+ Columns ``(process, side, block)`` — two rows per process.
394
+ process_block_frame : pl.DataFrame
395
+ Columns ``(process, block)`` — one row per process.
396
+ block_step_duration_frame : pl.DataFrame
397
+ Columns ``(block, period, step, step_duration)``.
398
+ overlap_set_frame : pl.DataFrame
399
+ Columns ``(period, block_coarse, step_coarse, block_fine,
400
+ step_fine, fraction)``.
401
+ block_step_previous_frame : pl.DataFrame
402
+ Columns ``(block, period, step, step_previous,
403
+ step_previous_within_timeset, period_previous,
404
+ step_previous_within_solve)``.
405
+ block_period_time_first_frame / _last_frame : pl.DataFrame
406
+ Columns ``(block, period, step)``.
407
+ """
408
+
409
+ # --- Internal bookkeeping --------------------------------------------
410
+ node_block: dict[str, str] = field(default_factory=dict)
411
+ process_block_in: dict[str, str] = field(default_factory=dict)
412
+ process_block_out: dict[str, str] = field(default_factory=dict)
413
+ process_block: dict[str, str] = field(default_factory=dict)
414
+ block_step_duration: dict[str, float] = field(default_factory=dict)
415
+ # Per-block (period → [(step, duration)]) timeline.
416
+ per_block_timeline: dict[str, dict[str, list[tuple[str, float]]]] = field(
417
+ default_factory=dict,
418
+ )
419
+
420
+ # --- Output frames ----------------------------------------------------
421
+ entity_block_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
422
+ process_side_block_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
423
+ process_block_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
424
+ block_step_duration_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
425
+ overlap_set_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
426
+ block_step_previous_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
427
+ block_period_time_first_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
428
+ block_period_time_last_frame: pl.DataFrame = field(default_factory=pl.DataFrame)
429
+
430
+ # ------------------------------------------------------------------
431
+ # Builder
432
+ # ------------------------------------------------------------------
433
+
434
+ @classmethod
435
+ def build(
436
+ cls,
437
+ *,
438
+ solve: str,
439
+ solve_config: "SolveConfig | None",
440
+ timeline_config: "TimelineConfig | None",
441
+ nodes: Iterable[str],
442
+ units: Iterable[str],
443
+ connections: Iterable[str],
444
+ resolution_groups: dict[str, float],
445
+ group_unit: Iterable[tuple[str, str]],
446
+ group_connection: Iterable[tuple[str, str]],
447
+ group_node: Iterable[tuple[str, str]],
448
+ process_source_sink: Iterable[tuple[str, str, str]],
449
+ process_ct_method: dict[str, str],
450
+ decomposition_groups: dict[str, str] | None = None,
451
+ reserve_upDown_group: Iterable[tuple[str, str, str]] | None = None,
452
+ process_reserve_upDown_node: (
453
+ Iterable[tuple[str, str, str, str]] | None
454
+ ) = None,
455
+ active_time_list: dict[str, list] | None = None,
456
+ default_jump_list: Iterable[tuple] | None = None,
457
+ validate: bool = True,
458
+ ) -> "BlockLayout":
459
+ """Compute every per-solve block frame in one pass.
460
+
461
+ Parameters mirror flextool's ``write_block_data_for_solve`` /
462
+ ``derive_blocks`` / ``derive_overlap_set`` signatures so the
463
+ port is a drop-in algorithmic equivalent.
464
+
465
+ ``validate=False`` skips ``validate_group_membership`` —
466
+ useful when the caller already validated upstream.
467
+
468
+ Returns
469
+ -------
470
+ BlockLayout
471
+ Fully populated dataclass. Frames carry the canonical
472
+ CSV column names so downstream helpers can join directly.
473
+
474
+ Raises
475
+ ------
476
+ FlexToolConfigError
477
+ On ambiguous group membership (validation failure).
478
+ NotImplementedError
479
+ On non-aligned-subset coarse blocks (reference behaviour).
480
+ """
481
+ gu = list(group_unit)
482
+ gc = list(group_connection)
483
+ gn = list(group_node)
484
+ decomp = decomposition_groups or {}
485
+
486
+ if validate:
487
+ validate_group_membership(
488
+ gu, gc, gn,
489
+ resolution_groups,
490
+ decomp,
491
+ reserve_upDown_group=reserve_upDown_group,
492
+ process_reserve_upDown_node=process_reserve_upDown_node,
493
+ )
494
+
495
+ layout = cls()
496
+ layout._derive_blocks(
497
+ solve=solve,
498
+ solve_config=solve_config,
499
+ timeline_config=timeline_config,
500
+ nodes=nodes,
501
+ units=units,
502
+ connections=connections,
503
+ resolution_groups=resolution_groups,
504
+ group_unit=gu,
505
+ group_connection=gc,
506
+ group_node=gn,
507
+ process_source_sink=process_source_sink,
508
+ process_ct_method=process_ct_method,
509
+ )
510
+ layout._build_block_timelines(
511
+ solve=solve,
512
+ solve_config=solve_config,
513
+ timeline_config=timeline_config,
514
+ active_time_list=active_time_list,
515
+ )
516
+ layout._build_overlap_set()
517
+ layout._build_block_predecessors(default_jump_list=default_jump_list)
518
+ layout._build_block_boundaries()
519
+ layout._materialize_frames()
520
+ return layout
521
+
522
+ # ------------------------------------------------------------------
523
+ # Source-only constructor (Phase 1 of the fast-path multi-block plan)
524
+ # ------------------------------------------------------------------
525
+
526
+ @classmethod
527
+ def from_source(
528
+ cls,
529
+ source,
530
+ solve_config: "SolveConfig",
531
+ timeline_config: "TimelineConfig",
532
+ *,
533
+ active_solve: str | None = None,
534
+ validate: bool = True,
535
+ ) -> "BlockLayout":
536
+ """Construct a :class:`BlockLayout` directly from a Spine
537
+ :class:`~flextool.engine_polars._input_source.InputSource`.
538
+
539
+ Source-only counterpart to :meth:`load_from_solve_data`: pulls
540
+ every input that :meth:`build` needs from the source / configs
541
+ (no workdir CSV reads), then delegates. Intended for the fast
542
+ single-solve path where ``solve_data/`` is empty.
543
+
544
+ Parameters
545
+ ----------
546
+ source : InputSource
547
+ Scenario-resolved per-(entity_class, parameter_name)
548
+ reader (e.g. :class:`SpineDbReader`,
549
+ :class:`InMemoryReader`).
550
+ solve_config, timeline_config :
551
+ Already-loaded configs. ``timeline_config`` must have had
552
+ :meth:`TimelineConfig.create_assumptive_parts` /
553
+ :meth:`TimelineConfig.create_timeline_from_timestep_duration`
554
+ applied so :func:`_timeline.get_active_time` succeeds for
555
+ ``active_solve``.
556
+ active_solve : str | None
557
+ Active solve name. When ``None`` we pick the first solve
558
+ entity in the scenario (single-solve fixtures).
559
+ validate : bool
560
+ Forwarded to :meth:`build` — when ``True`` runs
561
+ :func:`validate_group_membership` on the assembled inputs.
562
+
563
+ Returns
564
+ -------
565
+ BlockLayout
566
+ Fully populated, identical surface to what
567
+ :meth:`load_from_solve_data` produces from the slow-path's
568
+ ``solve_data/`` block CSVs (on the same scenario).
569
+
570
+ """
571
+ # ── 1. Resolve the active solve name ───────────────────────────
572
+ from flextool.engine_polars._projection_params import (
573
+ _try_entities,
574
+ _try_param,
575
+ )
576
+
577
+ if active_solve is None:
578
+ solves_param = _try_param(source, "model", "solves")
579
+ if solves_param is not None and solves_param.height > 0:
580
+ val_col = (
581
+ "value" if "value" in solves_param.columns
582
+ else solves_param.columns[-1]
583
+ )
584
+ active_solve = str(solves_param[val_col][0])
585
+ else:
586
+ solve_ents = _try_entities(source, "solve")
587
+ if solve_ents is None or solve_ents.height == 0:
588
+ raise FlexToolConfigError(
589
+ "BlockLayout.from_source: no active solve could be "
590
+ "resolved — pass ``active_solve`` explicitly or "
591
+ "populate ``model.solves`` / the ``solve`` entity "
592
+ "class in the source."
593
+ )
594
+ name_col = next(
595
+ c for c in solve_ents.columns if c != "id"
596
+ )
597
+ active_solve = str(solve_ents[name_col][0])
598
+
599
+ # ── 2. Entity universes ────────────────────────────────────────
600
+ units_df = _try_entities(source, "unit")
601
+ units = units_df["name"].to_list() if units_df is not None else []
602
+ conns_df = _try_entities(source, "connection")
603
+ connections = (
604
+ conns_df["name"].to_list() if conns_df is not None else []
605
+ )
606
+ nodes_df = _try_entities(source, "node")
607
+ nodes = nodes_df["name"].to_list() if nodes_df is not None else []
608
+
609
+ # ── 3. Group resolution (new_stepduration / decomposition_method)
610
+ resolution_groups: dict[str, float] = {}
611
+ rg_param = _try_param(source, "group", "new_stepduration")
612
+ if rg_param is not None:
613
+ name_col = (
614
+ "name" if "name" in rg_param.columns
615
+ else rg_param.columns[0]
616
+ )
617
+ for row in rg_param.iter_rows(named=True):
618
+ v = row["value"]
619
+ if v is None:
620
+ continue
621
+ try:
622
+ resolution_groups[row[name_col]] = float(v)
623
+ except (TypeError, ValueError):
624
+ continue
625
+
626
+ decomposition_groups: dict[str, str] = {}
627
+ dg_param = _try_param(source, "group", "decomposition_method")
628
+ if dg_param is not None:
629
+ name_col = (
630
+ "name" if "name" in dg_param.columns
631
+ else dg_param.columns[0]
632
+ )
633
+ for row in dg_param.iter_rows(named=True):
634
+ v = row["value"]
635
+ if v is None:
636
+ continue
637
+ decomposition_groups[row[name_col]] = str(v)
638
+
639
+ # ── 4. Group memberships ───────────────────────────────────────
640
+ def _pairs(cls_name: str, dim: str) -> list[tuple[str, str]]:
641
+ df = _try_entities(source, cls_name)
642
+ if df is None or df.height == 0:
643
+ return []
644
+ return list(zip(df["group"].to_list(), df[dim].to_list()))
645
+
646
+ group_node = _pairs("group__node", "node")
647
+ group_unit = _pairs("group__unit", "unit")
648
+ group_connection = _pairs("group__connection", "connection")
649
+
650
+ # ── 5. Reserve membership (optional; absent on most fixtures) ──
651
+ reserve_upDown_group: list[tuple[str, str, str]] = []
652
+ rug_df = _try_entities(source, "reserve__upDown__group")
653
+ if rug_df is not None and rug_df.height > 0:
654
+ # Filter rows whose ``method`` (if defined) is not ``no_reserve``.
655
+ method_param = _try_param(
656
+ source, "reserve__upDown__group", "method",
657
+ )
658
+ excluded: set[tuple[str, str, str]] = set()
659
+ if method_param is not None:
660
+ for row in method_param.iter_rows(named=True):
661
+ if str(row.get("value", "")) == "no_reserve":
662
+ excluded.add(
663
+ (row["reserve"], row["upDown"], row["group"])
664
+ )
665
+ for r, ud, g in zip(
666
+ rug_df["reserve"].to_list(),
667
+ rug_df["upDown"].to_list(),
668
+ rug_df["group"].to_list(),
669
+ ):
670
+ if (r, ud, g) in excluded:
671
+ continue
672
+ reserve_upDown_group.append((r, ud, g))
673
+
674
+ process_reserve_upDown_node: list[tuple[str, str, str, str]] = []
675
+ for cls_name, p_col in (
676
+ ("reserve__upDown__unit__node", "unit"),
677
+ ("reserve__upDown__connection__node", "connection"),
678
+ ):
679
+ df = _try_entities(source, cls_name)
680
+ if df is None or df.height == 0:
681
+ continue
682
+ for p, r, ud, n in zip(
683
+ df[p_col].to_list(),
684
+ df["reserve"].to_list(),
685
+ df["upDown"].to_list(),
686
+ df["node"].to_list(),
687
+ ):
688
+ process_reserve_upDown_node.append((p, r, ud, n))
689
+
690
+ # ── 6. process_source_sink (first source / first sink per process)
691
+ # Built directly from ``unit__inputNode`` / ``unit__outputNode`` /
692
+ # ``connection__node__node`` — matching the slow path's
693
+ # ``process__source.csv`` / ``process__sink.csv`` derivation
694
+ # (input_writer.py emits one row per (process, input-or-output
695
+ # node) pair; first one wins per `derive_blocks`'s usage).
696
+ first_source: dict[str, str] = {}
697
+ first_sink: dict[str, str] = {}
698
+ uin = _try_entities(source, "unit__inputNode")
699
+ if uin is not None:
700
+ for u, n in zip(uin["unit"].to_list(), uin["node"].to_list()):
701
+ first_source.setdefault(u, n)
702
+ uout = _try_entities(source, "unit__outputNode")
703
+ if uout is not None:
704
+ for u, n in zip(uout["unit"].to_list(), uout["node"].to_list()):
705
+ first_sink.setdefault(u, n)
706
+ cnn = _try_entities(source, "connection__node__node")
707
+ if cnn is not None:
708
+ for c, a, b in zip(
709
+ cnn["connection"].to_list(),
710
+ cnn["node_1"].to_list(),
711
+ cnn["node_2"].to_list(),
712
+ ):
713
+ first_source.setdefault(c, a)
714
+ first_sink.setdefault(c, b)
715
+ process_source_sink: list[tuple[str, str, str]] = []
716
+ all_procs = set(first_source) | set(first_sink)
717
+ for p in all_procs:
718
+ process_source_sink.append(
719
+ (p, first_source.get(p, ""), first_sink.get(p, ""))
720
+ )
721
+
722
+ # ── 7. process_ct_method ──────────────────────────────────────
723
+ # Slow-path ``process__ct_method.csv`` = union of
724
+ # ``unit.conversion_method`` and ``connection.transfer_method``
725
+ # (input_writer.py:394).
726
+ process_ct_method: dict[str, str] = {}
727
+ cm_unit = _try_param(source, "unit", "conversion_method")
728
+ if cm_unit is not None:
729
+ for row in cm_unit.iter_rows(named=True):
730
+ process_ct_method[row["name"]] = str(row["value"])
731
+ cm_conn = _try_param(source, "connection", "transfer_method")
732
+ if cm_conn is not None:
733
+ for row in cm_conn.iter_rows(named=True):
734
+ process_ct_method[row["name"]] = str(row["value"])
735
+
736
+ # ── 8. active_time_list — derived from timeline + solve configs.
737
+ from flextool.engine_polars._timeline import get_active_time
738
+
739
+ active_time_list = get_active_time(
740
+ current_solve=active_solve,
741
+ timesets_used_by_solves=(
742
+ solve_config.timesets_used_by_solves
743
+ ),
744
+ timesets=timeline_config.timeset_durations,
745
+ timelines=timeline_config.timelines,
746
+ timesets__timelines=timeline_config.timesets__timeline,
747
+ )
748
+
749
+ # ── 9. default_jump_list:
750
+ # Phase-1 scope deliberately defers building this from
751
+ # ``make_step_jump`` (needs ``period__branch`` /
752
+ # ``solve_branch__time_branch_list``, which require additional
753
+ # source plumbing). Passing ``None`` lets ``build`` fall back
754
+ # to ``_cyclic_block_predecessors`` — on simple single-period
755
+ # non-stochastic fixtures the slow-path's ``step_previous.csv``
756
+ # produces the same rows (verified by the parity test below
757
+ # and by the existing ``test_load_from_solve_data_bridge_*``).
758
+ return cls.build(
759
+ solve=active_solve,
760
+ solve_config=solve_config,
761
+ timeline_config=timeline_config,
762
+ nodes=nodes,
763
+ units=units,
764
+ connections=connections,
765
+ resolution_groups=resolution_groups,
766
+ group_unit=group_unit,
767
+ group_connection=group_connection,
768
+ group_node=group_node,
769
+ process_source_sink=process_source_sink,
770
+ process_ct_method=process_ct_method,
771
+ decomposition_groups=decomposition_groups,
772
+ reserve_upDown_group=reserve_upDown_group or None,
773
+ process_reserve_upDown_node=(
774
+ process_reserve_upDown_node or None
775
+ ),
776
+ active_time_list=active_time_list,
777
+ default_jump_list=None,
778
+ validate=validate,
779
+ )
780
+
781
+ # ------------------------------------------------------------------
782
+ # Phase 1: block tagging (entity + process side)
783
+ # ------------------------------------------------------------------
784
+
785
+ def _derive_blocks(
786
+ self,
787
+ *,
788
+ solve: str,
789
+ solve_config,
790
+ timeline_config,
791
+ nodes: Iterable[str],
792
+ units: Iterable[str],
793
+ connections: Iterable[str],
794
+ resolution_groups: dict[str, float],
795
+ group_unit: list[tuple[str, str]],
796
+ group_connection: list[tuple[str, str]],
797
+ group_node: list[tuple[str, str]],
798
+ process_source_sink: Iterable[tuple[str, str, str]],
799
+ process_ct_method: dict[str, str],
800
+ ) -> None:
801
+ """Mirror ``blocks.derive_blocks``."""
802
+ gu = group_unit
803
+ gc = group_connection
804
+ gn = group_node
805
+
806
+ default_step_duration = _solve_step_duration(
807
+ solve, solve_config, timeline_config
808
+ )
809
+ block_step_duration: dict[str, float] = {
810
+ DEFAULT_BLOCK: default_step_duration,
811
+ }
812
+ for g, dur in resolution_groups.items():
813
+ try:
814
+ block_step_duration[g] = float(dur)
815
+ except (TypeError, ValueError):
816
+ continue
817
+
818
+ # Node block.
819
+ node_block: dict[str, str] = {}
820
+ for n in nodes:
821
+ g = _assign_entity_to_group(n, gn, resolution_groups)
822
+ node_block[n] = g if g is not None else DEFAULT_BLOCK
823
+
824
+ # Process source/sink — first source/sink per process.
825
+ first_source: dict[str, str] = {}
826
+ first_sink: dict[str, str] = {}
827
+ for p, s, k in process_source_sink:
828
+ first_source.setdefault(p, s)
829
+ first_sink.setdefault(p, k)
830
+
831
+ process_block_in: dict[str, str] = {}
832
+ process_block_out: dict[str, str] = {}
833
+ process_block: dict[str, str] = {}
834
+
835
+ def _finer(a: str, b: str) -> str:
836
+ da = block_step_duration.get(a, default_step_duration)
837
+ db = block_step_duration.get(b, default_step_duration)
838
+ return a if da <= db else b
839
+
840
+ explicit_membership = list(gu) + list(gc)
841
+ for p in list(units) + list(connections):
842
+ explicit_block = _assign_entity_to_group(
843
+ p, explicit_membership, resolution_groups
844
+ )
845
+ if explicit_block is not None:
846
+ process_block_in[p] = explicit_block
847
+ process_block_out[p] = explicit_block
848
+ process_block[p] = explicit_block
849
+ continue
850
+
851
+ src = first_source.get(p)
852
+ snk = first_sink.get(p)
853
+ src_block = (
854
+ node_block.get(src, DEFAULT_BLOCK) if src else DEFAULT_BLOCK
855
+ )
856
+ snk_block = (
857
+ node_block.get(snk, DEFAULT_BLOCK) if snk else DEFAULT_BLOCK
858
+ )
859
+
860
+ ct = process_ct_method.get(p, "")
861
+ if _is_direct(ct):
862
+ finer = _finer(src_block, snk_block)
863
+ process_block_in[p] = finer
864
+ process_block_out[p] = finer
865
+ process_block[p] = finer
866
+ else:
867
+ process_block_in[p] = src_block
868
+ process_block_out[p] = snk_block
869
+ process_block[p] = _finer(src_block, snk_block)
870
+
871
+ self.node_block = node_block
872
+ self.process_block_in = process_block_in
873
+ self.process_block_out = process_block_out
874
+ self.process_block = process_block
875
+ self.block_step_duration = block_step_duration
876
+
877
+ # ------------------------------------------------------------------
878
+ # Phase 2: per-block timelines
879
+ # ------------------------------------------------------------------
880
+
881
+ def _build_block_timelines(
882
+ self,
883
+ *,
884
+ solve: str,
885
+ solve_config,
886
+ timeline_config,
887
+ active_time_list: dict[str, list] | None,
888
+ ) -> None:
889
+ """Mirror ``blocks._build_block_timelines``."""
890
+ per_block: dict[str, dict[str, list[tuple[str, float]]]] = {}
891
+
892
+ default_periods: dict[str, list[tuple[str, float]]] = {}
893
+
894
+ if active_time_list is not None:
895
+ for period, rows in active_time_list.items():
896
+ default_periods[period] = [
897
+ (entry.timestep, float(entry.duration)) for entry in rows
898
+ ]
899
+ elif solve_config is not None and timeline_config is not None:
900
+ for period, ts in solve_config.timesets_used_by_solves.get(solve, []):
901
+ tl = timeline_config.timesets__timeline.get(ts)
902
+ if not tl:
903
+ continue
904
+ tl_rows = timeline_config.timelines.get(tl, [])
905
+ default_periods.setdefault(period, [])
906
+ for step, dur in tl_rows:
907
+ default_periods[period].append((step, float(dur)))
908
+
909
+ per_block[DEFAULT_BLOCK] = default_periods
910
+
911
+ for block, dur in self.block_step_duration.items():
912
+ if block == DEFAULT_BLOCK:
913
+ continue
914
+ # Skip blocks no entity is assigned to.
915
+ if (
916
+ block not in self.node_block.values()
917
+ and block not in self.process_block_in.values()
918
+ and block not in self.process_block_out.values()
919
+ and block not in self.process_block.values()
920
+ ):
921
+ continue
922
+ block_rows: dict[str, list[tuple[str, float]]] = {}
923
+ for period, rows in default_periods.items():
924
+ aggregated = _aggregate_timeline(
925
+ rows, coarse_duration=float(dur),
926
+ )
927
+ block_rows[period] = aggregated
928
+ per_block[block] = block_rows
929
+
930
+ self.per_block_timeline = per_block
931
+
932
+ # ------------------------------------------------------------------
933
+ # Phase 3: overlap set (cross-resolution)
934
+ # ------------------------------------------------------------------
935
+
936
+ def _build_overlap_set(self) -> None:
937
+ """Mirror ``blocks.derive_overlap_set``.
938
+
939
+ Caches per-(b_coarse, b_fine) coverage maps so repeated arc
940
+ lookups don't re-walk the fine timeline (schematic
941
+ recommendation — the materialised join can balloon for
942
+ multi-block scenarios with ~30K rows).
943
+ """
944
+ per_block = self.per_block_timeline
945
+ default_rows = per_block.get(DEFAULT_BLOCK, {})
946
+
947
+ rows: list[tuple[str, str, str, str, str, float]] = []
948
+ non_default = [b for b in per_block if b != DEFAULT_BLOCK]
949
+
950
+ # Degenerate case: only the default block exists.
951
+ if not non_default:
952
+ for period, pr in default_rows.items():
953
+ for step, _dur in pr:
954
+ rows.append(
955
+ (period, DEFAULT_BLOCK, step,
956
+ DEFAULT_BLOCK, step, 1.0)
957
+ )
958
+ self._overlap_rows = rows
959
+ return
960
+
961
+ # Default-against-default identity rows.
962
+ for period, pr in default_rows.items():
963
+ for step, _dur in pr:
964
+ rows.append(
965
+ (period, DEFAULT_BLOCK, step,
966
+ DEFAULT_BLOCK, step, 1.0)
967
+ )
968
+
969
+ # Cache: coverage[block][period] = {fine_step → coarse_step}.
970
+ coverage: dict[str, dict[str, dict[str, str]]] = {}
971
+
972
+ # For every resolution-group block, emit:
973
+ # * self-identity rows (b, t, b, t, 1.0)
974
+ # * coarse↔default fine rows
975
+ # * default↔coarse symmetric rows
976
+ # Build coverage along the way.
977
+ for coarse in non_default:
978
+ coarse_rows = per_block.get(coarse, {})
979
+ coverage[coarse] = {}
980
+ # Self-identity.
981
+ for period, c_rows in coarse_rows.items():
982
+ for coarse_step, _dur in c_rows:
983
+ rows.append(
984
+ (period, coarse, coarse_step,
985
+ coarse, coarse_step, 1.0)
986
+ )
987
+ for period, c_rows in coarse_rows.items():
988
+ fine = default_rows.get(period, [])
989
+ f_idx = 0
990
+ coverage[coarse].setdefault(period, {})
991
+ for coarse_step, coarse_dur in c_rows:
992
+ remaining = float(coarse_dur)
993
+ eps = 1e-9
994
+ while remaining > eps and f_idx < len(fine):
995
+ fine_step, fine_dur = fine[f_idx]
996
+ rows.append(
997
+ (period, coarse, coarse_step,
998
+ DEFAULT_BLOCK, fine_step, 1.0)
999
+ )
1000
+ rows.append(
1001
+ (period, DEFAULT_BLOCK, fine_step,
1002
+ coarse, coarse_step, 1.0)
1003
+ )
1004
+ coverage[coarse][period][fine_step] = coarse_step
1005
+ remaining -= float(fine_dur)
1006
+ f_idx += 1
1007
+ if remaining > eps:
1008
+ raise NotImplementedError(
1009
+ f"overlap_set: coarse step '{coarse_step}' on "
1010
+ f"block '{coarse}' left {remaining}h unmatched "
1011
+ f"at end of fine timeline — non-aligned subsets "
1012
+ f"are not supported yet."
1013
+ )
1014
+
1015
+ # Pairs of resolution-group blocks: emit coarse→fine rows
1016
+ # (canonical direction) for blocks of different durations.
1017
+ if len(non_default) >= 2:
1018
+ for a in non_default:
1019
+ for b in non_default:
1020
+ if a == b:
1021
+ continue
1022
+ a_dur = self.block_step_duration.get(a, 0.0)
1023
+ b_dur = self.block_step_duration.get(b, 0.0)
1024
+ # Only emit coarse→fine pairs to avoid duplication.
1025
+ if a_dur > b_dur:
1026
+ emit = True
1027
+ elif a_dur == b_dur and a < b:
1028
+ emit = True
1029
+ else:
1030
+ emit = False
1031
+ if not emit:
1032
+ continue
1033
+ for period, a_cov in coverage[a].items():
1034
+ b_cov = coverage[b].get(period, {})
1035
+ for fine_step, a_step in a_cov.items():
1036
+ b_step = b_cov.get(fine_step)
1037
+ if b_step is None:
1038
+ continue
1039
+ rows.append(
1040
+ (period, a, a_step, b, b_step, 1.0)
1041
+ )
1042
+
1043
+ # Deduplicate while preserving order.
1044
+ seen: set[tuple] = set()
1045
+ deduped: list[tuple[str, str, str, str, str, float]] = []
1046
+ for row in rows:
1047
+ if row in seen:
1048
+ continue
1049
+ seen.add(row)
1050
+ deduped.append(row)
1051
+ self._overlap_rows = deduped
1052
+ # Cache for downstream consumers.
1053
+ self._coverage_cache = coverage
1054
+
1055
+ # ------------------------------------------------------------------
1056
+ # Phase 4: per-block predecessors + boundaries
1057
+ # ------------------------------------------------------------------
1058
+
1059
+ def _build_block_predecessors(
1060
+ self, *, default_jump_list: Iterable[tuple] | None,
1061
+ ) -> None:
1062
+ """Mirror ``blocks.derive_block_predecessors``."""
1063
+ rows: list[tuple[str, str, str, str, str, str, str]] = []
1064
+
1065
+ if default_jump_list is not None:
1066
+ for entry in default_jump_list:
1067
+ period = entry[0]
1068
+ step = entry[1]
1069
+ previous = entry[2]
1070
+ previous_within_timeset = entry[3]
1071
+ previous_period = entry[4]
1072
+ previous_within_solve = entry[5]
1073
+ rows.append(
1074
+ (
1075
+ DEFAULT_BLOCK,
1076
+ period,
1077
+ step,
1078
+ previous,
1079
+ previous_within_timeset,
1080
+ previous_period,
1081
+ previous_within_solve,
1082
+ )
1083
+ )
1084
+ else:
1085
+ default_rows = self.per_block_timeline.get(DEFAULT_BLOCK, {})
1086
+ rows.extend(_cyclic_block_predecessors(DEFAULT_BLOCK, default_rows))
1087
+
1088
+ for block in self.block_step_duration:
1089
+ if block == DEFAULT_BLOCK:
1090
+ continue
1091
+ block_rows_per_period = self.per_block_timeline.get(block, {})
1092
+ if not block_rows_per_period:
1093
+ continue
1094
+ rows.extend(_cyclic_block_predecessors(block, block_rows_per_period))
1095
+
1096
+ self._predecessor_rows = rows
1097
+
1098
+ def _build_block_boundaries(self) -> None:
1099
+ """Mirror ``blocks.derive_block_boundaries``."""
1100
+ first: list[tuple[str, str, str]] = []
1101
+ last: list[tuple[str, str, str]] = []
1102
+ for block in self.block_step_duration:
1103
+ rows_per_period = self.per_block_timeline.get(block, {})
1104
+ for period, rows in rows_per_period.items():
1105
+ if not rows:
1106
+ continue
1107
+ first.append((block, period, rows[0][0]))
1108
+ last.append((block, period, rows[-1][0]))
1109
+ self._boundary_first_rows = first
1110
+ self._boundary_last_rows = last
1111
+
1112
+ # ------------------------------------------------------------------
1113
+ # Phase 5: materialise polars frames
1114
+ # ------------------------------------------------------------------
1115
+
1116
+ def _materialize_frames(self) -> None:
1117
+ """Build the polars DataFrame surface from the dict bookkeeping.
1118
+
1119
+ Column names match the canonical CSV writers' headers so
1120
+ downstream callers can join directly without renaming.
1121
+ """
1122
+ # entity_block.csv — one row per node.
1123
+ eb_rows = list(self.node_block.items())
1124
+ self.entity_block_frame = pl.DataFrame(
1125
+ {
1126
+ "entity": [r[0] for r in eb_rows],
1127
+ "block": [r[1] for r in eb_rows],
1128
+ },
1129
+ schema={"entity": schema_dtype(_enums, "entity"),
1130
+ "block": pl.Utf8},
1131
+ )
1132
+
1133
+ # process_side_block.csv — two rows per process (source + sink).
1134
+ psb_rows: list[tuple[str, str, str]] = []
1135
+ for process, block in self.process_block_in.items():
1136
+ psb_rows.append((process, "source", block))
1137
+ for process, block in self.process_block_out.items():
1138
+ psb_rows.append((process, "sink", block))
1139
+ self.process_side_block_frame = pl.DataFrame(
1140
+ {
1141
+ "process": [r[0] for r in psb_rows],
1142
+ "side": [r[1] for r in psb_rows],
1143
+ "block": [r[2] for r in psb_rows],
1144
+ },
1145
+ schema={"process": schema_dtype(_enums, "process"),
1146
+ "side": pl.Utf8, "block": pl.Utf8},
1147
+ )
1148
+
1149
+ # process_block.csv — process-unified block.
1150
+ pb_rows = list(self.process_block.items())
1151
+ self.process_block_frame = pl.DataFrame(
1152
+ {
1153
+ "process": [r[0] for r in pb_rows],
1154
+ "block": [r[1] for r in pb_rows],
1155
+ },
1156
+ schema={"process": schema_dtype(_enums, "process"),
1157
+ "block": pl.Utf8},
1158
+ )
1159
+
1160
+ # block_step_duration.csv — full per-block timeline.
1161
+ bsd_rows: list[tuple[str, str, str, float]] = []
1162
+ for block, period_rows in self.per_block_timeline.items():
1163
+ for period, rows in period_rows.items():
1164
+ for step, dur in rows:
1165
+ bsd_rows.append((block, period, step, dur))
1166
+ self.block_step_duration_frame = pl.DataFrame(
1167
+ {
1168
+ "block": [r[0] for r in bsd_rows],
1169
+ "period": [r[1] for r in bsd_rows],
1170
+ "step": [r[2] for r in bsd_rows],
1171
+ "step_duration": [r[3] for r in bsd_rows],
1172
+ },
1173
+ schema={
1174
+ "block": pl.Utf8, "period": pl.Utf8,
1175
+ "step": pl.Utf8, "step_duration": pl.Float64,
1176
+ },
1177
+ )
1178
+
1179
+ # overlap_set.csv — cross-resolution rows.
1180
+ ov_rows = self._overlap_rows
1181
+ self.overlap_set_frame = pl.DataFrame(
1182
+ {
1183
+ "period": [r[0] for r in ov_rows],
1184
+ "block_coarse": [r[1] for r in ov_rows],
1185
+ "step_coarse": [r[2] for r in ov_rows],
1186
+ "block_fine": [r[3] for r in ov_rows],
1187
+ "step_fine": [r[4] for r in ov_rows],
1188
+ "fraction": [r[5] for r in ov_rows],
1189
+ },
1190
+ schema={
1191
+ "period": pl.Utf8, "block_coarse": pl.Utf8,
1192
+ "step_coarse": pl.Utf8, "block_fine": pl.Utf8,
1193
+ "step_fine": pl.Utf8, "fraction": pl.Float64,
1194
+ },
1195
+ )
1196
+
1197
+ # block_step_previous.csv — per-block predecessor rows.
1198
+ pred_rows = self._predecessor_rows
1199
+ self.block_step_previous_frame = pl.DataFrame(
1200
+ {
1201
+ "block": [r[0] for r in pred_rows],
1202
+ "period": [r[1] for r in pred_rows],
1203
+ "step": [r[2] for r in pred_rows],
1204
+ "step_previous": [r[3] for r in pred_rows],
1205
+ "step_previous_within_timeset": [r[4] for r in pred_rows],
1206
+ "period_previous": [r[5] for r in pred_rows],
1207
+ "step_previous_within_solve": [r[6] for r in pred_rows],
1208
+ },
1209
+ schema={
1210
+ "block": pl.Utf8, "period": pl.Utf8, "step": pl.Utf8,
1211
+ "step_previous": pl.Utf8,
1212
+ "step_previous_within_timeset": pl.Utf8,
1213
+ "period_previous": pl.Utf8,
1214
+ "step_previous_within_solve": pl.Utf8,
1215
+ },
1216
+ )
1217
+
1218
+ # block_period_time_first.csv / _last.csv.
1219
+ first_rows = self._boundary_first_rows
1220
+ last_rows = self._boundary_last_rows
1221
+ self.block_period_time_first_frame = pl.DataFrame(
1222
+ {
1223
+ "block": [r[0] for r in first_rows],
1224
+ "period": [r[1] for r in first_rows],
1225
+ "step": [r[2] for r in first_rows],
1226
+ },
1227
+ schema={"block": pl.Utf8,
1228
+ "period": schema_dtype(_enums, "period"),
1229
+ "step": schema_dtype(_enums, "step")},
1230
+ )
1231
+ self.block_period_time_last_frame = pl.DataFrame(
1232
+ {
1233
+ "block": [r[0] for r in last_rows],
1234
+ "period": [r[1] for r in last_rows],
1235
+ "step": [r[2] for r in last_rows],
1236
+ },
1237
+ schema={"block": pl.Utf8,
1238
+ "period": schema_dtype(_enums, "period"),
1239
+ "step": schema_dtype(_enums, "step")},
1240
+ )
1241
+
1242
+
1243
+ # ------------------------------------------------------------------
1244
+ # CSV bridge — load from flextool's solve_data/ CSVs
1245
+ # ------------------------------------------------------------------
1246
+
1247
+ @classmethod
1248
+ def load_from_solve_data(
1249
+ cls,
1250
+ solve_data_dir,
1251
+ *,
1252
+ missing_ok: bool = True,
1253
+ provider: "object | None" = None,
1254
+ ) -> "BlockLayout":
1255
+ """Load a ``BlockLayout`` from a :class:`FlexDataProvider`.
1256
+
1257
+ Step 2.5 Phase A: ``blocks.write_block_data`` is now patched
1258
+ into ``_PATCH_MODULES`` so the eight per-solve block frames
1259
+ live in the Provider after the cascade's preprocessing pass.
1260
+ Callers MUST pass *provider*. When only a workdir is available
1261
+ (off-cascade tests, transitional bridges), use
1262
+ :func:`flextool.engine_polars._input_source.seed_provider_from_dir`
1263
+ on ``workdir/"solve_data"`` to construct an ephemeral Provider
1264
+ first.
1265
+
1266
+ Parameters
1267
+ ----------
1268
+ solve_data_dir : Path
1269
+ Retained for symmetry with the legacy signature; unused
1270
+ when *provider* carries every requested key. The
1271
+ classmethod will refuse to read disk directly — the
1272
+ workdir-bridge contract is now Provider-only.
1273
+ missing_ok : bool
1274
+ When True (default), missing Provider keys produce empty
1275
+ frames. When False, raise ``KeyError`` on the first
1276
+ missing key.
1277
+ provider :
1278
+ :class:`FlexDataProvider` to serve frames from. Required.
1279
+
1280
+ Returns
1281
+ -------
1282
+ BlockLayout
1283
+ Populated layout. ``per_block_timeline`` is reconstructed
1284
+ from ``block_step_duration_frame`` rows.
1285
+ """
1286
+ if provider is None:
1287
+ # Off-cascade bridge path: seed an ephemeral Provider from
1288
+ # the workdir CSV directory and serve from that. Cascade
1289
+ # callers always pass *provider* explicitly; this branch is
1290
+ # reserved for tests and the legacy ``load_block_bundle``
1291
+ # contract. Centralised here so the Rule 1 audit can stay
1292
+ # strict — no bare ``pl.read_csv`` / ``_read_csv_file`` in
1293
+ # this module.
1294
+ from pathlib import Path as _Path
1295
+ from flextool.engine_polars._flex_data_provider import (
1296
+ FlexDataProvider,
1297
+ )
1298
+ from flextool.engine_polars._input_source import (
1299
+ seed_provider_from_dir,
1300
+ )
1301
+ sd_dir = _Path(solve_data_dir)
1302
+ provider = FlexDataProvider()
1303
+ seed_provider_from_dir(
1304
+ provider, sd_dir, "solve_data",
1305
+ names=_BLOCK_CSV_STEMS,
1306
+ )
1307
+
1308
+ def _read(name: str, schema: dict) -> pl.DataFrame:
1309
+ stem = name.removesuffix(".csv")
1310
+ k = f"solve_data/{stem}"
1311
+ if provider.has(k):
1312
+ df = provider.get(k)
1313
+ # Coerce dtypes — Provider may carry Utf8 for
1314
+ # numeric columns when the writer's _empty_frame
1315
+ # shortcut hits.
1316
+ for col, dt in schema.items():
1317
+ if col in df.columns and df.schema[col] != dt:
1318
+ df = df.with_columns(
1319
+ pl.col(col).cast(dt, strict=False),
1320
+ )
1321
+ return df
1322
+ if missing_ok:
1323
+ return pl.DataFrame(schema=schema)
1324
+ raise KeyError(
1325
+ f"BlockLayout.load_from_solve_data: provider has no "
1326
+ f"key for '{name}' (tried 'solve_data/{stem}' and "
1327
+ f"'{stem}')."
1328
+ )
1329
+
1330
+ layout = cls()
1331
+ layout.entity_block_frame = _read(
1332
+ "entity_block.csv",
1333
+ {"entity": pl.Utf8, "block": pl.Utf8},
1334
+ )
1335
+ layout.process_side_block_frame = _read(
1336
+ "process_side_block.csv",
1337
+ {"process": pl.Utf8, "side": pl.Utf8, "block": pl.Utf8},
1338
+ )
1339
+ layout.process_block_frame = _read(
1340
+ "process_block.csv",
1341
+ {"process": pl.Utf8, "block": pl.Utf8},
1342
+ )
1343
+ layout.block_step_duration_frame = _read(
1344
+ "block_step_duration.csv",
1345
+ {
1346
+ "block": pl.Utf8, "period": pl.Utf8,
1347
+ "step": pl.Utf8, "step_duration": pl.Float64,
1348
+ },
1349
+ )
1350
+ layout.overlap_set_frame = _read(
1351
+ "overlap_set.csv",
1352
+ {
1353
+ "period": pl.Utf8, "block_coarse": pl.Utf8,
1354
+ "step_coarse": pl.Utf8, "block_fine": pl.Utf8,
1355
+ "step_fine": pl.Utf8, "fraction": pl.Float64,
1356
+ },
1357
+ )
1358
+ layout.block_step_previous_frame = _read(
1359
+ "block_step_previous.csv",
1360
+ {
1361
+ "block": pl.Utf8, "period": pl.Utf8, "step": pl.Utf8,
1362
+ "step_previous": pl.Utf8,
1363
+ "step_previous_within_timeset": pl.Utf8,
1364
+ "period_previous": pl.Utf8,
1365
+ "step_previous_within_solve": pl.Utf8,
1366
+ },
1367
+ )
1368
+ layout.block_period_time_first_frame = _read(
1369
+ "block_period_time_first.csv",
1370
+ {"block": pl.Utf8, "period": pl.Utf8, "step": pl.Utf8},
1371
+ )
1372
+ layout.block_period_time_last_frame = _read(
1373
+ "block_period_time_last.csv",
1374
+ {"block": pl.Utf8, "period": pl.Utf8, "step": pl.Utf8},
1375
+ )
1376
+
1377
+ # Reconstruct internal bookkeeping dicts from frames so callers
1378
+ # can index by entity name.
1379
+ if layout.entity_block_frame.height > 0:
1380
+ layout.node_block = dict(zip(
1381
+ layout.entity_block_frame["entity"].to_list(),
1382
+ layout.entity_block_frame["block"].to_list(),
1383
+ ))
1384
+ if layout.process_side_block_frame.height > 0:
1385
+ for row in layout.process_side_block_frame.iter_rows(named=True):
1386
+ if row["side"] == "source":
1387
+ layout.process_block_in[row["process"]] = row["block"]
1388
+ elif row["side"] == "sink":
1389
+ layout.process_block_out[row["process"]] = row["block"]
1390
+ if layout.process_block_frame.height > 0:
1391
+ layout.process_block = dict(zip(
1392
+ layout.process_block_frame["process"].to_list(),
1393
+ layout.process_block_frame["block"].to_list(),
1394
+ ))
1395
+
1396
+ # block_step_duration: dict[block → step_duration] uses the
1397
+ # first step's duration for each block (every step in a block
1398
+ # shares the same duration by construction).
1399
+ bsd = layout.block_step_duration_frame
1400
+ if bsd.height > 0:
1401
+ layout.block_step_duration = dict(
1402
+ bsd.group_by("block")
1403
+ .agg(pl.col("step_duration").first())
1404
+ .iter_rows()
1405
+ )
1406
+ # per_block_timeline reconstruction.
1407
+ for row in bsd.iter_rows(named=True):
1408
+ blk = row["block"]
1409
+ period = row["period"]
1410
+ layout.per_block_timeline.setdefault(blk, {}).setdefault(
1411
+ period, [],
1412
+ ).append((row["step"], row["step_duration"]))
1413
+ return layout
1414
+
1415
+ # ------------------------------------------------------------------
1416
+ # Convenience accessors for downstream consumers
1417
+ # ------------------------------------------------------------------
1418
+
1419
+ def block_compat(self) -> pl.DataFrame:
1420
+ """Return the (bk, b_f) → 1 compatibility set used by the
1421
+ block-aware filtering of flow_to_n / flow_from_n.
1422
+
1423
+ The block axis column is named ``bk`` (not ``b``) to disambiguate
1424
+ from the branch axis — see the b_collision review note in
1425
+ ``schemas/flextool_axis_contract.json``. ``b_f`` is the
1426
+ block-fine companion column (also block vocabulary, but distinct
1427
+ column name so it doesn't collide).
1428
+
1429
+ Equivalent to::
1430
+
1431
+ overlap_set_frame
1432
+ .rename({"block_coarse": "bk", "block_fine": "b_f"})
1433
+ .select("bk", "b_f").unique()
1434
+
1435
+ Cached attribute would help on hot paths; the materialised set
1436
+ is small (≤ |blocks|² ≈ tens of rows).
1437
+ """
1438
+ if self.overlap_set_frame.height == 0:
1439
+ return pl.DataFrame(schema={
1440
+ "bk": schema_dtype(_enums, "bk"),
1441
+ "b_f": schema_dtype(_enums, "b_f"),
1442
+ })
1443
+ return (
1444
+ self.overlap_set_frame
1445
+ .pipe(rename_to_axis, {"block_coarse": "bk", "block_fine": "b_f"})
1446
+ .select("bk", "b_f").unique()
1447
+ )
1448
+
1449
+ def coarse_blocks(self, threshold: float = 1.0) -> list[str]:
1450
+ """Return blocks with at least one row of ``step_duration >
1451
+ threshold``.
1452
+
1453
+ Mirrors the ``coarse_blocks`` selection in
1454
+ ``input.py``'s nodeStateBlock synthesis (lines 2013-2040).
1455
+ """
1456
+ if self.block_step_duration_frame.height == 0:
1457
+ return []
1458
+ return (
1459
+ self.block_step_duration_frame
1460
+ .filter(pl.col("step_duration") > threshold)
1461
+ ["block"].unique().to_list()
1462
+ )
1463
+
1464
+ def is_empty(self) -> bool:
1465
+ """Return True if the layout carries no block data at all
1466
+ (every frame is empty). Useful for the ``missing_ok`` branch
1467
+ of the CSV-loading bridge."""
1468
+ return (
1469
+ self.entity_block_frame.height == 0
1470
+ and self.block_step_duration_frame.height == 0
1471
+ and self.overlap_set_frame.height == 0
1472
+ )
1473
+
1474
+
1475
+ __all__ = [
1476
+ "DEFAULT_BLOCK",
1477
+ "BlockLayout",
1478
+ "validate_group_membership",
1479
+ ]