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,990 @@
1
+ """Cluster E — block-layout consumers (Δ.9).
2
+
3
+ Lazy-polars port of flextool's block-aware derived helpers. Cluster E is
4
+ the fifth of six derived-helper port phases per
5
+ ``audit/native_data_path_design_derived_clusters.md``.
6
+
7
+ Cluster E fields (per the schematic):
8
+
9
+ * ``nodeStateBlock`` — set: nodes pulling daily-aggregation balance.
10
+ * ``period_block`` / ``period_block_succ`` / ``period_block_time``
11
+ — multi-resolution block decomposition for storage state.
12
+ * ``arc_sink_block_dt`` / ``arc_source_block_dt`` — per-arc daily-block
13
+ aggregation index ``(p, source, sink, d, b_first, t, weight)``.
14
+ * ``p_arc_sink_weight`` / ``p_arc_source_weight`` —
15
+ ``Param[(p, source, sink, d, t), weight]`` projected from the above.
16
+ * ``dtttdt_block_interior`` — interior-of-block dtttdt rows.
17
+ * ``nodeState_last_dt`` — ``(n, d, t)`` last fine-step of last block.
18
+ * ``flow_to_n`` / ``flow_from_n`` — block-compatibility filtered.
19
+ * ``flow_from_nodeBalance_eff`` / ``flow_from_nodeBalance_noEff`` —
20
+ block-compatibility filtered source-side nodeBalance arcs.
21
+
22
+ All consumers read ``BlockLayout``'s in-memory frames; no helper
23
+ re-reads ``solve_data/{entity_block,process_side_block,
24
+ block_step_duration,overlap_set,block_period_time_*}.csv``.
25
+
26
+ The single ``BlockLayout`` is built once per solve via
27
+ ``BlockLayout.load_from_solve_data`` (or, post-Γ.8, natively from
28
+ ``BlockLayout.build``). ``BlockBundle`` wraps the layout with cached
29
+ derived frames (``block_compat_frame``, ``process_side_block_lf`` etc.)
30
+ that the cluster E helpers join against.
31
+
32
+ Design decisions:
33
+
34
+ * **One BlockLayout per solve.** Repeated CSV reads were the Δ.2 carry-
35
+ over; cluster E folds every consumer onto one shared layout.
36
+ * **Cache ``block_compat`` and the rename'd join helpers.** Lazy frames
37
+ share the cached materialisation; downstream `.collect()` once at
38
+ the rim.
39
+ * **Identity-trivial fast path.** Single-block fixtures (``work_base``
40
+ etc.) collapse the filter joins to no-ops because
41
+ ``block_compat`` carries only ``(default, default)``.
42
+ * **Closes the Δ.3 `flow_to_n` / `flow_from_n` gap.** The block-aware
43
+ filter previously lived only in the CSV path
44
+ (``input.py::_load_process_topology``); the helper here mirrors it
45
+ on the source-driven path so multi-block fixtures' `db_direct_parity`
46
+ test stops being a known gap.
47
+
48
+ Reference: ``flextool/engine_polars/input.py::_load_process_topology``
49
+ (lines 703-783) and ``input.py::_load_storage`` (lines 1647-1699,
50
+ 2233-2253).
51
+ """
52
+ from __future__ import annotations
53
+
54
+ from dataclasses import dataclass, field
55
+ from pathlib import Path
56
+ from typing import TYPE_CHECKING
57
+
58
+ import polars as pl
59
+
60
+ from polar_high import Param
61
+
62
+ from flextool.engine_polars._axis_enums import (
63
+ alias_to_axis,
64
+ cast_dim,
65
+ cast_frame_axes,
66
+ get_global_axis_enums,
67
+ lit_axis,
68
+ rename_to_axis,
69
+ schema_dtype,
70
+ )
71
+ from flextool.engine_polars._block_layout import (
72
+ DEFAULT_BLOCK,
73
+ BlockLayout,
74
+ )
75
+
76
+
77
+ # Substrate handle for the cascade-wide axis enum vocabulary.
78
+ # Bare ``None`` here; ``cast_dim`` / ``schema_dtype`` in
79
+ # ``_axis_enums`` fall back to ``_LIVE_AXIS_ENUMS_CTX`` (the live
80
+ # ContextVar) when this is ``None``, so substrate sites pick up
81
+ # activation set by ``load_flextool`` automatically.
82
+ _enums: "dict | None" = None
83
+
84
+ if TYPE_CHECKING: # pragma: no cover — typing only
85
+ pass
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # BlockBundle — BlockLayout + cached lazy join frames
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ @dataclass
94
+ class BlockBundle:
95
+ """Cached lazy-frame surface over a :class:`BlockLayout`.
96
+
97
+ The bundle wraps a single per-solve :class:`BlockLayout` and exposes
98
+ pre-renamed lazy frames keyed for cluster E consumers' joins. The
99
+ rename + cache pattern matches the schematic's "cache ``overlap_set``
100
+ per (b_coarse, b_fine) pair" recommendation — every helper that
101
+ joins on ``block_compat`` shares the same materialised frame.
102
+
103
+ Attributes
104
+ ----------
105
+ layout : BlockLayout
106
+ The underlying per-solve block layout.
107
+ process_side_block_lf : pl.LazyFrame
108
+ Renamed ``(p, side, b_f)`` lazy frame for arc-side block lookups.
109
+ entity_block_lf : pl.LazyFrame
110
+ Renamed ``(n, b)`` lazy frame for node-side block lookups.
111
+ block_compat_frame : pl.DataFrame
112
+ Cached compatibility set ``(b, b_f)`` derived from
113
+ ``overlap_set`` — populated lazily on first access.
114
+ block_step_duration_arc_lf : pl.LazyFrame
115
+ Renamed ``(b_f, d, t, weight)`` lazy frame for per-arc weight
116
+ materialisation.
117
+ block_period_time_first_lf : pl.LazyFrame
118
+ Renamed ``(b, d, t)`` lazy frame for first-step boundaries.
119
+ block_period_time_last_lf : pl.LazyFrame
120
+ Renamed ``(b, d, t)`` lazy frame for last-step boundaries.
121
+ """
122
+
123
+ layout: BlockLayout
124
+ _block_compat_cached: pl.DataFrame | None = field(default=None, repr=False)
125
+ _coarse_blocks_cached: list[str] | None = field(default=None, repr=False)
126
+ _is_multi_block_cached: bool | None = field(default=None, repr=False)
127
+
128
+ # ------------------------------------------------------------------
129
+ # Lazy-frame surface (joins consume these)
130
+ # ------------------------------------------------------------------
131
+
132
+ @property
133
+ def process_side_block_lf(self) -> pl.LazyFrame:
134
+ """Lazy ``(p, side, b_f)`` frame, or empty when no block data."""
135
+ f = self.layout.process_side_block_frame
136
+ if f.height == 0:
137
+ return pl.LazyFrame(schema={
138
+ "p": schema_dtype(_enums, "p"),
139
+ "side": pl.Utf8,
140
+ "b_f": schema_dtype(_enums, "b_f"),
141
+ })
142
+ return f.lazy().pipe(rename_to_axis, {"process": "p", "block": "b_f"})
143
+
144
+ @property
145
+ def entity_block_lf(self) -> pl.LazyFrame:
146
+ """Lazy ``(n, bk)`` frame, or empty when no block data.
147
+
148
+ The block axis column is named ``bk`` (not ``b``) to disambiguate
149
+ from the branch axis — see the b_collision review note in
150
+ ``schemas/flextool_axis_contract.json``.
151
+ """
152
+ f = self.layout.entity_block_frame
153
+ if f.height == 0:
154
+ return pl.LazyFrame(schema={
155
+ "n": schema_dtype(_enums, "n"),
156
+ "bk": schema_dtype(_enums, "bk"),
157
+ })
158
+ return f.lazy().pipe(rename_to_axis, {"entity": "n", "block": "bk"})
159
+
160
+ @property
161
+ def block_step_duration_arc_lf(self) -> pl.LazyFrame:
162
+ """Lazy ``(b_f, d, t, weight)`` arc-keyed step duration."""
163
+ f = self.layout.block_step_duration_frame
164
+ if f.height == 0:
165
+ return pl.LazyFrame(schema={
166
+ "b_f": pl.Utf8,
167
+ "d": schema_dtype(_enums, "d"),
168
+ "t": schema_dtype(_enums, "t"),
169
+ "weight": pl.Float64,
170
+ })
171
+ return f.lazy().pipe(rename_to_axis, {
172
+ "block": "b_f", "period": "d",
173
+ "step": "t", "step_duration": "weight",
174
+ })
175
+
176
+ @property
177
+ def block_period_time_first_lf(self) -> pl.LazyFrame:
178
+ f = self.layout.block_period_time_first_frame
179
+ if f.height == 0:
180
+ return pl.LazyFrame(schema={
181
+ "bk": schema_dtype(_enums, "bk"),
182
+ "d": schema_dtype(_enums, "d"),
183
+ "t": schema_dtype(_enums, "t"),
184
+ })
185
+ return f.lazy().pipe(rename_to_axis, {"block": "bk", "period": "d", "step": "t"})
186
+
187
+ @property
188
+ def block_period_time_last_lf(self) -> pl.LazyFrame:
189
+ f = self.layout.block_period_time_last_frame
190
+ if f.height == 0:
191
+ return pl.LazyFrame(schema={
192
+ "bk": schema_dtype(_enums, "bk"),
193
+ "d": schema_dtype(_enums, "d"),
194
+ "t": schema_dtype(_enums, "t"),
195
+ })
196
+ return f.lazy().pipe(rename_to_axis, {"block": "bk", "period": "d", "step": "t"})
197
+
198
+ @property
199
+ def block_compat_frame(self) -> pl.DataFrame:
200
+ """Cached ``(b, b_f)`` overlap-derived compatibility set.
201
+
202
+ Computed on first access from ``layout.overlap_set_frame``;
203
+ materialised once per :class:`BlockBundle` instance.
204
+ """
205
+ if self._block_compat_cached is None:
206
+ self._block_compat_cached = self.layout.block_compat()
207
+ return self._block_compat_cached
208
+
209
+ def is_multi_block(self) -> bool:
210
+ """Return ``True`` when the layout exercises >1 distinct block.
211
+
212
+ Cached: layout is immutable for the bundle's lifetime.
213
+ """
214
+ if self._is_multi_block_cached is None:
215
+ bsd = self.layout.block_step_duration_frame
216
+ if bsd.height == 0:
217
+ self._is_multi_block_cached = False
218
+ else:
219
+ self._is_multi_block_cached = bsd["block"].n_unique() >= 2
220
+ return self._is_multi_block_cached
221
+
222
+ def coarse_blocks(self, threshold: float = 1.0) -> list[str]:
223
+ """Return blocks with at least one row of ``step_duration >
224
+ threshold``.
225
+
226
+ Cached for the canonical ``threshold=1.0`` (the schematic's
227
+ rule for "coarse" blocks). Other thresholds bypass the cache.
228
+ """
229
+ if threshold == 1.0:
230
+ if self._coarse_blocks_cached is None:
231
+ self._coarse_blocks_cached = self.layout.coarse_blocks(
232
+ threshold=1.0,
233
+ )
234
+ return self._coarse_blocks_cached
235
+ return self.layout.coarse_blocks(threshold=threshold)
236
+
237
+ def has_block_data(self) -> bool:
238
+ """Return ``True`` when any block frame has data."""
239
+ return (
240
+ self.layout.process_side_block_frame.height > 0
241
+ or self.layout.entity_block_frame.height > 0
242
+ or self.layout.block_step_duration_frame.height > 0
243
+ )
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Workdir bridge — load BlockBundle from solve_data/ CSVs
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ def load_block_bundle(
252
+ workdir: Path | None,
253
+ *,
254
+ block_layout: "BlockLayout | None" = None,
255
+ provider: "object | None" = None,
256
+ ) -> BlockBundle | None:
257
+ """Load a :class:`BlockBundle` from a Provider (preferred) or an
258
+ in-memory layout.
259
+
260
+ When ``block_layout`` is supplied (Phase 2 multi-block fast-path),
261
+ wrap that layout directly. Otherwise route through
262
+ :meth:`BlockLayout.load_from_solve_data` against *provider*.
263
+
264
+ The legacy ``workdir``-only signature is honoured by seeding an
265
+ ephemeral Provider from ``workdir/solve_data`` via
266
+ :func:`_input_source.seed_provider_from_dir`. This keeps off-
267
+ cascade tests (and the few callers that still pass a workdir
268
+ without a Provider) working while the cascade itself never
269
+ touches disk.
270
+
271
+ Returns ``None`` if neither input yields a non-empty layout.
272
+ """
273
+ if block_layout is not None:
274
+ if block_layout.is_empty():
275
+ return None
276
+ return BlockBundle(layout=block_layout)
277
+ if provider is None and workdir is None:
278
+ return None
279
+ if provider is None and workdir is not None:
280
+ sd = Path(workdir) / "solve_data"
281
+ if not sd.exists():
282
+ return None
283
+ sd = (
284
+ Path(workdir) / "solve_data" if workdir is not None
285
+ else Path(".")
286
+ )
287
+ layout = BlockLayout.load_from_solve_data(sd, provider=provider)
288
+ if layout.is_empty():
289
+ return None
290
+ return BlockBundle(layout=layout)
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # §3.3.1 — flow_to_n / flow_from_n block-aware filter (Δ.3 gap closure)
295
+ # ---------------------------------------------------------------------------
296
+
297
+
298
+ def filter_flow_n_by_block(
299
+ flow_n: pl.DataFrame,
300
+ bundle: BlockBundle | None,
301
+ *,
302
+ side: str,
303
+ ) -> pl.DataFrame:
304
+ """Apply block-compatibility filter to ``flow_to_n`` / ``flow_from_n``.
305
+
306
+ Mirror of ``input.py::_load_process_topology`` lines 728-782. An
307
+ arc ``(p, source, sink)`` contributes to node ``n``'s nodeBalance
308
+ iff ``(b_n, b_f)`` exists in :py:attr:`BlockBundle.block_compat_frame`,
309
+ where:
310
+
311
+ * ``b_n`` is the entity-block of the destination node ``n``
312
+ (default = ``DEFAULT_BLOCK`` when missing).
313
+ * ``b_f`` is the process-side block on *side* (default =
314
+ ``DEFAULT_BLOCK`` when missing).
315
+
316
+ Parameters
317
+ ----------
318
+ flow_n : pl.DataFrame
319
+ Schema ``[p, source, sink, n]``.
320
+ bundle : BlockBundle or None
321
+ When ``None`` or empty / single-block, filter is a no-op
322
+ (returns ``flow_n`` unchanged).
323
+ side : str
324
+ ``"sink"`` (for ``flow_to_n``) or ``"source"``
325
+ (for ``flow_from_n``).
326
+
327
+ Returns
328
+ -------
329
+ pl.DataFrame
330
+ The filtered frame. Mirrors the reference's "only replace if
331
+ the filter actually drops rows" guard so empty-overlap fixtures
332
+ keep their pre-filter shape.
333
+ """
334
+ if flow_n is None or flow_n.height == 0:
335
+ return flow_n
336
+ if bundle is None:
337
+ return flow_n
338
+ psb_f = bundle.layout.process_side_block_frame
339
+ eb_f = bundle.layout.entity_block_frame
340
+ compat = bundle.block_compat_frame
341
+ if psb_f.height == 0 or eb_f.height == 0 or compat.height == 0:
342
+ return flow_n
343
+
344
+ psb_side = (
345
+ bundle.process_side_block_lf
346
+ .filter(pl.col("side") == side)
347
+ .select("p", "b_f")
348
+ )
349
+ eb_lf = bundle.entity_block_lf
350
+
351
+ # Align ``n`` dtype across both sides. Under activation
352
+ # ``flow_n.n`` carries e-vocabulary (node + process tokens for
353
+ # indirect units' arcs); entity_block_lf.n carries n-vocabulary
354
+ # (nodes only). Per contract n ⊂ e, so up-cast entity_block_lf.n
355
+ # to e-Enum and the join composes natively in Enum. Process
356
+ # tokens in flow_n.n won't match any entity_block_lf row (left
357
+ # join produces null block info) and the subsequent inner join
358
+ # with ``compat`` drops them — same semantics as the prior
359
+ # Utf8-roundtrip but without the materialisation.
360
+ #
361
+ # Block join keys (bk, b_f) use the cast_dim fill: substrate
362
+ # produces them as block-Enum under activation, Utf8 otherwise.
363
+ # The ``DEFAULT_BLOCK`` fill must use lit_axis so the literal
364
+ # matches the column dtype.
365
+ eb_lf_e = eb_lf.with_columns(cast_dim(pl.col("n"), None, "e"))
366
+ flow_n_e = flow_n.with_columns(cast_dim(pl.col("n"), None, "e"))
367
+ with_blocks = (
368
+ flow_n_e.lazy()
369
+ .join(psb_side, on="p", how="left")
370
+ .join(eb_lf_e, on="n", how="left")
371
+ .with_columns(
372
+ b_f=pl.col("b_f").fill_null(lit_axis(DEFAULT_BLOCK, "block")),
373
+ bk=pl.col("bk").fill_null(lit_axis(DEFAULT_BLOCK, "block")),
374
+ )
375
+ )
376
+ filtered = (
377
+ with_blocks
378
+ .join(compat.lazy(), on=["bk", "b_f"], how="inner")
379
+ .select("p", "source", "sink", "n")
380
+ .unique()
381
+ .collect()
382
+ )
383
+ # Match reference: only replace if filter dropped rows. The
384
+ # ``filtered.height > 0`` guard preserves the pre-filter frame for
385
+ # fixtures whose overlap_set is degenerate-but-non-empty.
386
+ if 0 < filtered.height < flow_n.height:
387
+ return filtered
388
+ return flow_n
389
+
390
+
391
+ def flow_to_n_block_filtered(
392
+ pss: pl.DataFrame,
393
+ bundle: BlockBundle | None,
394
+ ) -> pl.DataFrame:
395
+ """Build ``flow_to_n`` (``n = sink``) and apply the block-aware filter.
396
+
397
+ Schema: ``[p, source, sink, n]``. The block-aware filter is the
398
+ Δ.3 gap closure — flextool's CSV path filters in
399
+ ``_load_process_topology``; the source-driven path now mirrors the
400
+ same filter.
401
+ """
402
+ if pss is None or pss.height == 0:
403
+ # Empty-fallback dtype: ``n`` carries e-vocabulary (sink/source
404
+ # values are e-typed under activation; the column legitimately
405
+ # mixes node + process tokens for indirect units' arcs).
406
+ # Declare it ``e`` so empty and populated branches agree.
407
+ return pl.DataFrame(schema={
408
+ "p": schema_dtype(_enums, "p"),
409
+ "source": schema_dtype(_enums, "source"),
410
+ "sink": schema_dtype(_enums, "sink"),
411
+ "n": schema_dtype(_enums, "e"),
412
+ })
413
+ # Cross-axis projection: ``sink`` carries e-axis tokens (mix of
414
+ # node + process names). ``alias_to_axis("sink", "e")`` casts
415
+ # to e-Enum under activation, preserving every token. The
416
+ # downstream block-filter join in ``filter_flow_n_by_block``
417
+ # also up-casts entity_block_lf.n to e-Enum (n ⊂ e), so the
418
+ # join composes natively without Utf8 materialisation.
419
+ base = (
420
+ pss.lazy()
421
+ .with_columns(alias_to_axis(pl.col("sink"), "e").alias("n"))
422
+ .select("p", "source", "sink", "n")
423
+ .sort("p", "source", "sink", "n")
424
+ .collect()
425
+ )
426
+ return filter_flow_n_by_block(base, bundle, side="sink")
427
+
428
+
429
+ def flow_from_n_block_filtered(
430
+ pss: pl.DataFrame,
431
+ bundle: BlockBundle | None,
432
+ ) -> pl.DataFrame:
433
+ """Build ``flow_from_n`` (``n = source``) with block-aware filter."""
434
+ if pss is None or pss.height == 0:
435
+ return pl.DataFrame(schema={
436
+ "p": schema_dtype(_enums, "p"),
437
+ "source": schema_dtype(_enums, "source"),
438
+ "sink": schema_dtype(_enums, "sink"),
439
+ "n": schema_dtype(_enums, "e"),
440
+ })
441
+ base = (
442
+ pss.lazy()
443
+ # Cross-axis projection: ``source`` is e-axis (node + process
444
+ # union). Cast to e-Enum (preserves every token) so the
445
+ # downstream block-filter join on ``n`` composes natively in
446
+ # Enum after entity_block_lf.n is up-cast to e as well.
447
+ .with_columns(alias_to_axis(pl.col("source"), "e").alias("n"))
448
+ .select("p", "source", "sink", "n")
449
+ .sort("p", "source", "sink", "n")
450
+ .collect()
451
+ )
452
+ return filter_flow_n_by_block(base, bundle, side="source")
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # §3.9 — flow_from_nodeBalance block filter
457
+ # ---------------------------------------------------------------------------
458
+
459
+
460
+ def flow_from_nodeBalance_block_filtered(
461
+ flow_from_nb: pl.DataFrame | None,
462
+ bundle: BlockBundle | None,
463
+ ) -> pl.DataFrame | None:
464
+ """Apply the block-compatibility filter to source-side nodeBalance arcs.
465
+
466
+ Mirror of ``input.py::_load_storage`` lines 1664-1699. The
467
+ ``flow_from_nodeBalance_eff`` / ``flow_from_nodeBalance_noEff``
468
+ frames carry ``(p, source, sink, n=source)``; the filter drops arcs
469
+ whose source-block doesn't overlap the destination node's block.
470
+
471
+ Returns the filtered frame (possibly identical to input when no
472
+ filter applies). ``None`` in → ``None`` out.
473
+ """
474
+ if flow_from_nb is None or flow_from_nb.height == 0:
475
+ return flow_from_nb
476
+ if bundle is None:
477
+ return flow_from_nb
478
+ return filter_flow_n_by_block(flow_from_nb, bundle, side="source")
479
+
480
+
481
+ # ---------------------------------------------------------------------------
482
+ # Δ.27 — flow_from_nodeBalance_{eff,noEff} source-driven seed
483
+ # ---------------------------------------------------------------------------
484
+
485
+
486
+ def flow_from_nodeBalance_seed(
487
+ pss_partition: pl.DataFrame | None,
488
+ nodeBalance: pl.DataFrame | None,
489
+ bundle: BlockBundle | None = None,
490
+ ) -> pl.DataFrame | None:
491
+ """Source-driven seed for ``flow_from_nodeBalance_eff`` /
492
+ ``flow_from_nodeBalance_noEff``.
493
+
494
+ Mirror of the inline derivation in
495
+ ``input.py::_load_storage`` lines 1658-1705 (the slow path's
496
+ storage loader). Per the dispatch:
497
+
498
+ ::
499
+
500
+ flow_from_nb_<part> = pss_<part>
501
+ .filter(source ∈ nodeBalance.n)
502
+ .with_columns(n=source)
503
+ .select(p, source, sink, n)
504
+
505
+ Then the block-compatibility filter (when *bundle* carries an
506
+ ``overlap_set``) drops arc rows whose source-block doesn't overlap
507
+ the destination node's block.
508
+
509
+ The two partitions (``eff`` / ``noEff``) share this logic; the
510
+ caller passes the appropriate ``process_source_sink_eff`` /
511
+ ``_noEff`` partition to produce the matching field.
512
+
513
+ Parameters
514
+ ----------
515
+ pss_partition : pl.DataFrame or None
516
+ ``process_source_sink_eff`` or ``_noEff`` — schema
517
+ ``[p, source, sink]``. ``None`` → returns ``None``.
518
+ nodeBalance : pl.DataFrame or None
519
+ ``[n]`` set frame. Empty / ``None`` → returns ``None`` (no
520
+ nodeBalance nodes means there are no source-side flows to
521
+ gather).
522
+ bundle : BlockBundle or None
523
+ When supplied, applies the source-side block-compat filter
524
+ (mirrors :func:`flow_from_nodeBalance_block_filtered`).
525
+ ``None`` keeps the unfiltered seed.
526
+
527
+ Returns
528
+ -------
529
+ pl.DataFrame or None
530
+ ``[p, source, sink, n]`` with ``n = source`` and the filter
531
+ applied. ``None`` when the inputs cannot produce a non-empty
532
+ frame.
533
+ """
534
+ if pss_partition is None or pss_partition.height == 0:
535
+ return None
536
+ if nodeBalance is None or nodeBalance.height == 0:
537
+ return None
538
+ if "n" not in nodeBalance.columns:
539
+ return None
540
+ # Cross-axis is_in: nodeBalance.n is n-Enum (n ⊂ e), pss.source is
541
+ # e-Enum. Up-cast nodeBalance.n to e-Enum (Pattern 2) so the
542
+ # membership filter composes natively in Enum. Project ``n`` as
543
+ # e-Enum via alias_to_axis — matches the downstream block-filter
544
+ # join in :func:`filter_flow_n_by_block`, which also up-casts
545
+ # entity_block_lf.n to e-Enum.
546
+ nb_nodes_e = nodeBalance.lazy().select(cast_dim(pl.col("n"), None, "e"))
547
+ seed = (
548
+ pss_partition.lazy()
549
+ .filter(pl.col("source").is_in(nb_nodes_e.collect()["n"]))
550
+ .with_columns(alias_to_axis(pl.col("source"), "e").alias("n"))
551
+ .select("p", "source", "sink", "n")
552
+ .unique()
553
+ .sort("p", "source", "sink", "n")
554
+ .collect()
555
+ )
556
+ if seed.height == 0:
557
+ return None
558
+ if bundle is not None:
559
+ return filter_flow_n_by_block(seed, bundle, side="source")
560
+ return seed
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # §3.9.2 — nodeStateBlock multi-resolution synthesis
565
+ # ---------------------------------------------------------------------------
566
+
567
+
568
+ def nodeStateBlock_lf(
569
+ bundle: BlockBundle | None,
570
+ explicit_intraperiod: pl.LazyFrame | None,
571
+ node_set: set[str] | None = None,
572
+ *,
573
+ coarse_threshold: float = 1.0,
574
+ ) -> pl.LazyFrame:
575
+ """Synthesise ``nodeStateBlock`` per audit §3.9.2.
576
+
577
+ Two contributing branches:
578
+
579
+ 1. **Explicit method**: nodes whose
580
+ ``storage_binding_method == 'bind_intraperiod_blocks'`` enter
581
+ the set verbatim.
582
+ 2. **Multi-resolution synthesis**: when ``bundle`` carries >=2
583
+ distinct blocks AND a node entity is assigned a coarse block
584
+ (``step_duration > coarse_threshold``), that node is folded
585
+ into the set so the daily-aggregation balance fires.
586
+
587
+ Returns a lazy ``[n]`` frame, possibly empty.
588
+ """
589
+ parts: list[pl.LazyFrame] = []
590
+ if explicit_intraperiod is not None:
591
+ parts.append(
592
+ explicit_intraperiod.select("n").unique()
593
+ )
594
+ if bundle is not None and bundle.is_multi_block():
595
+ coarse = bundle.coarse_blocks(threshold=coarse_threshold)
596
+ if coarse:
597
+ eb_lf = bundle.entity_block_lf
598
+ picked = (
599
+ eb_lf
600
+ .filter(pl.col("bk").is_in(coarse))
601
+ .select(pl.col("n"))
602
+ )
603
+ if node_set is not None:
604
+ picked = picked.filter(
605
+ pl.col("n").is_in(list(node_set)))
606
+ parts.append(picked.unique())
607
+ if not parts:
608
+ return pl.LazyFrame(schema={"n": schema_dtype(_enums, "n")})
609
+ out = pl.concat(parts).unique().sort("n")
610
+ return out
611
+
612
+
613
+ # ---------------------------------------------------------------------------
614
+ # §3.9.3 — period_block / period_block_succ / period_block_time
615
+ # ---------------------------------------------------------------------------
616
+
617
+
618
+ def period_block_multi_resolution_lf(
619
+ bundle: BlockBundle | None,
620
+ *,
621
+ coarse_threshold: float = 1.0,
622
+ ) -> dict[str, pl.LazyFrame] | None:
623
+ """Build the multi-resolution synthesis branch of period_block_*.
624
+
625
+ Returns a dict ``{"period_block": LF, "period_block_succ": LF,
626
+ "period_block_time": LF}`` when *bundle* exercises multiple blocks
627
+ AND at least one block is coarse (per *coarse_threshold*). Returns
628
+ ``None`` when the synthesis branch doesn't fire (caller falls back
629
+ to the timeset-based default branch).
630
+
631
+ Mirror of ``input.py:1985-2126`` and the multi-resolution path of
632
+ ``period_block_family_from_source`` in ``_derived_params.py``.
633
+ """
634
+ if bundle is None or not bundle.is_multi_block():
635
+ return None
636
+ coarse = bundle.coarse_blocks(threshold=coarse_threshold)
637
+ if not coarse:
638
+ return None
639
+ eb = bundle.layout.entity_block_frame
640
+ coarse_use_set = set(
641
+ eb.filter(pl.col("block").is_in(coarse))["block"]
642
+ .unique().to_list()
643
+ )
644
+ if not coarse_use_set:
645
+ return None
646
+ coarse_use = sorted(coarse_use_set)
647
+
648
+ bsd = bundle.layout.block_step_duration_frame
649
+ bsd_c = bsd.filter(pl.col("block").is_in(coarse_use))
650
+ if bsd_c.height == 0:
651
+ return None
652
+
653
+ # period_block: (d, b_first) — coarse block step list.
654
+ new_pb = (
655
+ bsd_c
656
+ .pipe(rename_to_axis, {"period": "d", "step": "b_first"})
657
+ .select("d", "b_first")
658
+ .unique()
659
+ )
660
+
661
+ # period_block_succ: cyclic per (block, period).
662
+ #
663
+ # WARNING — legacy mirror, NOT on any live path (this function is
664
+ # exported in ``__all__`` but has no caller; the live producer is
665
+ # ``_derived_params.period_block_family_from_source``). The live
666
+ # producer segments the cyclic loop PER representative period so a
667
+ # coarse node cannot chain storage across months-apart representative
668
+ # days (the coarse-storage relaxation bug). This mirror still closes
669
+ # ONE loop over the whole period. If this function is ever revived it
670
+ # MUST replicate that seam segmentation (see ``_repday_segment_map``);
671
+ # doing it here needs the fine-timeline ranks, which this ``bundle``-
672
+ # only signature does not carry.
673
+ succ_rows: list[tuple[str, str, str]] = []
674
+ bsd_sorted = (
675
+ bsd_c
676
+ .pipe(rename_to_axis, {"period": "d", "step": "b_first"})
677
+ .sort("block", "d", "b_first")
678
+ )
679
+ for (_blk, dval), grp in bsd_sorted.group_by(
680
+ ["block", "d"], maintain_order=True
681
+ ):
682
+ bfs = grp["b_first"].to_list()
683
+ n = len(bfs)
684
+ for i in range(n):
685
+ succ_rows.append((dval, bfs[i], bfs[(i + 1) % n]))
686
+ if succ_rows:
687
+ new_pbs = pl.DataFrame(
688
+ succ_rows,
689
+ schema=["d", "b_first", "b_next"],
690
+ orient="row",
691
+ ).with_columns(
692
+ alias_to_axis("d", "d"),
693
+ alias_to_axis("b_first", "b_first"),
694
+ alias_to_axis("b_next", "b_next"),
695
+ )
696
+ else:
697
+ new_pbs = pl.DataFrame(schema={
698
+ "d": schema_dtype(_enums, "d"),
699
+ "b_first": schema_dtype(_enums, "b_first"),
700
+ "b_next": schema_dtype(_enums, "b_next"),
701
+ })
702
+
703
+ # period_block_time: (d, b_first, t) — overlap_set rows where
704
+ # b_coarse=coarse, b_fine=default.
705
+ ov = bundle.layout.overlap_set_frame
706
+ if ov.height == 0:
707
+ return None
708
+ ov_renamed = ov.pipe(rename_to_axis, {
709
+ "period": "d",
710
+ "block_coarse": "bk",
711
+ "step_coarse": "b_first",
712
+ "block_fine": "b_fine",
713
+ "step_fine": "t",
714
+ })
715
+ ov_keep = ov_renamed.filter(
716
+ pl.col("bk").is_in(coarse_use)
717
+ & (pl.col("b_fine") == DEFAULT_BLOCK)
718
+ )
719
+ if ov_keep.height == 0:
720
+ new_pbt = pl.DataFrame(schema={
721
+ "d": schema_dtype(_enums, "d"),
722
+ "b_first": schema_dtype(_enums, "b_first"),
723
+ "t": schema_dtype(_enums, "t"),
724
+ })
725
+ else:
726
+ new_pbt = ov_keep.select("d", "b_first", "t").unique()
727
+
728
+ return {
729
+ "period_block": new_pb.lazy(),
730
+ "period_block_succ": new_pbs.lazy(),
731
+ "period_block_time": new_pbt.lazy(),
732
+ }
733
+
734
+
735
+ # ---------------------------------------------------------------------------
736
+ # §3.9.4 — arc_sink_block_dt / arc_source_block_dt + weights
737
+ # ---------------------------------------------------------------------------
738
+
739
+
740
+ @dataclass
741
+ class ArcBlockFrames:
742
+ """Container for the cluster E arc-block aggregation frames."""
743
+
744
+ arc_sink_block_dt: pl.DataFrame | None = None
745
+ arc_source_block_dt: pl.DataFrame | None = None
746
+ p_arc_sink_weight: Param | None = None
747
+ p_arc_source_weight: Param | None = None
748
+
749
+
750
+ def arc_block_dt(
751
+ pss: pl.DataFrame | None,
752
+ nodeStateBlock: pl.DataFrame | None,
753
+ period_block_time: pl.DataFrame | None,
754
+ bundle: BlockBundle | None,
755
+ ) -> ArcBlockFrames:
756
+ """Build per-arc daily-block aggregation frames.
757
+
758
+ For each arc ``(p, source, sink)`` whose nodeStateBlock side participates
759
+ in ``nodeStateBlock``, project to ``(p, source, sink, d, b_first, t,
760
+ weight)`` with ``weight = block_step_duration`` of the arc-side block
761
+ at fine ``(d, t)``.
762
+
763
+ Returns an :class:`ArcBlockFrames` with up to four populated fields;
764
+ any may be ``None`` when the corresponding side has no rows.
765
+ """
766
+ out = ArcBlockFrames()
767
+ if (bundle is None
768
+ or not bundle.has_block_data()
769
+ or pss is None or pss.height == 0
770
+ or nodeStateBlock is None or nodeStateBlock.height == 0
771
+ or period_block_time is None or period_block_time.height == 0):
772
+ return out
773
+
774
+ psb_f = bundle.layout.process_side_block_frame
775
+ bsd_f = bundle.layout.block_step_duration_frame
776
+ if psb_f.height == 0 or bsd_f.height == 0:
777
+ return out
778
+
779
+ # Phase 4.8f: defend axis-aware join keys on incoming frame params.
780
+ # ``pss`` joins on ``["d", "t"]`` against pbt and on entity-axis cols
781
+ # via ``is_in`` below; ``nodeStateBlock`` provides the n→entity set.
782
+ _enums = get_global_axis_enums()
783
+ if _enums is not None:
784
+ pss = cast_frame_axes(pss, _enums)
785
+ nodeStateBlock = cast_frame_axes(nodeStateBlock, _enums)
786
+ period_block_time = cast_frame_axes(period_block_time, _enums)
787
+
788
+ psb = bundle.process_side_block_lf
789
+ bsd_arc = bundle.block_step_duration_arc_lf
790
+ # Phase 4.8f: ``is_in`` against ``pss['sink']`` / ``pss['source']``
791
+ # whose canonical axis is the ``entity`` union Enum vocabulary —
792
+ # cannot pass a node-Enum Series. Use a 1-column frame + semi join.
793
+ nsb_set_e_df = (nodeStateBlock.select(pl.col("n").alias("e")).unique())
794
+ if _enums is not None:
795
+ nsb_set_e_df = cast_frame_axes(nsb_set_e_df, _enums)
796
+ pbt = period_block_time
797
+
798
+ psb_sink = psb.filter(pl.col("side") == "sink").select("p", "b_f")
799
+ sink_arcs = (
800
+ pss.lazy()
801
+ .join(nsb_set_e_df.lazy().rename({"e": "sink"}), on="sink", how="semi")
802
+ .join(psb_sink, on="p", how="inner")
803
+ )
804
+ sink_ab = (
805
+ sink_arcs
806
+ .join(bsd_arc, on="b_f", how="inner")
807
+ .join(pbt.lazy(), on=["d", "t"], how="inner")
808
+ .select("p", "source", "sink", "d", "b_first", "t", "weight")
809
+ .unique()
810
+ .collect()
811
+ )
812
+ if sink_ab.height > 0:
813
+ out.arc_sink_block_dt = sink_ab
814
+ wf = (
815
+ sink_ab.select("p", "source", "sink", "d", "t", "weight")
816
+ .unique()
817
+ .rename({"weight": "value"})
818
+ )
819
+ out.p_arc_sink_weight = Param(
820
+ ("p", "source", "sink", "d", "t"), wf,
821
+ )
822
+
823
+ psb_src = psb.filter(pl.col("side") == "source").select("p", "b_f")
824
+ src_arcs = (
825
+ pss.lazy()
826
+ .join(nsb_set_e_df.lazy().rename({"e": "source"}), on="source", how="semi")
827
+ .join(psb_src, on="p", how="inner")
828
+ )
829
+ src_ab = (
830
+ src_arcs
831
+ .join(bsd_arc, on="b_f", how="inner")
832
+ .join(pbt.lazy(), on=["d", "t"], how="inner")
833
+ .select("p", "source", "sink", "d", "b_first", "t", "weight")
834
+ .unique()
835
+ .collect()
836
+ )
837
+ if src_ab.height > 0:
838
+ out.arc_source_block_dt = src_ab
839
+ wf = (
840
+ src_ab.select("p", "source", "sink", "d", "t", "weight")
841
+ .unique()
842
+ .rename({"weight": "value"})
843
+ )
844
+ out.p_arc_source_weight = Param(
845
+ ("p", "source", "sink", "d", "t"), wf,
846
+ )
847
+ return out
848
+
849
+
850
+ # ---------------------------------------------------------------------------
851
+ # §3.9 — nodeState_last_dt
852
+ # ---------------------------------------------------------------------------
853
+
854
+
855
+ def nodeState_last_dt_lf(
856
+ nodeState: pl.DataFrame | None,
857
+ bundle: BlockBundle | None,
858
+ ) -> pl.LazyFrame:
859
+ """Build ``nodeState_last_dt`` ``(n, d, t)``.
860
+
861
+ Last-fine-step-of-last-block per node. Built from
862
+ ``block_period_time_last`` × ``entity_block`` × ``nodeState``.
863
+ Mirror of ``input.py:2233-2253``.
864
+ """
865
+ empty = pl.LazyFrame(schema={
866
+ "n": schema_dtype(_enums, "n"),
867
+ "d": schema_dtype(_enums, "d"),
868
+ "t": schema_dtype(_enums, "t"),
869
+ })
870
+ if nodeState is None or nodeState.height == 0:
871
+ return empty
872
+ if bundle is None:
873
+ return empty
874
+ bptl_f = bundle.layout.block_period_time_last_frame
875
+ eb_f = bundle.layout.entity_block_frame
876
+ if bptl_f.height == 0 or eb_f.height == 0:
877
+ return empty
878
+ # Phase 4.8f: defend axis-aware join keys on incoming frame param.
879
+ _live = get_global_axis_enums()
880
+ if _live is not None:
881
+ nodeState = cast_frame_axes(nodeState, _live)
882
+ return (
883
+ nodeState.lazy().select("n")
884
+ .join(bundle.entity_block_lf, on="n", how="inner")
885
+ .join(bundle.block_period_time_last_lf, on="bk", how="inner")
886
+ .select("n", "d", "t")
887
+ .unique()
888
+ )
889
+
890
+
891
+ # ---------------------------------------------------------------------------
892
+ # §3.9 — dtttdt_block_interior
893
+ # ---------------------------------------------------------------------------
894
+
895
+
896
+ def dtttdt_block_interior_lf(
897
+ dtttdt: pl.DataFrame | None,
898
+ period_block_time: pl.DataFrame | None,
899
+ ) -> pl.LazyFrame:
900
+ """Interior-of-block dtttdt rows.
901
+
902
+ Two paths matching ``input.py``'s branching:
903
+
904
+ 1. **Default** (timeset-block decomposition): keep dtttdt rows where
905
+ ``t_previous_within_timeset == t_previous`` (jump=1 interior).
906
+ 2. **Synthesised (multi-resolution)**: when *period_block_time* has
907
+ multiple ``b_first`` per period, rebuild interior pairs from the
908
+ coarse block decomposition: per (d, b_first), consecutive sorted
909
+ fine t's give intra-day predecessor pairs.
910
+
911
+ The caller distinguishes via *period_block_time*'s shape; this
912
+ helper detects automatically.
913
+ """
914
+ empty = pl.LazyFrame(schema={
915
+ "d": schema_dtype(_enums, "d"),
916
+ "t": schema_dtype(_enums, "t"),
917
+ "t_previous": schema_dtype(_enums, "t_previous"),
918
+ })
919
+ if dtttdt is None or dtttdt.height == 0:
920
+ return empty
921
+ multi_res = False
922
+ if period_block_time is not None and period_block_time.height > 0:
923
+ nb = (
924
+ period_block_time
925
+ .group_by("d")
926
+ .agg(pl.col("b_first").n_unique().alias("nb"))
927
+ ["nb"].max()
928
+ )
929
+ if nb is not None and nb > 1:
930
+ multi_res = True
931
+ if multi_res and period_block_time is not None:
932
+ rows: list[tuple[str, str, str]] = []
933
+ pbt_sorted = period_block_time.sort("d", "b_first", "t")
934
+ for (dval, _bf), grp in pbt_sorted.group_by(
935
+ ["d", "b_first"], maintain_order=True
936
+ ):
937
+ ts = grp["t"].to_list()
938
+ for i in range(1, len(ts)):
939
+ rows.append((dval, ts[i], ts[i - 1]))
940
+ if not rows:
941
+ return empty
942
+ return (
943
+ pl.DataFrame(
944
+ rows,
945
+ schema=["d", "t", "t_previous"],
946
+ orient="row",
947
+ )
948
+ .with_columns(
949
+ alias_to_axis("d", "d"),
950
+ alias_to_axis("t", "t"),
951
+ alias_to_axis("t_previous", "t_previous"),
952
+ )
953
+ .unique()
954
+ .lazy()
955
+ )
956
+ if "t_previous_within_timeset" not in dtttdt.columns:
957
+ return empty
958
+ # Defensive re-cast: align d / t / t_previous to canonical Enum so
959
+ # the returned schema matches the empty-fallback and multi_res
960
+ # branches above even when ``dtttdt`` arrives with Utf8 axis columns.
961
+ return (
962
+ dtttdt.lazy()
963
+ .filter(pl.col("t_previous_within_timeset")
964
+ == pl.col("t_previous"))
965
+ .select(alias_to_axis("d", "d"),
966
+ alias_to_axis("t", "t"),
967
+ alias_to_axis("t_previous", "t_previous"))
968
+ )
969
+
970
+
971
+ # ---------------------------------------------------------------------------
972
+ # Public — apply_block_cluster: single-pass entry for apply_derived_e.
973
+ # ---------------------------------------------------------------------------
974
+
975
+
976
+ __all__ = [
977
+ "BlockBundle",
978
+ "load_block_bundle",
979
+ "filter_flow_n_by_block",
980
+ "flow_to_n_block_filtered",
981
+ "flow_from_n_block_filtered",
982
+ "flow_from_nodeBalance_block_filtered",
983
+ "flow_from_nodeBalance_seed",
984
+ "nodeStateBlock_lf",
985
+ "period_block_multi_resolution_lf",
986
+ "arc_block_dt",
987
+ "ArcBlockFrames",
988
+ "nodeState_last_dt_lf",
989
+ "dtttdt_block_interior_lf",
990
+ ]